@commonlyai/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1264 @@
1
+ /**
2
+ * commonly agent <subcommand>
3
+ *
4
+ * register — register a webhook agent against an instance
5
+ * connect — local dev loop: poll events → forward to localhost
6
+ * attach — wrap a local CLI as a Commonly agent (ADR-005)
7
+ * run — poll events, spawn the wrapped CLI, post results (ADR-005)
8
+ * init — scaffold a webhook-SDK agent in the current dir (ADR-006)
9
+ * list — list installed agents
10
+ * logs — stream recent events for an agent
11
+ * heartbeat — manually trigger an agent's heartbeat
12
+ */
13
+
14
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync, readdirSync } from 'fs';
15
+ import { join, dirname, resolve as pathResolve } from 'path';
16
+ import { homedir, tmpdir } from 'os';
17
+ import { fileURLToPath } from 'url';
18
+
19
+ import { createClient } from '../lib/api.js';
20
+ import { getToken, resolveInstanceUrl } from '../lib/config.js';
21
+ import { startPoller } from '../lib/poller.js';
22
+ import { startWebhookServer, forwardToLocalWebhook } from '../lib/webhook-server.js';
23
+ import { getAdapter, listAdapterNames } from '../lib/adapters/index.js';
24
+ import { getSession, setSession, clearSessions } from '../lib/session-store.js';
25
+ import { readLongTerm, syncBack } from '../lib/memory-bridge.js';
26
+ import { detectMemorySources, composeImport, importMemory } from '../lib/memory-import.js';
27
+ import { detectSkills, importSkills } from '../lib/skills-import.js';
28
+ import { parseEnvironmentFile, resolveWorkspace } from '../lib/environment.js';
29
+ import { detectBwrap } from '../lib/sandbox/bwrap.js';
30
+
31
+ // ── Token file I/O — ~/.commonly/tokens/<name>.json (ADR-005) ───────────────
32
+
33
+ const tokensDir = () => join(homedir(), '.commonly', 'tokens');
34
+ const tokenFile = (name) => join(tokensDir(), `${name}.json`);
35
+
36
+ export const saveAgentToken = (name, record) => {
37
+ if (!existsSync(tokensDir())) mkdirSync(tokensDir(), { recursive: true });
38
+ writeFileSync(
39
+ tokenFile(name),
40
+ JSON.stringify({ ...record, savedAt: new Date().toISOString() }, null, 2),
41
+ 'utf8',
42
+ );
43
+ };
44
+
45
+ export const loadAgentToken = (name) => {
46
+ const file = tokenFile(name);
47
+ if (!existsSync(file)) return null;
48
+ try {
49
+ return JSON.parse(readFileSync(file, 'utf8'));
50
+ } catch {
51
+ return null;
52
+ }
53
+ };
54
+
55
+ export const deleteAgentToken = (name) => {
56
+ const file = tokenFile(name);
57
+ if (existsSync(file)) rmSync(file);
58
+ };
59
+
60
+ /**
61
+ * Enumerate every agent attached on this laptop — i.e. every file in
62
+ * ~/.commonly/tokens/*.json. Cross-references the session store for a
63
+ * "last turn" timestamp per agent so the user sees cold vs. live agents.
64
+ *
65
+ * Returns an array of plain records so the CLI table formatter stays simple
66
+ * and tests can assert on shape directly.
67
+ */
68
+ export const listLocalAgents = () => {
69
+ const dir = tokensDir();
70
+ if (!existsSync(dir)) return [];
71
+ return readdirSync(dir)
72
+ .filter((f) => f.endsWith('.json'))
73
+ .map((f) => f.slice(0, -'.json'.length))
74
+ .map((name) => {
75
+ const record = loadAgentToken(name);
76
+ if (!record) return null;
77
+ let lastTurn = null;
78
+ try {
79
+ const sessionsFile = join(homedir(), '.commonly', 'sessions', `${name}.json`);
80
+ if (existsSync(sessionsFile)) {
81
+ const sessions = JSON.parse(readFileSync(sessionsFile, 'utf8'));
82
+ // One entry per pod — we surface the most recent across pods.
83
+ Object.values(sessions).forEach((s) => {
84
+ if (s?.lastTurn && (!lastTurn || s.lastTurn > lastTurn)) lastTurn = s.lastTurn;
85
+ });
86
+ }
87
+ } catch {
88
+ // Corrupt session store — not a hard error, just skip.
89
+ }
90
+ return {
91
+ name,
92
+ adapter: record.adapter || '?',
93
+ podId: record.podId || '?',
94
+ instanceUrl: record.instanceUrl || '?',
95
+ instanceId: record.instanceId || 'default',
96
+ savedAt: record.savedAt || null,
97
+ lastTurn,
98
+ };
99
+ })
100
+ .filter(Boolean);
101
+ };
102
+
103
+ // Event types that carry a human/agent-authored prompt the wrapper should
104
+ // forward to the CLI. Other event types (heartbeat, delivery, etc.) are acked
105
+ // as no_action even if they happen to carry `content` in their payload.
106
+ const CHAT_EVENT_TYPES = new Set(['chat.mention', 'message.posted', 'dm.message']);
107
+
108
+ // ── default environment for adapters that benefit from auto-MCP wiring ─────
109
+
110
+ // Adapters that can consume `mcp[]` from the resolved environment spec.
111
+ // `claude` reads it via --mcp-config (see adapters/claude.js). `codex` and
112
+ // `stub` do not (codex uses ~/.codex/config.toml at boot, set up server-side
113
+ // for cloud-codex; the local codex wrapper doesn't ship MCP wiring yet —
114
+ // follow-up after #440). Returning null means "no default" — the wrapper
115
+ // proceeds with environment=null exactly like before this change.
116
+ const ADAPTERS_WITH_DEFAULT_MCP = new Set(['claude']);
117
+
118
+ // Minimum-viable environment for fresh attach: wire @commonlyai/mcp so the
119
+ // agent gets commonly_* kernel tools out of the box. ${COMMONLY_API_URL} and
120
+ // ${COMMONLY_AGENT_TOKEN} are substituted at spawn-time by the adapter
121
+ // (cli/src/lib/adapters/claude.js §substitutePlaceholders) so the env file
122
+ // stays free of per-attach secrets.
123
+ export const buildDefaultEnvironment = (adapterName) => {
124
+ if (!ADAPTERS_WITH_DEFAULT_MCP.has(adapterName)) return null;
125
+ return {
126
+ mcp: [
127
+ {
128
+ name: 'commonly',
129
+ transport: 'stdio',
130
+ command: ['npx', '-y', '@commonlyai/mcp@latest'],
131
+ env: {
132
+ COMMONLY_API_URL: '${COMMONLY_API_URL}',
133
+ COMMONLY_AGENT_TOKEN: '${COMMONLY_AGENT_TOKEN}',
134
+ },
135
+ },
136
+ ],
137
+ };
138
+ };
139
+
140
+ // ── attach: register a local-CLI-wrapped agent (ADR-005) ────────────────────
141
+
142
+ /**
143
+ * Publish, install, and mint a runtime token for a local-CLI-wrapped agent.
144
+ * Pure core — the commander action wraps this with config loading + logging.
145
+ */
146
+ export const performAttach = async ({
147
+ client,
148
+ adapterName,
149
+ agentName,
150
+ podId,
151
+ displayName,
152
+ envPath = null,
153
+ log = () => {},
154
+ }) => {
155
+ const adapter = getAdapter(adapterName);
156
+ if (!adapter) {
157
+ throw new Error(
158
+ `Unknown adapter "${adapterName}". Known: ${listAdapterNames().join(', ')}`,
159
+ );
160
+ }
161
+
162
+ const detected = await adapter.detect();
163
+ if (!detected) {
164
+ throw new Error(`${adapterName} not found on PATH. Install it and retry.`);
165
+ }
166
+
167
+ // ADR-008 §invariant #4: sandbox failure is a hard stop. Validate the env
168
+ // here so attach refuses up front rather than handing the user a runtime
169
+ // that silently degrades to unsandboxed.
170
+ let environment = null;
171
+ let workspace = null;
172
+ if (envPath) {
173
+ environment = await parseEnvironmentFile(envPath);
174
+ workspace = await resolveWorkspace(environment, agentName, dirname(envPath));
175
+ log(`workspace: ${workspace.path}${workspace.created ? ' (created)' : ''}`);
176
+
177
+ const sandboxMode = environment.sandbox?.mode || 'none';
178
+ if (sandboxMode === 'bwrap') {
179
+ const bwrap = detectBwrap();
180
+ if (!bwrap.available) {
181
+ throw new Error(`sandbox.mode=bwrap requested but unavailable: ${bwrap.error}`);
182
+ }
183
+ if (environment.sandbox?.network?.policy === 'restricted') {
184
+ log(
185
+ 'WARNING: bwrap mode does not enforce host allowlist; declared hosts '
186
+ + 'are advisory in Phase 1. Use sandbox.mode=container for enforced policy.',
187
+ );
188
+ }
189
+ } else if (sandboxMode !== 'none' && sandboxMode !== undefined) {
190
+ throw new Error(
191
+ `sandbox.mode=${sandboxMode} is not yet implemented in the local-CLI driver. `
192
+ + `Phase 1 supports: none, bwrap.`,
193
+ );
194
+ }
195
+ } else {
196
+ // No --env provided. Apply a sensible default if the adapter supports MCP
197
+ // so a fresh `commonly agent attach claude` gives the agent commonly_*
198
+ // kernel tools out of the box (parity with cloud-codex, which boots with
199
+ // @commonlyai/mcp pre-wired in ~/.codex/config.toml). Operators can still
200
+ // opt out by passing an explicit --env that omits the MCP block.
201
+ // See #440 for the gap that motivated this default.
202
+ environment = buildDefaultEnvironment(adapterName);
203
+ if (environment) {
204
+ log(`environment: applied default with @commonlyai/mcp wired (pass --env to override)`);
205
+ }
206
+ }
207
+
208
+ // Identity-bearing runtime tag from the adapter (e.g. 'codex', 'claude-
209
+ // code'). Falls back to adapter.name for adapters that haven't been
210
+ // updated to the two-field scheme. Paired with `host: 'byo'` below so a
211
+ // CLI-attached agent and a cloud-hosted agent of the same identity share
212
+ // the runtimeType and differ only on host. Replaces the old
213
+ // `runtimeType: 'local-cli'` + `wrappedCli` pair (kept readable on the
214
+ // server via legacy normalization).
215
+ const runtimeType = adapter.runtimeType || adapter.name;
216
+
217
+ // Idempotent publish — already-published is expected and fine. Install will
218
+ // surface the real error if the manifest is truly unusable.
219
+ try {
220
+ await client.post('/api/registry/publish', {
221
+ manifest: {
222
+ name: agentName,
223
+ version: '1.0.0',
224
+ description: `${adapter.name} CLI wrapped as a Commonly agent (ADR-005).`,
225
+ runtimeType,
226
+ },
227
+ displayName: displayName || agentName,
228
+ });
229
+ } catch (err) {
230
+ log(`publish skipped: ${err.message}`);
231
+ }
232
+
233
+ // config.runtime is an opaque blob the backend stores verbatim — kernel
234
+ // does not interpret runtimeType here. ADR-004 §Identity's `sourceRuntime`
235
+ // is a different field (memory-sync tag); don't conflate the two.
236
+ // ADR-008 §Attach + run with an environment: include the resolved env spec
237
+ // on config.environment so cross-driver implementations can realize it
238
+ // server-side without re-reading the user's file.
239
+ const installResult = await client.post('/api/registry/install', {
240
+ agentName,
241
+ podId,
242
+ displayName: displayName || agentName,
243
+ version: '1.0.0',
244
+ config: {
245
+ runtime: {
246
+ runtimeType,
247
+ host: 'byo',
248
+ },
249
+ ...(environment ? { environment } : {}),
250
+ },
251
+ scopes: ['context:read', 'messages:write', 'memory:read', 'memory:write'],
252
+ });
253
+
254
+ const installation = installResult.installation || installResult;
255
+ const instanceId = installation.instanceId || 'default';
256
+
257
+ let runtimeToken = installResult.runtimeToken;
258
+ if (!runtimeToken) {
259
+ // ADR-005 §detach + reattach: the agent User row's hashed token persists
260
+ // across detach (per ADR-001 identity-continuity), so re-issuance from a
261
+ // fresh attach hits the "already has a runtime token" branch and gets a
262
+ // {existing: true} response with no usable raw token. Pass force:true so
263
+ // the server clears + re-mints — we just deleted the local copy on
264
+ // detach, the prior hash is unrecoverable from the CLI side anyway.
265
+ const tokenData = await client.post(
266
+ `/api/registry/pods/${podId}/agents/${agentName}/runtime-tokens`,
267
+ { force: true },
268
+ );
269
+ runtimeToken = tokenData.token;
270
+ }
271
+ if (!runtimeToken) {
272
+ throw new Error('Runtime token was not returned by install or tokens endpoint');
273
+ }
274
+
275
+ return {
276
+ installation,
277
+ instanceId,
278
+ runtimeToken,
279
+ detected,
280
+ wrappedCli: adapter.name,
281
+ environment,
282
+ workspace,
283
+ };
284
+ };
285
+
286
+ // ── memory import (retention plan Phase C) ──────────────────────────────────
287
+
288
+ /**
289
+ * Detect → preview → confirm → promote local memory into the agent's
290
+ * envelope. Interactive by design: local memory can hold private material,
291
+ * so nothing is sent without an explicit yes (or --yes for scripts; a
292
+ * non-TTY without --yes aborts rather than guessing).
293
+ *
294
+ * Exported for tests; the attach flag and the import-memory subcommand are
295
+ * both thin wrappers over this.
296
+ */
297
+ export const runMemoryImport = async ({
298
+ client,
299
+ explicitPath = null,
300
+ yes = false,
301
+ cwd = process.cwd(),
302
+ log = console.log,
303
+ promptFn = null,
304
+ }) => {
305
+ const sources = detectMemorySources({ cwd, explicitPath });
306
+ if (!sources.length) {
307
+ log('No local memory found (looked for CLAUDE.md / MEMORY.md / ~/.claude project memory). Pass a path to import a specific file.');
308
+ return { imported: false, reason: 'nothing-found' };
309
+ }
310
+
311
+ const totalKb = Math.max(1, Math.round(sources.reduce((n, s) => n + s.bytes, 0) / 1024));
312
+ log(`Found ${sources.length} memory source${sources.length === 1 ? '' : 's'} (${totalKb}KB):`);
313
+ for (const s of sources) {
314
+ log(` ${s.path} [${s.label}, ${Math.max(1, Math.round(s.bytes / 1024))}KB]`);
315
+ }
316
+ // Compose up-front so size-ceiling errors surface before the prompt, and
317
+ // the preview shows exactly what would land in the envelope.
318
+ const composed = composeImport(sources);
319
+ const previewLines = composed.split('\n').slice(0, 8);
320
+ log('\nPreview:');
321
+ for (const line of previewLines) log(` | ${line}`);
322
+ log(` | … (${composed.length} chars total)\n`);
323
+
324
+ if (!yes) {
325
+ if (!process.stdin.isTTY && !promptFn) {
326
+ log('Not a terminal — re-run with --yes to confirm the import.');
327
+ return { imported: false, reason: 'needs-confirmation' };
328
+ }
329
+ const ask = promptFn || (async (q) => {
330
+ const { createInterface } = await import('readline/promises');
331
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
332
+ const answer = await rl.question(q);
333
+ rl.close();
334
+ return answer;
335
+ });
336
+ const answer = await ask('Import into this agent\'s memory? Appends to long_term; never overwrites. [y/N] ');
337
+ if (!/^y(es)?$/i.test(String(answer).trim())) {
338
+ log('Import cancelled.');
339
+ return { imported: false, reason: 'declined' };
340
+ }
341
+ }
342
+
343
+ const result = await importMemory(client, { sources });
344
+ log(`✓ Imported ${result.files} file${result.files === 1 ? '' : 's'} into long_term memory${result.appended ? ' (appended to existing)' : ''}.`);
345
+ return { imported: true, ...result };
346
+ };
347
+
348
+ // ── run: local-CLI wrapper loop (ADR-005) ────────────────────────────────────
349
+
350
+ const extractPrompt = (event) => {
351
+ if (!CHAT_EVENT_TYPES.has(event.type)) return null;
352
+ const p = event.payload || {};
353
+ return p.content || p.prompt || p.text || null;
354
+ };
355
+
356
+ /**
357
+ * Start the run loop. Polls /events, spawns the adapter per event, posts the
358
+ * result into the pod, acks.
359
+ *
360
+ * ADR-005 invariant #4: serialized per agent. The outer poll loop awaits each
361
+ * event before moving to the next; this function is only ever invoked once
362
+ * per `commonly agent run` process, so two spawns never overlap for the same
363
+ * agent.
364
+ *
365
+ * ADR-005 §Spawning semantics: on adapter failure the event is NOT acked, so
366
+ * the kernel re-delivers. This diverges from `startPoller` (which acks all
367
+ * outcomes) — the local-CLI wrapper needs re-delivery on spawn failure because
368
+ * spawn failure is a runtime problem, not a "processed and declined" outcome.
369
+ */
370
+ export const performRun = ({
371
+ instanceUrl,
372
+ token,
373
+ adapter,
374
+ agentName,
375
+ instanceId = 'default',
376
+ podId = null,
377
+ environment = null,
378
+ workspacePath = null,
379
+ intervalMs = 5000,
380
+ log = () => {},
381
+ onError,
382
+ setTimeoutImpl = setTimeout,
383
+ }) => {
384
+ const client = createClient({ instance: instanceUrl, token });
385
+ let running = true;
386
+ // Stop-after-N-auth-failures: without this, a revoked token leaves the
387
+ // poller hammering 401s forever at 60s backoff — invisible to the user.
388
+ // Exiting tells them to run `commonly agent detach <name>`.
389
+ // 3 is deliberate — 1 would churn on a token-rotation race during
390
+ // reprovision-all; 5+ wastes rate-limit budget after the real-revoke case.
391
+ let consecutiveAuthErrors = 0;
392
+ const MAX_AUTH_ERRORS = 3;
393
+
394
+ // Adapters default `ctx.cwd` to this path. Node's child_process.spawn
395
+ // rejects with "spawn <bin> ENOENT" when cwd does not exist — same shape
396
+ // as binary-not-found — so we ensure it up front to avoid the confusing
397
+ // diagnostic. ADR-008: when an environment spec was attached, the resolved
398
+ // workspace path replaces the /tmp default.
399
+ const agentCwd = workspacePath || join(tmpdir(), 'commonly-agents', agentName);
400
+ if (!existsSync(agentCwd)) mkdirSync(agentCwd, { recursive: true });
401
+
402
+ const processEvent = async (event) => {
403
+ const eventPodId = event.podId || podId;
404
+ const prompt = extractPrompt(event);
405
+ if (!prompt || !eventPodId) {
406
+ // No prompt, or nowhere to post the response — skip spawn entirely so
407
+ // we never consume a CLI turn for a message with no destination.
408
+ log(`[${event.type}] no prompt — no-op`);
409
+ return { outcome: 'no_action' };
410
+ }
411
+
412
+ const sessionId = getSession(agentName, eventPodId);
413
+ // ADR-005 §Memory bridge: read long_term before spawn, inject via ctx,
414
+ // and (if the adapter returns a summary) patch-sync back after.
415
+ const memoryLongTerm = await readLongTerm(client, { onError });
416
+ log(`[${event.type}] spawning ${adapter.name}`);
417
+ const result = await adapter.spawn(prompt, {
418
+ sessionId,
419
+ cwd: agentCwd,
420
+ env: process.env,
421
+ memoryLongTerm,
422
+ environment,
423
+ // Runtime context the adapter uses to substitute ${COMMONLY_AGENT_TOKEN}
424
+ // / ${COMMONLY_API_URL} placeholders in MCP env values, command args,
425
+ // and URLs. Lets users keep tokens out of their checked-in env files.
426
+ runtimeToken: token,
427
+ instanceUrl,
428
+ metadata: { event },
429
+ });
430
+
431
+ if (result.newSessionId) {
432
+ setSession(agentName, eventPodId, result.newSessionId);
433
+ }
434
+ if (result.text) {
435
+ await client.post(`/api/agents/runtime/pods/${eventPodId}/messages`, {
436
+ content: result.text,
437
+ });
438
+ log(`[${event.type}] posted ${Buffer.byteLength(result.text)} bytes`);
439
+ }
440
+ if (result.memorySummary) {
441
+ try {
442
+ await syncBack(client, { summary: result.memorySummary });
443
+ log(`[${event.type}] memory synced (${Buffer.byteLength(result.memorySummary)} bytes)`);
444
+ } catch (err) {
445
+ // Memory-sync failure is non-fatal: the turn already posted, and the
446
+ // next spawn will re-read from the kernel anyway. Surface, don't
447
+ // throw. Preserve the original error via `cause` so the stack trace
448
+ // survives for CLI debugging.
449
+ onError?.(new Error(`memory sync failed: ${err.message}`, { cause: err }));
450
+ }
451
+ }
452
+ return { outcome: 'posted' };
453
+ };
454
+
455
+ const tick = async () => {
456
+ if (!running) return;
457
+ try {
458
+ const { events = [] } = await client.get('/api/agents/runtime/events', {
459
+ agentName, instanceId, limit: 10,
460
+ });
461
+ consecutiveAuthErrors = 0;
462
+ for (const event of events) {
463
+ if (!running) break;
464
+ let result;
465
+ try {
466
+ result = await processEvent(event);
467
+ } catch (err) {
468
+ // Spawn failed — skip ack, let kernel re-deliver (ADR-005).
469
+ log(`[${event.type}] spawn error: ${err.message}`);
470
+ onError?.(err);
471
+ continue;
472
+ }
473
+ try {
474
+ await client.post(`/api/agents/runtime/events/${event._id}/ack`, { result });
475
+ } catch (ackErr) {
476
+ onError?.(new Error(`Ack failed for ${event._id}: ${ackErr.message}`));
477
+ }
478
+ }
479
+ } catch (err) {
480
+ if (err?.status === 401 || err?.status === 403) {
481
+ consecutiveAuthErrors += 1;
482
+ if (consecutiveAuthErrors >= MAX_AUTH_ERRORS) {
483
+ onError?.(new Error(
484
+ `Runtime token rejected ${consecutiveAuthErrors} times in a row — stopping. `
485
+ + `Run: commonly agent detach ${agentName}`,
486
+ ));
487
+ running = false;
488
+ return;
489
+ }
490
+ }
491
+ onError?.(err);
492
+ }
493
+ if (running) setTimeoutImpl(tick, intervalMs);
494
+ };
495
+
496
+ tick();
497
+ return { stop: () => { running = false; } };
498
+ };
499
+
500
+ // ── init: scaffold a webhook-SDK agent (ADR-006 §Scaffolding) ───────────────
501
+
502
+ const SUPPORTED_LANGUAGES = ['python'];
503
+
504
+ // Resolve a repo-relative path from this module, regardless of where the CLI
505
+ // is invoked from. agent.js lives at cli/src/commands/agent.js → repo root is
506
+ // three levels up. The scaffolder copies the canonical SDK file from the repo
507
+ // into the user's cwd; ADR-006 §SDK lives = "live-copy, not dependency".
508
+ //
509
+ // Removal condition: ADR-006 §Migration path Phase 4 — when the CLI is
510
+ // published as `@commonlyai/cli` on npm, the example files won't sit at a
511
+ // repo-relative path; we'll need to bundle them into the package at build
512
+ // time and resolve via `import.meta.resolve` or a packaged-data lookup.
513
+ // Until that phase, repo-relative is the simplest correct answer.
514
+ const repoFile = (...parts) => pathResolve(
515
+ dirname(fileURLToPath(import.meta.url)), '..', '..', '..', ...parts,
516
+ );
517
+
518
+ const SDK_SOURCES = {
519
+ python: {
520
+ sdkSrc: () => repoFile('examples', 'sdk', 'python', 'commonly.py'),
521
+ botSrc: () => repoFile('examples', 'hello-world-python', 'bot.py'),
522
+ sdkOut: 'commonly.py',
523
+ botOut: (name) => `${name}.py`,
524
+ runHint: (name) => `COMMONLY_TOKEN=$(cat .commonly-env) python3 ${name}.py`,
525
+ },
526
+ };
527
+
528
+ /**
529
+ * Scaffold a webhook-SDK agent into `targetDir`. Pure core — the commander
530
+ * action wraps this with config loading, install + token-mint, and logging.
531
+ *
532
+ * Refuses to clobber any of the three output files. Self-serve install
533
+ * (ADR-006 §Self-serve install) makes publish unnecessary.
534
+ */
535
+ export const performInit = async ({
536
+ client,
537
+ language,
538
+ agentName,
539
+ podId,
540
+ displayName,
541
+ targetDir,
542
+ }) => {
543
+ const recipe = SDK_SOURCES[language];
544
+ if (!recipe) {
545
+ throw new Error(
546
+ `Unsupported language "${language}". Supported: ${SUPPORTED_LANGUAGES.join(', ')}`,
547
+ );
548
+ }
549
+
550
+ const sdkOutPath = join(targetDir, recipe.sdkOut);
551
+ const botOutPath = join(targetDir, recipe.botOut(agentName));
552
+ const tokenOutPath = join(targetDir, '.commonly-env');
553
+
554
+ for (const f of [sdkOutPath, botOutPath, tokenOutPath]) {
555
+ if (existsSync(f)) {
556
+ throw new Error(`Refusing to overwrite existing file: ${f}`);
557
+ }
558
+ }
559
+
560
+ if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true });
561
+
562
+ // Copy the SDK file verbatim; copy the bot template byte-for-byte (the
563
+ // user renames their handler later — file name carries the agent name).
564
+ writeFileSync(sdkOutPath, readFileSync(recipe.sdkSrc(), 'utf8'), 'utf8');
565
+ writeFileSync(botOutPath, readFileSync(recipe.botSrc(), 'utf8'), 'utf8');
566
+
567
+ // Self-serve install (ADR-006). No publish step; the backend synthesizes
568
+ // an ephemeral registry row when no manifest exists for this name.
569
+ const installResult = await client.post('/api/registry/install', {
570
+ agentName,
571
+ podId,
572
+ displayName: displayName || agentName,
573
+ version: '1.0.0',
574
+ config: { runtime: { runtimeType: 'webhook', host: 'byo' } },
575
+ scopes: ['context:read', 'messages:write', 'memory:read', 'memory:write'],
576
+ });
577
+ const installation = installResult.installation || installResult;
578
+ const instanceId = installation.instanceId || 'default';
579
+
580
+ let runtimeToken = installResult.runtimeToken;
581
+ if (!runtimeToken) {
582
+ // ADR-005 §detach + reattach: pass force:true so the server clears the
583
+ // hashed-but-unrecoverable prior token and mints fresh.
584
+ const tokenData = await client.post(
585
+ `/api/registry/pods/${podId}/agents/${agentName}/runtime-tokens`,
586
+ { force: true },
587
+ );
588
+ runtimeToken = tokenData.token;
589
+ }
590
+ if (!runtimeToken) {
591
+ throw new Error('Runtime token was not returned by install or tokens endpoint');
592
+ }
593
+
594
+ // .commonly-env uses KEY=VALUE format so `source .commonly-env`, dotenv,
595
+ // and standard env-loading tools work out of the box. Future keys (e.g.
596
+ // COMMONLY_BASE_URL) just append. Mode 0600 (POSIX) since the file holds
597
+ // a long-lived bearer token.
598
+ writeFileSync(tokenOutPath, `COMMONLY_TOKEN=${runtimeToken}\n`,
599
+ { encoding: 'utf8', mode: 0o600 });
600
+
601
+ return {
602
+ installation,
603
+ instanceId,
604
+ runtimeToken,
605
+ files: {
606
+ sdk: sdkOutPath,
607
+ bot: botOutPath,
608
+ env: tokenOutPath,
609
+ },
610
+ runHint: recipe.runHint(agentName),
611
+ };
612
+ };
613
+
614
+ // ── detach: uninstall a local-CLI-wrapped agent (ADR-005) ───────────────────
615
+
616
+ /**
617
+ * Uninstall the agent from its pod AND clean up local token + session files.
618
+ *
619
+ * The three pieces of state created by `attach` are removed in this order:
620
+ * 1. Backend: AgentInstallation (DELETE /api/registry/agents/:name/pods/:podId
621
+ * marks uninstalled, deletes AgentProfile, removes agent User from pod).
622
+ * The runtime token tied to the User row is GC'd by the cleanup service
623
+ * 7 days after all installs go inactive — we don't force-revoke here
624
+ * because the token may legitimately still be in use for another pod.
625
+ * 2. Local token file at ~/.commonly/tokens/<name>.json
626
+ * 3. Local session store at ~/.commonly/sessions/<name>.json
627
+ *
628
+ * Idempotent: if the backend returns 404 (already uninstalled elsewhere), we
629
+ * still clean up local files so the CLI state stays in sync with reality.
630
+ * `--force` / `skipBackend:true` short-circuits to local-only cleanup for
631
+ * the case where the backend has already been uninstalled via another path.
632
+ */
633
+ export const performDetach = async ({
634
+ client,
635
+ agentName,
636
+ podId,
637
+ skipBackend = false,
638
+ log = () => {},
639
+ }) => {
640
+ if (!agentName || (!skipBackend && !podId)) {
641
+ // A corrupted token file could leave us without a podId; without this
642
+ // guard `encodeURIComponent(undefined)` produces a request against
643
+ // `/pods/undefined` that the backend 404s, silently succeeding.
644
+ throw new Error(
645
+ 'performDetach requires agentName (and podId unless skipBackend=true)',
646
+ );
647
+ }
648
+
649
+ let backendResult = { skipped: true };
650
+ if (!skipBackend) {
651
+ try {
652
+ const body = await client.del(`/api/registry/agents/${encodeURIComponent(agentName)}/pods/${encodeURIComponent(podId)}`);
653
+ backendResult = { skipped: false, body };
654
+ log(`backend: uninstalled '${agentName}' from pod ${podId}`);
655
+ } catch (err) {
656
+ // 404 means "already gone" — continue to local cleanup.
657
+ if (err.status === 404) {
658
+ backendResult = { skipped: false, alreadyGone: true };
659
+ log(`backend: '${agentName}' already uninstalled from pod ${podId}`);
660
+ } else {
661
+ // Re-throw anything else (403 auth, 500, network) — caller decides.
662
+ throw err;
663
+ }
664
+ }
665
+ }
666
+
667
+ deleteAgentToken(agentName);
668
+ clearSessions(agentName);
669
+ log(`local: removed token file + session store for '${agentName}'`);
670
+
671
+ return { backend: backendResult, localCleaned: true };
672
+ };
673
+
674
+ export const registerAgent = (program) => {
675
+ const agent = program.command('agent').description('Manage agents');
676
+
677
+ agent.addHelpText('after', `
678
+ Examples:
679
+ # Wrap your local claude binary as a pod agent (ADR-005)
680
+ $ commonly agent attach claude --pod <podId> --name my-claude
681
+ $ commonly agent run my-claude
682
+ $ commonly agent detach my-claude
683
+
684
+ # Scaffold a custom Python agent (ADR-006)
685
+ $ commonly agent init --language python --name research-bot --pod <podId>
686
+
687
+ # List installed agents
688
+ $ commonly agent list
689
+
690
+ Docs:
691
+ https://github.com/Team-Commonly/commonly/blob/main/docs/agents/LOCAL_CLI_WRAPPER.md
692
+ https://github.com/Team-Commonly/commonly/blob/main/docs/agents/WEBHOOK_SDK.md
693
+ `);
694
+
695
+ // ── register ──────────────────────────────────────────────────────────────
696
+ agent
697
+ .command('register')
698
+ .description('Register a webhook agent')
699
+ .requiredOption('--name <name>', 'Agent name (e.g. my-agent)')
700
+ .requiredOption('--pod <podId>', 'Pod ID to install into')
701
+ .requiredOption('--webhook <url>', 'Webhook URL (e.g. https://my-agent.example.com/cap)')
702
+ .option('--secret <secret>', 'Webhook signing secret (HMAC-SHA256)')
703
+ .option('--display <name>', 'Display name shown in the pod')
704
+ .option('--instance <url>', 'Target Commonly instance')
705
+ .action(async (opts) => {
706
+ const instanceUrl = resolveInstanceUrl(opts.instance);
707
+ const token = getToken(opts.instance);
708
+ if (!token) { console.error('Not logged in. Run: commonly login'); process.exit(1); }
709
+
710
+ const client = createClient({ instance: instanceUrl, token });
711
+
712
+ try {
713
+ // Publish the agent definition if it doesn't exist yet.
714
+ await client.post('/api/registry/publish', {
715
+ manifest: {
716
+ name: opts.name,
717
+ version: '1.0.0',
718
+ description: `Webhook-driven Commonly agent (ADR-006) at ${opts.webhook}.`,
719
+ runtimeType: 'webhook',
720
+ },
721
+ displayName: opts.display || opts.name,
722
+ }).catch(() => {
723
+ // Ignore publish errors — agent may already exist, install will proceed
724
+ });
725
+
726
+ const result = await client.post('/api/registry/install', {
727
+ agentName: opts.name,
728
+ podId: opts.pod,
729
+ displayName: opts.display || opts.name,
730
+ version: '1.0.0',
731
+ config: {
732
+ runtime: {
733
+ runtimeType: 'webhook',
734
+ host: 'byo',
735
+ webhookUrl: opts.webhook,
736
+ ...(opts.secret ? { webhookSecret: opts.secret } : {}),
737
+ },
738
+ },
739
+ scopes: ['context:read', 'messages:write', 'memory:read'],
740
+ });
741
+
742
+ const installation = result.installation || result;
743
+ const instId = installation.instanceId || 'default';
744
+ console.log(`Agent registered: ${installation.agentName} (${instId})`);
745
+ console.log(`Pod: ${opts.pod}`);
746
+ console.log(`Webhook: ${opts.webhook}`);
747
+
748
+ // Fetch runtime token for agent connect
749
+ let runtimeToken = result.runtimeToken;
750
+ if (!runtimeToken) {
751
+ try {
752
+ const tokenData = await client.post(
753
+ `/api/registry/pods/${opts.pod}/agents/${opts.name}/runtime-tokens`,
754
+ { force: true },
755
+ );
756
+ runtimeToken = tokenData.token;
757
+ } catch {
758
+ // non-fatal — user can fetch manually
759
+ }
760
+ }
761
+ if (runtimeToken) {
762
+ console.log(`\nRuntime token: ${runtimeToken}`);
763
+ console.log('Use this with: commonly agent connect --name <name> --token <token>');
764
+ }
765
+ } catch (err) {
766
+ console.error(`Registration failed: ${err.message}`);
767
+ process.exit(1);
768
+ }
769
+ });
770
+
771
+ // ── connect ───────────────────────────────────────────────────────────────
772
+ agent
773
+ .command('connect')
774
+ .description('Start local dev loop — poll events and forward to localhost')
775
+ .requiredOption('--name <name>', 'Agent name')
776
+ .option('--port <port>', 'Local webhook server port', '3001')
777
+ .option('--path <path>', 'Local webhook path', '/cap')
778
+ .option('--secret <secret>', 'Signing secret to verify forwarded events')
779
+ .option('--token <token>', 'Agent runtime token (cm_agent_*) — use instead of user token for event polling')
780
+ .option('--instance-id <id>', 'Instance ID (default: "default")', 'default')
781
+ .option('--instance <url>', 'Target Commonly instance')
782
+ .option('--interval <ms>', 'Poll interval in ms', '5000')
783
+ .action(async (opts) => {
784
+ const instanceUrl = resolveInstanceUrl(opts.instance);
785
+ const token = opts.token || getToken(opts.instance);
786
+ if (!token) { console.error('Not logged in. Run: commonly login'); process.exit(1); }
787
+
788
+ const port = parseInt(opts.port, 10);
789
+ const localUrl = `http://localhost:${port}${opts.path}`;
790
+
791
+ console.log(`Connecting ${opts.name} → ${instanceUrl}`);
792
+ console.log(`Forwarding events to ${localUrl}`);
793
+ console.log('Waiting for events... (Ctrl+C to stop)\n');
794
+
795
+ // Start local webhook server
796
+ const { close } = await startWebhookServer({
797
+ port,
798
+ path: opts.path,
799
+ secret: opts.secret,
800
+ onEvent: async (event) => {
801
+ // Developer's agent code handles this — just ack locally
802
+ console.log(`[local] ${event.type} from pod ${event.podId}`);
803
+ return { outcome: 'acknowledged' };
804
+ },
805
+ onReady: (url) => console.log(`Local server ready: ${url}`),
806
+ });
807
+
808
+ // Start poller — forwards each event to the local server
809
+ const poller = startPoller({
810
+ instanceUrl,
811
+ token,
812
+ agentName: opts.name,
813
+ instanceId: opts.instanceId,
814
+ intervalMs: parseInt(opts.interval, 10),
815
+ onEvent: async (event) => {
816
+ const ts = new Date().toTimeString().slice(0, 8);
817
+ process.stdout.write(`[${ts}] ${event.type.padEnd(18)}`);
818
+
819
+ try {
820
+ const result = await forwardToLocalWebhook(event, localUrl, opts.secret);
821
+ console.log(`→ ${result.outcome}`);
822
+ return result;
823
+ } catch (err) {
824
+ console.log(`→ error (${err.message})`);
825
+ return { outcome: 'error', reason: err.message };
826
+ }
827
+ },
828
+ onError: (err) => {
829
+ console.error(`[poll error] ${err.message}`);
830
+ },
831
+ });
832
+
833
+ // Graceful shutdown
834
+ process.on('SIGINT', () => {
835
+ console.log('\nStopping...');
836
+ poller.stop();
837
+ close();
838
+ process.exit(0);
839
+ });
840
+ });
841
+
842
+ // ── attach (ADR-005, ADR-008) ─────────────────────────────────────────────
843
+ agent
844
+ .command('attach <adapter>')
845
+ .description('Wrap a local CLI as a Commonly agent (stub|claude|codex|…)')
846
+ .requiredOption('--pod <podId>', 'Pod ID to install into')
847
+ .requiredOption('--name <name>', 'Agent name (e.g. my-claude)')
848
+ .option('--display <name>', 'Display name shown in the pod')
849
+ .option('--env <path>', 'Path to environment.json (ADR-008 — sandbox/skills/MCP)')
850
+ .option('--import-memory [path]', 'Import local memory (CLAUDE.md / MEMORY.md / ~/.claude project memory, or a given file/dir) into the agent after attach')
851
+ .option('--yes', 'Skip the import confirmation prompt')
852
+ .option('--instance <url>', 'Target Commonly instance')
853
+ .action(async (adapterName, opts) => {
854
+ const instanceUrl = resolveInstanceUrl(opts.instance);
855
+ const token = getToken(opts.instance);
856
+ if (!token) { console.error('Not logged in. Run: commonly login'); process.exit(1); }
857
+
858
+ const client = createClient({ instance: instanceUrl, token });
859
+
860
+ try {
861
+ const envAbsPath = opts.env ? pathResolve(opts.env) : null;
862
+ const {
863
+ installation, instanceId, runtimeToken, detected, wrappedCli,
864
+ environment, workspace,
865
+ } = await performAttach({
866
+ client,
867
+ adapterName,
868
+ agentName: opts.name,
869
+ podId: opts.pod,
870
+ displayName: opts.display,
871
+ envPath: envAbsPath,
872
+ log: (line) => console.warn(`[attach] ${line}`),
873
+ });
874
+
875
+ saveAgentToken(opts.name, {
876
+ agentName: opts.name,
877
+ instanceId,
878
+ podId: opts.pod,
879
+ instanceUrl,
880
+ runtimeToken,
881
+ adapter: wrappedCli,
882
+ ...(environment ? {
883
+ environment,
884
+ workspacePath: workspace?.path || null,
885
+ } : {}),
886
+ });
887
+
888
+ console.log(`✓ ${wrappedCli} detected at ${detected.path} (${detected.version})`);
889
+ console.log(`✓ Agent '${installation.agentName}' registered in pod ${opts.pod} (${instanceId})`);
890
+ console.log(`✓ Runtime token saved to ${tokenFile(opts.name)}`);
891
+ if (workspace) {
892
+ console.log(`✓ Workspace: ${workspace.path}${workspace.created ? ' (created)' : ''}`);
893
+ }
894
+
895
+ // Retention plan Phase C: the agent arrives whole. Import failure is
896
+ // a warning, never an attach failure — the attach already succeeded
897
+ // and `commonly agent import-memory` can retry later.
898
+ if (opts.importMemory) {
899
+ try {
900
+ const runtimeClient = createClient({ instance: instanceUrl, token: runtimeToken });
901
+ await runMemoryImport({
902
+ client: runtimeClient,
903
+ explicitPath: typeof opts.importMemory === 'string' ? pathResolve(opts.importMemory) : null,
904
+ yes: Boolean(opts.yes),
905
+ });
906
+ } catch (err) {
907
+ console.warn(`Memory import failed (agent is still attached): ${err.message}`);
908
+ console.warn(`Retry with: commonly agent import-memory ${opts.name}`);
909
+ }
910
+ }
911
+
912
+ console.log(`\n Run with: commonly agent run ${opts.name}`);
913
+ } catch (err) {
914
+ console.error(`Attach failed: ${err.message}`);
915
+ process.exit(1);
916
+ }
917
+ });
918
+
919
+ // ── import-memory (retention plan Phase C) ────────────────────────────────
920
+ agent
921
+ .command('import-memory <name>')
922
+ .description("Import local memory (CLAUDE.md / MEMORY.md / ~/.claude project memory) into an attached agent's long_term memory")
923
+ .option('--path <path>', 'Import a specific file or directory of .md files instead of auto-detecting')
924
+ .option('--yes', 'Skip the confirmation prompt')
925
+ .action(async (name, opts) => {
926
+ const record = loadAgentToken(name);
927
+ if (!record) {
928
+ console.error(`No token for '${name}'. Attach it first: commonly agent attach <adapter> --pod <podId> --name ${name}`);
929
+ process.exit(1);
930
+ }
931
+ try {
932
+ const client = createClient({ instance: record.instanceUrl, token: record.runtimeToken });
933
+ const result = await runMemoryImport({
934
+ client,
935
+ explicitPath: opts.path ? pathResolve(opts.path) : null,
936
+ yes: Boolean(opts.yes),
937
+ });
938
+ if (!result.imported && result.reason === 'needs-confirmation') process.exit(1);
939
+ } catch (err) {
940
+ console.error(`Import failed: ${err.message}`);
941
+ process.exit(1);
942
+ }
943
+ });
944
+
945
+ // ── import-skills (retention plan Phase C — metadata only) ────────────────
946
+ agent
947
+ .command('import-skills <name>')
948
+ .description("Register a local agent's skills (names + descriptions only) on its Commonly profile — content stays on your machine")
949
+ .option('--dir <path>', 'Skills directory (default: <cwd>/.claude/skills, then ~/.claude/skills)')
950
+ .option('--yes', 'Skip the confirmation prompt')
951
+ .option('--instance <url>', 'Target Commonly instance')
952
+ .action(async (name, opts) => {
953
+ const record = loadAgentToken(name);
954
+ if (!record) {
955
+ console.error(`No token for '${name}'. Attach it first: commonly agent attach <adapter> --pod <podId> --name ${name}`);
956
+ process.exit(1);
957
+ }
958
+ // Skill registration is a USER action (pod-membership-checked route),
959
+ // unlike memory import which authenticates as the agent.
960
+ const userToken = getToken(opts.instance);
961
+ if (!userToken) { console.error('Not logged in. Run: commonly login'); process.exit(1); }
962
+
963
+ try {
964
+ const skills = detectSkills({ explicitDir: opts.dir ? pathResolve(opts.dir) : null });
965
+ if (!skills.length) {
966
+ console.log('No skills found (looked for */SKILL.md under .claude/skills). Pass --dir to point at a skills directory.');
967
+ return;
968
+ }
969
+ console.log(`Found ${skills.length} skill${skills.length === 1 ? '' : 's'}:`);
970
+ for (const s of skills) {
971
+ console.log(` ${s.name}${s.description ? ` — ${s.description.slice(0, 80)}` : ''}`);
972
+ }
973
+ console.log('\nOnly names + descriptions are uploaded — skill content stays on this machine.');
974
+
975
+ if (!opts.yes) {
976
+ if (!process.stdin.isTTY) {
977
+ console.log('Not a terminal — re-run with --yes to confirm.');
978
+ process.exit(1);
979
+ }
980
+ const { createInterface } = await import('readline/promises');
981
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
982
+ const answer = await rl.question(`Register on ${record.agentName}'s profile? [y/N] `);
983
+ rl.close();
984
+ if (!/^y(es)?$/i.test(answer.trim())) {
985
+ console.log('Cancelled.');
986
+ return;
987
+ }
988
+ }
989
+
990
+ const client = createClient({ instance: record.instanceUrl, token: userToken });
991
+ const result = await importSkills(client, {
992
+ skills,
993
+ podId: record.podId,
994
+ agentName: record.agentName,
995
+ instanceId: record.instanceId || 'default',
996
+ });
997
+ console.log(`✓ Registered ${result.imported} skill${result.imported === 1 ? '' : 's'} on ${record.agentName}'s profile.`);
998
+ } catch (err) {
999
+ console.error(`Skills import failed: ${err.message}`);
1000
+ process.exit(1);
1001
+ }
1002
+ });
1003
+
1004
+ // ── run (ADR-005) ─────────────────────────────────────────────────────────
1005
+ agent
1006
+ .command('run <name>')
1007
+ .description('Run the local-CLI wrapper loop for an attached agent')
1008
+ .option('--interval <ms>', 'Poll interval in ms', '5000')
1009
+ .action(async (name, opts) => {
1010
+ const record = loadAgentToken(name);
1011
+ if (!record) {
1012
+ console.error(`No token for '${name}'. Run: commonly agent attach <adapter> --pod <podId> --name ${name}`);
1013
+ process.exit(1);
1014
+ }
1015
+
1016
+ const adapter = getAdapter(record.adapter);
1017
+ if (!adapter) {
1018
+ console.error(`Unknown adapter '${record.adapter}' in token file. Known: ${listAdapterNames().join(', ')}`);
1019
+ process.exit(1);
1020
+ }
1021
+
1022
+ console.log(`[${name}] polling ${record.instanceUrl} for events (ctrl+c to stop)`);
1023
+
1024
+ const { stop } = performRun({
1025
+ instanceUrl: record.instanceUrl,
1026
+ token: record.runtimeToken,
1027
+ adapter,
1028
+ agentName: record.agentName,
1029
+ instanceId: record.instanceId,
1030
+ podId: record.podId,
1031
+ environment: record.environment || null,
1032
+ workspacePath: record.workspacePath || null,
1033
+ intervalMs: parseInt(opts.interval, 10),
1034
+ log: (line) => console.log(`[${name}] ${line}`),
1035
+ onError: (err) => console.error(`[${name}] ${err.message}`),
1036
+ });
1037
+
1038
+ process.on('SIGINT', () => {
1039
+ console.log(`\n[${name}] stopping...`);
1040
+ stop();
1041
+ process.exit(0);
1042
+ });
1043
+ });
1044
+
1045
+ // ── detach (ADR-005) ──────────────────────────────────────────────────────
1046
+ agent
1047
+ .command('detach <name>')
1048
+ .description('Uninstall an attached agent from its pod and delete local state')
1049
+ .option('--force', 'Skip the backend uninstall call; only remove local files')
1050
+ .action(async (name, opts) => {
1051
+ const record = loadAgentToken(name);
1052
+ if (!record) {
1053
+ console.error(
1054
+ `No local state for '${name}'. If the agent is still installed on `
1055
+ + `the backend, uninstall it via the Agent Hub UI or:\n`
1056
+ + ` curl -X DELETE <instance>/api/registry/agents/${name}/pods/<podId> \\\n`
1057
+ + ` -H "Authorization: Bearer <user JWT>"`,
1058
+ );
1059
+ process.exit(1);
1060
+ }
1061
+
1062
+ try {
1063
+ let client = null;
1064
+ if (!opts.force) {
1065
+ // The DELETE endpoint expects the user's JWT, not the agent runtime
1066
+ // token — matches the auth surface of attach/init. We use the saved
1067
+ // instanceUrl to find the right user token (getToken accepts URL).
1068
+ const userToken = getToken(record.instanceUrl);
1069
+ if (!userToken) {
1070
+ console.error(
1071
+ `No user login found for ${record.instanceUrl}. Either run `
1072
+ + `'commonly login --instance ${record.instanceUrl}' first, or `
1073
+ + `'commonly agent detach ${name} --force' to clean up local files only.`,
1074
+ );
1075
+ process.exit(1);
1076
+ }
1077
+ client = createClient({ instance: record.instanceUrl, token: userToken });
1078
+ }
1079
+
1080
+ const result = await performDetach({
1081
+ client,
1082
+ agentName: record.agentName,
1083
+ podId: record.podId,
1084
+ skipBackend: !!opts.force,
1085
+ log: (line) => console.warn(`[detach] ${line}`),
1086
+ });
1087
+
1088
+ if (opts.force) {
1089
+ console.log(`✓ Removed local state for '${name}' (backend NOT notified)`);
1090
+ } else if (result.backend?.alreadyGone) {
1091
+ console.log(`✓ '${name}' was already uninstalled from pod ${record.podId}; cleaned local state`);
1092
+ } else {
1093
+ console.log(`✓ Detached '${name}' from pod ${record.podId} and removed local state`);
1094
+ }
1095
+ } catch (err) {
1096
+ console.error(`Detach failed: ${err.message}`);
1097
+ console.error(`If the backend is unreachable, retry with --force to clean up local files.`);
1098
+ process.exit(1);
1099
+ }
1100
+ });
1101
+
1102
+ // ── init (ADR-006) ────────────────────────────────────────────────────────
1103
+ agent
1104
+ .command('init')
1105
+ .description('Scaffold a webhook-SDK agent into the current directory')
1106
+ .requiredOption('--language <lang>', `One of: ${SUPPORTED_LANGUAGES.join(', ')}`)
1107
+ .requiredOption('--name <name>', 'Agent name (e.g. research-bot)')
1108
+ .requiredOption('--pod <podId>', 'Pod ID to install into')
1109
+ .option('--display <name>', 'Display name shown in the pod')
1110
+ .option('--dir <path>', 'Target directory (default: current dir)')
1111
+ .option('--instance <url>', 'Target Commonly instance')
1112
+ .action(async (opts) => {
1113
+ const instanceUrl = resolveInstanceUrl(opts.instance);
1114
+ const token = getToken(opts.instance);
1115
+ if (!token) { console.error('Not logged in. Run: commonly login'); process.exit(1); }
1116
+
1117
+ const client = createClient({ instance: instanceUrl, token });
1118
+
1119
+ try {
1120
+ const result = await performInit({
1121
+ client,
1122
+ language: opts.language,
1123
+ agentName: opts.name,
1124
+ podId: opts.pod,
1125
+ displayName: opts.display,
1126
+ targetDir: pathResolve(opts.dir || process.cwd()),
1127
+ });
1128
+
1129
+ console.log(`✓ Written: ${result.files.bot}`);
1130
+ console.log(`✓ Written: ${result.files.sdk}`);
1131
+ console.log(`✓ Registered '${opts.name}' in pod ${opts.pod} (${result.instanceId})`);
1132
+ console.log(`✓ Runtime token saved to ${result.files.env}`);
1133
+ console.log('\nNext:');
1134
+ console.log(` 1. Edit ${opts.name}.py to handle events.`);
1135
+ console.log(` 2. Run: ${result.runHint}`);
1136
+ } catch (err) {
1137
+ console.error(`Init failed: ${err.message}`);
1138
+ process.exit(1);
1139
+ }
1140
+ });
1141
+
1142
+ // ── list ──────────────────────────────────────────────────────────────────
1143
+ agent
1144
+ .command('list')
1145
+ .description('List agents — installed on backend (default) or attached locally (--local)')
1146
+ .option('--local', 'List agents attached on this laptop (~/.commonly/tokens/)')
1147
+ .option('--pod <podId>', 'Filter by pod (backend mode only)')
1148
+ .option('--instance <url>', 'Target Commonly instance (backend mode only)')
1149
+ .addHelpText('after', `
1150
+ The two modes answer different questions:
1151
+
1152
+ backend (default) — who is installed in pods I can see, on any driver
1153
+ --local — who have I attached on THIS laptop via 'agent attach'
1154
+
1155
+ Use --local to find the name you'd pass to 'agent run' or 'agent detach'.
1156
+ `)
1157
+ .action(async (opts) => {
1158
+ if (opts.local) {
1159
+ const agents = listLocalAgents();
1160
+ if (agents.length === 0) {
1161
+ console.log('No agents attached locally. Run: commonly agent attach <adapter> --pod <podId> --name <n>');
1162
+ return;
1163
+ }
1164
+ const col = (s, w) => String(s ?? '').padEnd(w).slice(0, w);
1165
+ console.log(`${col('NAME', 20)} ${col('ADAPTER', 10)} ${col('POD', 26)} LAST TURN`);
1166
+ console.log('─'.repeat(80));
1167
+ agents.forEach((a) => {
1168
+ const lastTurn = a.lastTurn ? new Date(a.lastTurn).toLocaleString() : 'never';
1169
+ console.log(`${col(a.name, 20)} ${col(a.adapter, 10)} ${col(a.podId, 26)} ${lastTurn}`);
1170
+ });
1171
+ return;
1172
+ }
1173
+
1174
+ const token = getToken(opts.instance);
1175
+ if (!token) { console.error('Not logged in. Run: commonly login'); process.exit(1); }
1176
+
1177
+ const client = createClient({ instance: resolveInstanceUrl(opts.instance), token });
1178
+
1179
+ try {
1180
+ const params = opts.pod ? { podId: opts.pod } : {};
1181
+ const data = await client.get('/api/registry/admin/installations', params);
1182
+ const installations = data.installations || data || [];
1183
+
1184
+ if (installations.length === 0) {
1185
+ console.log('No agents installed.');
1186
+ return;
1187
+ }
1188
+
1189
+ const col = (s, w) => String(s ?? '').padEnd(w).slice(0, w);
1190
+ console.log(`${col('NAME', 16)} ${col('INSTANCE', 10)} ${col('RUNTIME', 14)} ${col('HOST', 6)} ${col('STATUS', 10)} LAST SEEN`);
1191
+ console.log('─'.repeat(80));
1192
+ installations.forEach((inst) => {
1193
+ const runtimeType = inst.config?.runtime?.runtimeType || inst.runtimeType || '?';
1194
+ const host = inst.config?.runtime?.host || '';
1195
+ const lastSeen = inst.usage?.lastUsedAt
1196
+ ? new Date(inst.usage.lastUsedAt).toLocaleString()
1197
+ : 'never';
1198
+ console.log(`${col(inst.agentName, 16)} ${col(inst.instanceId, 10)} ${col(runtimeType, 14)} ${col(host, 6)} ${col(inst.status, 10)} ${lastSeen}`);
1199
+ });
1200
+ } catch (err) {
1201
+ console.error(`Failed: ${err.message}`);
1202
+ process.exit(1);
1203
+ }
1204
+ });
1205
+
1206
+ // ── logs ──────────────────────────────────────────────────────────────────
1207
+ agent
1208
+ .command('logs <name>')
1209
+ .description('Stream recent events for an agent')
1210
+ .option('--instance-id <id>', 'Instance ID', 'default')
1211
+ .option('--follow', 'Keep polling for new events', false)
1212
+ .option('--instance <url>', 'Target Commonly instance')
1213
+ .action(async (name, opts) => {
1214
+ const token = getToken(opts.instance);
1215
+ if (!token) { console.error('Not logged in. Run: commonly login'); process.exit(1); }
1216
+
1217
+ const client = createClient({ instance: resolveInstanceUrl(opts.instance), token });
1218
+
1219
+ const fetchAndPrint = async () => {
1220
+ const { events = [] } = await client.get('/api/agents/runtime/events', {
1221
+ agentName: name,
1222
+ instanceId: opts.instanceId,
1223
+ limit: 20,
1224
+ });
1225
+ events.forEach((e) => {
1226
+ const ts = new Date(e.createdAt).toTimeString().slice(0, 8);
1227
+ const outcome = e.delivery?.outcome || e.status;
1228
+ console.log(`[${ts}] ${e.type.padEnd(18)} → ${outcome}`);
1229
+ });
1230
+ return events;
1231
+ };
1232
+
1233
+ try {
1234
+ await fetchAndPrint();
1235
+ if (opts.follow) {
1236
+ console.log('Following... (Ctrl+C to stop)');
1237
+ setInterval(fetchAndPrint, 5000);
1238
+ }
1239
+ } catch (err) {
1240
+ console.error(`Failed: ${err.message}`);
1241
+ process.exit(1);
1242
+ }
1243
+ });
1244
+
1245
+ // ── heartbeat ─────────────────────────────────────────────────────────────
1246
+ agent
1247
+ .command('heartbeat <name>')
1248
+ .description('Manually trigger an agent heartbeat')
1249
+ .option('--instance <url>', 'Target Commonly instance')
1250
+ .action(async (name, opts) => {
1251
+ const token = getToken(opts.instance);
1252
+ if (!token) { console.error('Not logged in. Run: commonly login'); process.exit(1); }
1253
+
1254
+ const client = createClient({ instance: resolveInstanceUrl(opts.instance), token });
1255
+
1256
+ try {
1257
+ await client.post(`/api/registry/admin/agents/${name}/trigger-heartbeat`, {});
1258
+ console.log(`Heartbeat triggered for ${name}`);
1259
+ } catch (err) {
1260
+ console.error(`Failed: ${err.message}`);
1261
+ process.exit(1);
1262
+ }
1263
+ });
1264
+ };