@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.
- package/README.md +27 -0
- package/package.json +55 -0
- package/src/commands/agent.js +1264 -0
- package/src/commands/dev.js +653 -0
- package/src/commands/login.js +105 -0
- package/src/commands/pod.js +123 -0
- package/src/index.js +73 -0
- package/src/lib/adapters/claude.js +296 -0
- package/src/lib/adapters/codex.js +251 -0
- package/src/lib/adapters/index.js +22 -0
- package/src/lib/adapters/stub.js +24 -0
- package/src/lib/api.js +62 -0
- package/src/lib/config.js +138 -0
- package/src/lib/environment.js +326 -0
- package/src/lib/memory-bridge.js +54 -0
- package/src/lib/memory-import.js +156 -0
- package/src/lib/poller.js +89 -0
- package/src/lib/sandbox/bwrap.js +127 -0
- package/src/lib/session-store.js +56 -0
- package/src/lib/skills-import.js +112 -0
- package/src/lib/webhook-server.js +88 -0
|
@@ -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
|
+
};
|