@commonlyai/cli 0.1.212 → 0.1.214

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.212",
3
+ "version": "0.1.214",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -1,6 +1,7 @@
1
1
  import { createOwnKeyResolver, createMachineReportClient } from '../lib/own-key-launch.js';
2
2
  import { foregroundDaemonHint } from '../lib/daemon-hint.js';
3
3
  import { loadDaemonRecord } from '../lib/daemon-store.js';
4
+ import { daemonSeatLogPath, logSize, readAppended, readLogTail } from '../lib/daemon-logs.js';
4
5
  import { WALKED_HARNESSES } from '../lib/runtime-inventory.js';
5
6
  import { createRuntimeReporter } from '../lib/runtime-observation.js';
6
7
  /**
@@ -16,6 +17,7 @@ import { createRuntimeReporter } from '../lib/runtime-observation.js';
16
17
  * heartbeat — manually trigger an agent's heartbeat
17
18
  */
18
19
 
20
+ import { refreshMcpTokenCopy, removeMcpTokenCopy } from '../lib/mcp-token.js';
19
21
  import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync, readdirSync } from 'fs';
20
22
  import { join, dirname, basename, resolve as pathResolve } from 'path';
21
23
  import { homedir, tmpdir } from 'os';
@@ -147,6 +149,12 @@ export const saveAgentToken = (name, record) => {
147
149
  JSON.stringify({ ...record, savedAt: new Date().toISOString() }, null, 2),
148
150
  { encoding: 'utf8', mode: 0o600 },
149
151
  );
152
+ // After the record is durable, so a failed refresh never loses a freshly minted token.
153
+ try {
154
+ refreshMcpTokenCopy(name, record?.runtimeToken);
155
+ } catch (err) {
156
+ console.warn(`Could not refresh the MCP token copy for '${name}': ${err.message}. Run: commonly mcp config --agent ${name}`);
157
+ }
150
158
  };
151
159
 
152
160
  export const loadAgentToken = (name) => {
@@ -162,6 +170,7 @@ export const loadAgentToken = (name) => {
162
170
  export const deleteAgentToken = (name) => {
163
171
  const file = tokenFile(name);
164
172
  if (existsSync(file)) rmSync(file);
173
+ removeMcpTokenCopy(name);
165
174
  };
166
175
 
167
176
  // ── env-var bootstrap for `agent run` (#913) ────────────────────────────────
@@ -4366,38 +4375,85 @@ Use --local to find the name you'd pass to 'agent run' or 'agent detach'.
4366
4375
  });
4367
4376
 
4368
4377
  // ── logs ──────────────────────────────────────────────────────────────────
4378
+ // Without --pod, the seat's local daemon log is read. With --pod, the owner
4379
+ // login reads recent events and runs from the member-gated seat-activity
4380
+ // route, whichever machine the seat runs on. The owner login is never sent to
4381
+ // the agent-runtime routes, which only accept a seat's own token.
4369
4382
  agent
4370
4383
  .command('logs <name>')
4371
- .description('Stream recent events for an agent')
4372
- .option('--instance-id <id>', 'Instance ID', 'default')
4373
- .option('--follow', 'Keep polling for new events', false)
4374
- .option('--instance <url>', 'Target Commonly instance')
4384
+ .description("Show a seat's recent log: local daemon log, or server activity with --pod")
4385
+ .option('--pod <podId>', 'Read server-side events and runs for a pod you belong to')
4386
+ .option('--instance-id <id>', 'Seat instance ID', 'default')
4387
+ .option('--follow', 'Continue printing new output', false)
4388
+ .option('--lines <n>', 'Number of trailing local lines to show', '50')
4389
+ .option('--instance <url>', 'Target Commonly instance (with --pod)')
4375
4390
  .action(async (name, opts) => {
4391
+ if (opts.follow && opts.pod) {
4392
+ console.error('--follow needs a local seat log; server activity is a snapshot. Re-run without --follow.');
4393
+ process.exit(1);
4394
+ }
4395
+ const lines = Number(opts.lines);
4396
+ if (!Number.isInteger(lines) || lines < 1) {
4397
+ console.error(`--lines must be a positive integer, got '${opts.lines}'`);
4398
+ process.exit(1);
4399
+ }
4400
+ if (!opts.pod) {
4401
+ const path = daemonSeatLogPath(name);
4402
+ let output;
4403
+ try {
4404
+ output = readLogTail(path, lines);
4405
+ } catch (err) {
4406
+ console.error(`Cannot read ${path}: ${err.message}`);
4407
+ process.exit(1);
4408
+ }
4409
+ if (output === null) {
4410
+ console.error(`No local log for '${name}' at ${path}. Pass --pod <podId> to read its server-side activity.`);
4411
+ process.exit(1);
4412
+ }
4413
+ if (output) console.log(output);
4414
+ if (!opts.follow) return;
4415
+ // Poll rather than fs.watch: a rename-rotated log would leave a watch
4416
+ // on the old inode silent. Only appended bytes are printed.
4417
+ let offset = logSize(path);
4418
+ const timer = setInterval(() => {
4419
+ try {
4420
+ const next = readAppended(path, offset);
4421
+ offset = next.offset;
4422
+ if (next.text) process.stdout.write(next.text);
4423
+ } catch (err) {
4424
+ // Rotation can leave a gap with no file; wait for it to come back.
4425
+ if (err?.code === 'ENOENT') return;
4426
+ console.error(`Cannot follow ${path}: ${err.message}`);
4427
+ process.exit(1);
4428
+ }
4429
+ }, 1000);
4430
+ const close = () => { clearInterval(timer); process.exit(0); };
4431
+ process.once('SIGINT', close);
4432
+ process.once('SIGTERM', close);
4433
+ return;
4434
+ }
4435
+
4376
4436
  const token = getToken(opts.instance);
4377
4437
  if (!token) { console.error('Not logged in. Run: commonly login'); process.exit(1); }
4378
-
4379
4438
  const client = createClient({ instance: resolveInstanceUrl(opts.instance), token });
4380
-
4381
- const fetchAndPrint = async () => {
4382
- const { events = [] } = await client.get('/api/agents/runtime/events', {
4439
+ const printActivity = async () => {
4440
+ const { events = [], runs = [] } = await client.get('/api/agents/seat-activity', {
4441
+ podId: opts.pod,
4383
4442
  agentName: name,
4384
4443
  instanceId: opts.instanceId,
4385
- limit: 20,
4386
4444
  });
4387
4445
  events.forEach((e) => {
4388
4446
  const ts = new Date(e.createdAt).toTimeString().slice(0, 8);
4389
- const outcome = e.delivery?.outcome || e.status;
4390
- console.log(`[${ts}] ${e.type.padEnd(18)} → ${outcome}`);
4447
+ console.log(`[${ts}] ${String(e.type).padEnd(18)} → ${e.outcome || e.status}`);
4391
4448
  });
4392
- return events;
4449
+ runs.forEach((r) => {
4450
+ const ts = new Date(r.startedAt).toTimeString().slice(0, 8);
4451
+ console.log(`[${ts}] run ${String(r.trigger).padEnd(14)} → ${r.status} (${r.totalTokens} tokens)`);
4452
+ });
4453
+ if (events.length === 0 && runs.length === 0) console.log(`No recent activity for '${name}' in this pod.`);
4393
4454
  };
4394
-
4395
4455
  try {
4396
- await fetchAndPrint();
4397
- if (opts.follow) {
4398
- console.log('Following... (Ctrl+C to stop)');
4399
- setInterval(fetchAndPrint, 5000);
4400
- }
4456
+ await printActivity();
4401
4457
  } catch (err) {
4402
4458
  console.error(`Failed: ${err.message}`);
4403
4459
  process.exit(1);
@@ -0,0 +1,55 @@
1
+ /**
2
+ * commonly mcp config — print an MCP server config for a locally attached agent (TASK-666).
3
+ * The MCP server only accepts a cm_agent_* runtime token, so the config points
4
+ * COMMONLY_TOKEN_FILE at a 0600 copy of that token and never prints the token.
5
+ */
6
+ import { listLocalAgents, loadAgentToken } from './agent.js';
7
+ import { validMcpTokenName, writeMcpTokenCopy } from '../lib/mcp-token.js';
8
+
9
+ const NO_AGENT_HINT = 'Attach one first: commonly agent attach <adapter> --pod <podId> --name <name>';
10
+
11
+ export const buildMcpConfig = ({ agent } = {}) => {
12
+ let name = agent;
13
+ if (!name) {
14
+ const local = listLocalAgents();
15
+ if (local.length === 0) throw new Error(`No local agent has a runtime token on this computer. ${NO_AGENT_HINT}`);
16
+ if (local.length > 1) {
17
+ throw new Error(`More than one local agent (${local.map((a) => a.name).join(', ')}). Pick one with --agent <name>.`);
18
+ }
19
+ name = local[0].name;
20
+ }
21
+ if (!validMcpTokenName(name)) throw new Error(`Invalid agent name '${name}'`);
22
+ const record = loadAgentToken(name);
23
+ const token = String(record?.runtimeToken || '').trim();
24
+ if (!token) throw new Error(`No runtime token saved for agent '${name}'. ${NO_AGENT_HINT}`);
25
+ if (!token.startsWith('cm_agent_')) throw new Error(`The saved token for '${name}' is not a cm_agent_* runtime token. Re-attach it: commonly agent attach`);
26
+ if (!record.instanceUrl) throw new Error(`Agent '${name}' has no saved instance URL. Re-attach it.`);
27
+
28
+ const tokenPath = writeMcpTokenCopy(name, token);
29
+
30
+ return {
31
+ mcpServers: {
32
+ commonly: {
33
+ command: 'npx',
34
+ args: ['-y', '@commonlyai/mcp@latest'],
35
+ env: { COMMONLY_API_URL: record.instanceUrl, COMMONLY_TOKEN_FILE: tokenPath },
36
+ },
37
+ },
38
+ };
39
+ };
40
+
41
+ export const registerMcp = (program) => {
42
+ const mcp = program.command('mcp').description('Connect an MCP client (Claude Code, Codex, Cursor) to Commonly');
43
+ mcp
44
+ .command('config')
45
+ .description('Print an MCP server config for a locally attached agent; the token stays in a 0600 file')
46
+ .option('--agent <name>', 'Local agent to use (default: the only one)')
47
+ .action((opts) => {
48
+ try {
49
+ console.log(JSON.stringify(buildMcpConfig({ agent: opts.agent }), null, 2));
50
+ } catch (error) {
51
+ console.error(error.message);
52
+ process.exitCode = 1;
53
+ }
54
+ });
55
+ };
package/src/index.js CHANGED
@@ -22,6 +22,7 @@ import { registerPod } from './commands/pod.js';
22
22
  import { registerDev } from './commands/dev.js';
23
23
  import { registerVerify } from './commands/verify.js';
24
24
  import { registerDoctor } from './commands/doctor.js';
25
+ import { registerMcp } from './commands/mcp.js';
25
26
 
26
27
  const __dirname = dirname(fileURLToPath(import.meta.url));
27
28
  const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'));
@@ -57,6 +58,7 @@ registerVerify(program);
57
58
 
58
59
  // Install health: one line per check (TASK-660)
59
60
  registerDoctor(program);
61
+ registerMcp(program);
60
62
 
61
63
  // Each subcommand owns its own `--instance <url>` flag; a program-level
62
64
  // duplicate shadowed the subcommand value on commander v12, causing
@@ -1,4 +1,4 @@
1
- import { readFileSync } from 'node:fs';
1
+ import { closeSync, openSync, readFileSync, readSync, statSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
 
@@ -23,3 +23,21 @@ export const readLogTail = (path, count = 50) => {
23
23
  throw error;
24
24
  }
25
25
  };
26
+
27
+ export const logSize = (path) => statSync(path).size;
28
+
29
+ // Bytes appended since `offset`, for `agent logs --follow`. A file that shrank
30
+ // (rotated or truncated) is read from the start again.
31
+ export const readAppended = (path, offset) => {
32
+ const size = logSize(path);
33
+ const start = size < offset ? 0 : offset;
34
+ if (size === start) return { text: '', offset: size };
35
+ const fd = openSync(path, 'r');
36
+ try {
37
+ const buffer = Buffer.alloc(size - start);
38
+ readSync(fd, buffer, 0, buffer.length, start);
39
+ return { text: buffer.toString('utf8'), offset: size };
40
+ } finally {
41
+ closeSync(fd);
42
+ }
43
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The 0600 copy of an agent's runtime token that `commonly mcp config` points
3
+ * COMMONLY_TOKEN_FILE at (TASK-666). It is a second live secret, so the agent
4
+ * token's lifecycle owns it: save refreshes it, delete removes it.
5
+ */
6
+ import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'fs';
7
+ import { homedir } from 'os';
8
+ import { join } from 'path';
9
+
10
+ export const validMcpTokenName = (name) => typeof name === 'string' && /^[A-Za-z0-9._-]+$/.test(name) && !name.startsWith('.');
11
+
12
+ export const mcpTokenPath = (name) => {
13
+ if (!validMcpTokenName(name)) throw new Error(`Invalid agent name '${name}'`);
14
+ return join(homedir(), '.commonly', 'mcp', `${name}.token`);
15
+ };
16
+
17
+ // Remove first, then create exclusively at 0600: no write into a looser-mode file or through a symlink.
18
+ export const writeMcpTokenCopy = (name, token) => {
19
+ const dir = join(homedir(), '.commonly', 'mcp');
20
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
21
+ chmodSync(dir, 0o700);
22
+ const path = mcpTokenPath(name);
23
+ rmSync(path, { force: true });
24
+ writeFileSync(path, `${token}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
25
+ return path;
26
+ };
27
+
28
+ // A bad name never has a copy, so remove and refresh are no-ops for it and cannot block detach or save.
29
+ export const removeMcpTokenCopy = (name) => {
30
+ if (validMcpTokenName(name)) rmSync(mcpTokenPath(name), { force: true });
31
+ };
32
+
33
+ // Reattach mints a new token; a copy that already exists must follow it.
34
+ export const refreshMcpTokenCopy = (name, token) => {
35
+ if (token && validMcpTokenName(name) && existsSync(mcpTokenPath(name))) writeMcpTokenCopy(name, token);
36
+ };