claude-code-kanban 4.29.0 → 4.31.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/cli.js CHANGED
@@ -1,29 +1,42 @@
1
- const fs = require('fs');
2
- const path = require('path');
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
3
  const { getClaudeDir, displayPath } = require('./lib/claude-dir');
4
4
  const { isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
5
-
5
+ const { linkUrl } = require('./public/link-url');
6
6
  // Help is auto-generated from this table — keep flags/usage in sync with `run` behavior.
7
7
  const COMMANDS = {
8
8
  'preview-doc': {
9
9
  summary: 'Open a markdown or HTML file in the preview modal on connected browser tabs',
10
- usage: 'claude-code-kanban preview-doc <file.md|file.html> [--session <id>]',
10
+ usage: 'claude-code-kanban preview-doc <file.md|file.html|url> [--session <id>]',
11
11
  flags: {
12
- '--session <id>': 'Switch focused session in the browser (does not link the file)',
12
+ '--session <id>': 'Switch focused session in the browser (does not link the file). Required for a URL.',
13
13
  },
14
+ notes: 'HTML renders in a sandboxed iframe; local stylesheets, scripts and images are inlined. Relative paths resolve against the current dir. An http(s) URL is linked to the session instead, and the tab on screen shows an Open button.',
15
+ examples: [
16
+ 'claude-code-kanban preview-doc ./notes.md --session $CLAUDE_SESSION_ID',
17
+ ],
14
18
  run: runPreviewCli,
15
19
  },
16
20
  'link-doc': {
17
- summary: 'Link a file to a session in the sidebar without opening the preview modal',
18
- usage: 'claude-code-kanban link-doc <file> --session <id> [--unlink]',
21
+ summary: 'Link a file or URL to a session in the sidebar without opening the preview modal',
22
+ usage: 'claude-code-kanban link-doc <file|url> --session <id> [--unlink] | link-doc --list --session <id> [--json]',
19
23
  flags: {
20
- '--session <id>': 'Session to link the file to (required unless $PREVIEW_SESSION is set)',
21
- '--unlink': 'Remove the link instead of adding it',
24
+ '<file|url>': 'Any file type; one the preview cannot render opens in the editor. An http(s) URL opens in a new tab.',
25
+ '--session <id>': 'Session to link the file to (required unless $PREVIEW_SESSION is set); full id or unique prefix',
26
+ '--unlink': 'Remove the link instead of adding it (the file need not exist)',
27
+ '--list': 'Print the docs the server holds for the session',
28
+ '--json': 'With --list: output JSON',
22
29
  },
30
+ notes: 'The server keeps the link, so it shows when a browser tab opens later.',
31
+ examples: [
32
+ 'claude-code-kanban link-doc ./design.md --session $CLAUDE_SESSION_ID',
33
+ 'claude-code-kanban link-doc https://github.com/org/repo/pull/12 --session $CLAUDE_SESSION_ID',
34
+ 'claude-code-kanban link-doc --list --session $CLAUDE_SESSION_ID',
35
+ ],
23
36
  run: runLinkDocCli,
24
37
  },
25
38
  session: {
26
- summary: 'List or open Claude Code sessions',
39
+ summary: 'List, search, open and inspect Claude Code sessions',
27
40
  verbs: {
28
41
  list: {
29
42
  summary: 'List sessions (pinned/sticky always included)',
@@ -31,19 +44,37 @@ const COMMANDS = {
31
44
  flags: {
32
45
  '--active': 'Only sessions with recent activity (sidebar-style filter)',
33
46
  '--days <n>': 'Only sessions modified within the last N days (fractional ok, e.g. 0.5)',
34
- '--project <name>': 'Filter by project name (substring match)',
47
+ '--project <name>': 'Filter by project: an absolute path selects one project, other text matches a part of the path',
35
48
  '--limit <n|all>': 'Max rows to display (default: 10). Use "all" for no cap.',
36
49
  '--no-pins': 'Disable always-include and sticky-first ordering for pinned sessions',
37
50
  '--json': 'Output JSON instead of a table',
38
51
  },
52
+ examples: [
53
+ 'claude-code-kanban session list --active',
54
+ 'claude-code-kanban session list --days 0.5 --limit all --project my-repo',
55
+ ],
39
56
  run: runSessionListCli,
40
57
  },
58
+ search: {
59
+ summary: 'Find sessions whose name or id contains the text, from any transcript',
60
+ usage: 'claude-code-kanban session search <text> [--limit <n>] [--json]',
61
+ flags: {
62
+ '<text>': 'At least 3 characters; matched against the session name and id',
63
+ '--limit <n>': 'Max rows (default and max: 20)',
64
+ '--json': 'Output JSON instead of a table',
65
+ },
66
+ examples: [
67
+ 'claude-code-kanban session search login-redirect',
68
+ ],
69
+ run: runSessionSearchCli,
70
+ },
41
71
  open: {
42
72
  summary: 'Focus a session in the browser (Active tab)',
43
73
  usage: 'claude-code-kanban session open <id>',
44
74
  flags: {
45
75
  '<id>': 'Full session id, or a unique prefix',
46
76
  },
77
+ examples: ['claude-code-kanban session open $CLAUDE_SESSION_ID'],
47
78
  run: runSessionOpenCli,
48
79
  },
49
80
  view: {
@@ -53,8 +84,30 @@ const COMMANDS = {
53
84
  '<id>': 'Full session id, or a unique prefix',
54
85
  '--json': 'Output JSON instead of formatted sections',
55
86
  },
87
+ examples: ['claude-code-kanban session view $CLAUDE_SESSION_ID'],
56
88
  run: runSessionViewCli,
57
89
  },
90
+ plan: {
91
+ summary: 'Print the plan saved for a session (plan mode)',
92
+ usage: 'claude-code-kanban session plan <id> [--json]',
93
+ flags: {
94
+ '<id>': 'Full session id, or a unique prefix',
95
+ '--json': 'Output JSON ({content, slug}); content is null when there is no plan',
96
+ },
97
+ examples: ['claude-code-kanban session plan 3fa9c1'],
98
+ run: runSessionPlanCli,
99
+ },
100
+ agents: {
101
+ summary: 'List the subagents a session has run, and whether it waits for the user',
102
+ usage: 'claude-code-kanban session agents <id> [--json]',
103
+ flags: {
104
+ '<id>': 'Full session id, or a unique prefix',
105
+ '--json': 'Output JSON ({agents, waitingForUser})',
106
+ },
107
+ notes: 'Needs the cck hooks in this config dir (claude-code-kanban --install); without them the list is empty.',
108
+ examples: ['claude-code-kanban session agents $CLAUDE_SESSION_ID'],
109
+ run: runSessionAgentsCli,
110
+ },
58
111
  pin: {
59
112
  summary: 'Pin (or unpin) a session in the sidebar of connected browser tabs',
60
113
  usage: 'claude-code-kanban session pin <id> [--sticky] [--unpin]',
@@ -63,6 +116,10 @@ const COMMANDS = {
63
116
  '--sticky': 'Set sticky state (always shown, top of list)',
64
117
  '--unpin': 'Clear pin/sticky state',
65
118
  },
119
+ examples: [
120
+ 'claude-code-kanban session pin $CLAUDE_SESSION_ID --sticky',
121
+ 'claude-code-kanban session pin $CLAUDE_SESSION_ID --unpin',
122
+ ],
66
123
  run: runSessionPinCli,
67
124
  },
68
125
  pins: {
@@ -82,10 +139,46 @@ const COMMANDS = {
82
139
  '--limit <n>': 'Number of messages (default: 10, max: 50)',
83
140
  '--json': 'Output JSON instead of formatted lines',
84
141
  },
142
+ examples: ['claude-code-kanban session peek 3fa9c1 --limit 20'],
85
143
  run: runSessionPeekCli,
86
144
  },
87
145
  },
88
146
  },
147
+ task: {
148
+ summary: 'Read the tasks on the board',
149
+ verbs: {
150
+ list: {
151
+ summary: 'List the tasks of a session, a project, or every session',
152
+ usage: 'claude-code-kanban task list (<session> | --project <path> | --all) [--status <s>] [--json]',
153
+ flags: {
154
+ '<session>': 'Full session id, or a unique prefix',
155
+ '--project <path>': 'Every task of the sessions in this project (absolute path, as in `project list`)',
156
+ '--all': 'Every task on the board',
157
+ '--status <s>': 'Only tasks in this status: pending, in_progress or completed',
158
+ '--json': 'Output JSON instead of a table',
159
+ },
160
+ examples: [
161
+ 'claude-code-kanban task list $CLAUDE_SESSION_ID',
162
+ 'claude-code-kanban task list --all --status in_progress',
163
+ ],
164
+ run: runTaskListCli,
165
+ },
166
+ },
167
+ },
168
+ project: {
169
+ summary: 'Read the projects the board knows',
170
+ verbs: {
171
+ list: {
172
+ summary: 'List known project paths, newest activity first',
173
+ usage: 'claude-code-kanban project list [--json]',
174
+ flags: {
175
+ '--json': 'Output JSON instead of a table',
176
+ },
177
+ notes: 'These are the folders `dispatch start --cwd` accepts.',
178
+ run: runProjectListCli,
179
+ },
180
+ },
181
+ },
89
182
  dispatch: {
90
183
  summary: 'Start a Claude Code session for a task in cck and collect its report',
91
184
  verbs: {
@@ -104,6 +197,10 @@ const COMMANDS = {
104
197
  '--worktree [name]': 'Run in a new git worktree',
105
198
  '--json': 'Output JSON',
106
199
  },
200
+ notes: 'Needs the terminal token, so it runs on the machine of the cck server. Run `claude-code-kanban skills get dispatch` for how to write the spec.',
201
+ examples: [
202
+ 'claude-code-kanban dispatch start --cwd . --spec-file spec.md --name fix-login-redirect --group auth-refactor --peer my-peer --report --json',
203
+ ],
107
204
  run: runDispatchStartCli,
108
205
  },
109
206
  done: {
@@ -116,6 +213,9 @@ const COMMANDS = {
116
213
  '--summary <text>': 'What changed, what was found, what remains',
117
214
  '--summary-file <path>': 'Read the summary from a file',
118
215
  },
216
+ examples: [
217
+ 'claude-code-kanban dispatch done d_1a2b3c --cap <cap> --outcome succeeded --summary-file summary.md',
218
+ ],
119
219
  run: runDispatchDoneCli,
120
220
  },
121
221
  wait: {
@@ -126,6 +226,8 @@ const COMMANDS = {
126
226
  '--timeout <dur>': 'How long to wait, e.g. 90s, 15m, 1h (default: 10m)',
127
227
  '--json': 'Output JSON',
128
228
  },
229
+ notes: 'Returns as soon as any watched dispatch settles, with settled, running and timeout.',
230
+ examples: ['claude-code-kanban dispatch wait --timeout 15m --json'],
129
231
  run: runDispatchWaitCli,
130
232
  },
131
233
  list: {
@@ -146,12 +248,18 @@ const COMMANDS = {
146
248
  summary: 'Print the guide for a skill',
147
249
  usage: 'claude-code-kanban skills get <name>',
148
250
  flags: { '<name>': 'Skill name, e.g. dispatch' },
251
+ examples: ['claude-code-kanban skills get dispatch'],
149
252
  run: runSkillsGetCli,
150
253
  },
151
254
  },
152
255
  },
153
256
  };
154
257
 
258
+ for (const [noun, cmd] of Object.entries(COMMANDS)) {
259
+ cmd.name = noun;
260
+ for (const [verb, v] of Object.entries(cmd.verbs || {})) v.name = `${noun} ${verb}`;
261
+ }
262
+
155
263
  function runCli(argv) {
156
264
  if (argv.includes('--version') || argv.includes('-v')) {
157
265
  console.log(require('./package.json').version);
@@ -160,7 +268,10 @@ function runCli(argv) {
160
268
  const cli = resolveCliCommand(argv);
161
269
  if (cli.kind === 'server') return false;
162
270
  if (cli.kind === 'help') {
163
- if (cli.target && Object.hasOwn(COMMANDS, cli.target)) printNounHelp(cli.target);
271
+ const noun = cli.target && Object.hasOwn(COMMANDS, cli.target) ? COMMANDS[cli.target] : null;
272
+ const verb = noun?.verbs && cli.verb && Object.hasOwn(noun.verbs, cli.verb) ? noun.verbs[cli.verb] : null;
273
+ if (verb) printLeafHelp(verb);
274
+ else if (noun) printNounHelp(cli.target);
164
275
  else printTopHelp();
165
276
  process.exit(0);
166
277
  }
@@ -176,11 +287,11 @@ function runCli(argv) {
176
287
  }
177
288
  if (cli.kind === 'noun') {
178
289
  printNounHelp(cli.noun);
179
- process.exit(0);
290
+ process.exit(argv.includes('--help') || argv.includes('-h') ? 0 : 1);
180
291
  }
181
292
  if (cli.kind === 'leaf') {
182
293
  if (cli.args.includes('--help') || cli.args.includes('-h')) {
183
- printLeafHelp(cli.name, cli.entry);
294
+ printLeafHelp(cli.entry);
184
295
  process.exit(0);
185
296
  }
186
297
  cli.entry.run(cli.args)
@@ -195,7 +306,7 @@ function resolveCliCommand(argv) {
195
306
  const noun = argv[2] && !argv[2].startsWith('-') ? argv[2] : null;
196
307
  const hasHelp = (a) => a.includes('--help') || a.includes('-h');
197
308
  if (!noun) return hasHelp(argv) ? { kind: 'help' } : { kind: 'server' };
198
- if (noun === 'help') return { kind: 'help', target: argv[3] };
309
+ if (noun === 'help') return { kind: 'help', target: argv[3], verb: argv[4] };
199
310
  if (!Object.hasOwn(COMMANDS, noun)) return { kind: 'unknown-noun', noun };
200
311
  const entry = COMMANDS[noun];
201
312
  if (!entry.verbs) return { kind: 'leaf', name: noun, entry, args: argv.slice(3) };
@@ -209,12 +320,8 @@ function printTopHelp() {
209
320
  console.log('Usage: claude-code-kanban <command> [args] [--flags]\n');
210
321
  console.log('Commands:');
211
322
  for (const [name, cmd] of Object.entries(COMMANDS)) {
212
- console.log(` ${name.padEnd(20)}${cmd.summary}`);
213
- if (cmd.verbs) {
214
- for (const [vName, v] of Object.entries(cmd.verbs)) {
215
- console.log(` ${`${name} ${vName}`.padEnd(18)}${v.summary}`);
216
- }
217
- }
323
+ const verbs = cmd.verbs ? ` (${Object.keys(cmd.verbs).join(', ')})` : '';
324
+ console.log(` ${name.padEnd(20)}${cmd.summary}${verbs}`);
218
325
  }
219
326
  console.log(` ${'help'.padEnd(20)}Show help for a command (claude-code-kanban help <command>)`);
220
327
  console.log('\nFlags:');
@@ -226,6 +333,11 @@ function printTopHelp() {
226
333
  console.log(' --open Open browser on start');
227
334
  console.log(' --install, --uninstall Install or remove the plugin, context spy, and statusline');
228
335
  console.log(' --plugin-only With --install: refresh only the plugin, skip context spy and statusline');
336
+ console.log('\nEnvironment:');
337
+ console.log(' CCK_URL Server base URL, e.g. http://127.0.0.1:4795 (wins over PORT)');
338
+ console.log(' PORT Server port (default: the one this config dir\'s server reports, else 3541)');
339
+ console.log(' CLAUDE_CONFIG_DIR Claude config dir whose board to use');
340
+ console.log('\nRun `claude-code-kanban help <command>` for its subcommands, and `help <command> <subcommand>` for flags and examples.');
229
341
  }
230
342
 
231
343
  function printNounHelp(noun) {
@@ -237,23 +349,33 @@ function printNounHelp(noun) {
237
349
  for (const [vName, v] of Object.entries(entry.verbs)) {
238
350
  console.log(` ${vName.padEnd(12)}${v.summary}`);
239
351
  }
240
- console.log(`\nRun \`claude-code-kanban ${noun} <subcommand> --help\` for details.`);
352
+ console.log(`\nRun \`claude-code-kanban help ${noun} <subcommand>\` for flags and examples.`);
241
353
  } else {
242
- printLeafHelp(noun, entry);
354
+ printLeafHelp(entry);
243
355
  }
244
356
  }
245
357
 
246
- function printLeafHelp(name, entry) {
358
+ function printLeafHelp(entry) {
247
359
  console.log(`${entry.summary}\n`);
248
360
  console.log(`Usage: ${entry.usage}`);
249
- if (entry.flags && Object.keys(entry.flags).length) {
250
- const pad = Math.max(...Object.keys(entry.flags).map(f => f.length));
251
- console.log('\nFlags:');
252
- for (const [flag, desc] of Object.entries(entry.flags)) {
253
- console.log(` ${flag.padEnd(pad + 2)}${desc}`);
254
- }
361
+ const flags = { ...entry.flags, '--help, -h': 'Show this help' };
362
+ const pad = Math.max(...Object.keys(flags).map(f => f.length));
363
+ console.log('\nFlags:');
364
+ for (const [flag, desc] of Object.entries(flags)) {
365
+ console.log(` ${flag.padEnd(pad + 2)}${desc}`);
255
366
  }
256
- console.log('\n --help, -h Show this help');
367
+ if (entry.notes) console.log(`\n${entry.notes}`);
368
+ if (entry.examples?.length) {
369
+ console.log('\nExamples:');
370
+ for (const ex of entry.examples) console.log(` ${ex}`);
371
+ }
372
+ }
373
+
374
+ // For a bad value: the leaf help would bury the one line that says what is wrong.
375
+ function usageError(entry, message) {
376
+ console.error(message);
377
+ console.error(`Run \`claude-code-kanban help ${entry.name}\` for usage.`);
378
+ return 1;
257
379
  }
258
380
 
259
381
  function getArgValue(args, name) {
@@ -266,10 +388,24 @@ function getArgValue(args, name) {
266
388
 
267
389
  // The hub runs one cck per config dir, each on its own port, so 3541 can be another dir's board.
268
390
  // The server's beacon in this config dir names the right one; PORT still wins when set.
391
+ // A beacon left by a dead server means this dir's board is down: 3541 would be someone else's.
392
+ // Returns null in that case.
269
393
  function cliPort() {
270
394
  if (process.env.PORT) return process.env.PORT;
271
- const { port, pid } = readCckJson('server.json') || {};
272
- return port && pid && isPidAlive(pid) ? port : 3541;
395
+ const beacon = readCckJson('server.json');
396
+ if (!beacon) return 3541;
397
+ return beacon.port && beacon.pid && isPidAlive(beacon.pid) ? beacon.port : null;
398
+ }
399
+
400
+ function cliBaseUrl() {
401
+ if (process.env.CCK_URL) return process.env.CCK_URL.replace(/\/+$/, '');
402
+ const port = cliPort();
403
+ return port === null ? null : `http://127.0.0.1:${port}`;
404
+ }
405
+
406
+ function cliTargetPort() {
407
+ if (!process.env.CCK_URL) return cliPort();
408
+ try { return new URL(process.env.CCK_URL).port; } catch (_) { return ''; }
273
409
  }
274
410
 
275
411
  function readCckJson(name) {
@@ -281,14 +417,21 @@ function isPidAlive(pid) {
281
417
  try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
282
418
  }
283
419
  function unreachable() {
284
- return `Cannot reach cck server for ${displayPath(getClaudeDir())} on port ${cliPort()}. Start it first with "claude-code-kanban".`;
420
+ const dir = displayPath(getClaudeDir());
421
+ const start = 'Start it first with "claude-code-kanban".';
422
+ if (process.env.CCK_URL) return `Cannot reach cck server for ${dir} at ${cliBaseUrl()} (CCK_URL). ${start}`;
423
+ const port = cliPort();
424
+ if (port === null) return `Cannot reach cck server for ${dir}: its server.json names a server that is no longer running. ${start}`;
425
+ return `Cannot reach cck server for ${dir} on port ${port}. ${start}`;
285
426
  }
286
427
 
287
428
  class CliUnreachable extends Error { constructor() { super(unreachable()); this.code = 'unreachable'; } }
288
429
 
289
430
  async function cliFetch(urlPath, init) {
431
+ const base = cliBaseUrl();
432
+ if (!base) throw new CliUnreachable();
290
433
  try {
291
- return await fetch(`http://127.0.0.1:${cliPort()}${urlPath}`, init);
434
+ return await fetch(`${base}${urlPath}`, init);
292
435
  } catch (e) {
293
436
  if (e.cause?.code === 'ECONNREFUSED' || /fetch failed/i.test(e.message)) throw new CliUnreachable();
294
437
  throw e;
@@ -319,10 +462,12 @@ function reportCliError(e) {
319
462
  async function runPreviewCli(args) {
320
463
  const filePathArg = args.find(a => !a.startsWith('--'));
321
464
  if (!filePathArg) {
322
- printLeafHelp('preview-doc', COMMANDS['preview-doc']);
465
+ printLeafHelp(COMMANDS['preview-doc']);
323
466
  return 1;
324
467
  }
325
468
  const sessionId = getArgValue(args, 'session') || process.env.PREVIEW_SESSION || null;
469
+ const url = linkUrl(filePathArg);
470
+ if (url) return previewUrlCli(url, sessionId);
326
471
  const abs = path.resolve(filePathArg);
327
472
  try {
328
473
  if (!await cliPostJson('/api/preview', { path: abs, sessionId }, 'Preview')) return 1;
@@ -331,25 +476,53 @@ async function runPreviewCli(args) {
331
476
  } catch (e) { reportCliError(e); return 1; }
332
477
  }
333
478
 
479
+ // The modal cannot show a web page, so a URL is linked instead and the tab on screen
480
+ // offers to open it.
481
+ async function previewUrlCli(url, sessionArg) {
482
+ const entry = COMMANDS['preview-doc'];
483
+ if (!sessionArg) return usageError(entry, '--session is required for a URL: it is linked to the session.');
484
+ const resolved = await resolveSessionByIdOrPrefix(sessionArg);
485
+ if (!resolved) return 1;
486
+ return postDocLink(url, resolved.id, { open: true });
487
+ }
488
+
489
+ async function postDocLink(target, sessionId, { unlink = false, open = false } = {}) {
490
+ try {
491
+ const out = await cliPostJson('/api/document/link', { path: target, sessionId, unlink, open }, 'Link');
492
+ if (!out) return 1;
493
+ const what = linkUrl(target) ? 'URL' : 'Document';
494
+ console.log(`${what} ${unlink ? 'unlinked from' : 'linked to'} session ${sessionId.slice(0, 8)}: ${out.path}`);
495
+ if (!unlink && out.tabs === 0) console.log('No browser tab is open; the board shows it when one opens.');
496
+ return 0;
497
+ } catch (e) { reportCliError(e); return 1; }
498
+ }
499
+
334
500
  async function runLinkDocCli(args) {
335
- const filePathArg = args.find(a => !a.startsWith('--'));
501
+ const entry = COMMANDS['link-doc'];
502
+ const [filePathArg] = positionals(args, ['--session']);
336
503
  const sessionArg = getArgValue(args, 'session') || process.env.PREVIEW_SESSION || null;
337
- if (!filePathArg) {
338
- printLeafHelp('link-doc', COMMANDS['link-doc']);
339
- return 1;
340
- }
341
- if (!sessionArg) {
342
- console.error('--session is required: linked docs are stored per session.');
504
+ const list = args.includes('--list');
505
+ if (!filePathArg && !list) {
506
+ printLeafHelp(entry);
343
507
  return 1;
344
508
  }
509
+ if (!sessionArg) return usageError(entry, '--session is required: linked docs are stored per session.');
345
510
  const unlink = args.includes('--unlink');
346
511
  // Resolved here because the browser keys linked docs by full id, so a prefix won't match.
347
512
  const resolved = await resolveSessionByIdOrPrefix(sessionArg);
348
513
  if (!resolved) return 1;
349
- const abs = path.resolve(filePathArg);
514
+ if (list) return printLinkedDocs(resolved.id, args.includes('--json'));
515
+ return postDocLink(linkUrl(filePathArg) || path.resolve(filePathArg), resolved.id, { unlink });
516
+ }
517
+
518
+ async function printLinkedDocs(sessionId, asJson) {
350
519
  try {
351
- if (!await cliPostJson('/api/document/link', { path: abs, sessionId: resolved.id, unlink }, 'Link')) return 1;
352
- console.log(`Document ${unlink ? 'unlinked from' : 'linked to'} session ${resolved.id.slice(0, 8)}: ${abs}`);
520
+ const res = await cliFetch(`/api/document/links?session=${encodeURIComponent(sessionId)}`);
521
+ if (!res.ok) throw new Error(`Failed to fetch linked docs (${res.status})`);
522
+ const paths = (await res.json())[sessionId] || [];
523
+ if (asJson) console.log(JSON.stringify(paths, null, 2));
524
+ else if (!paths.length) console.log(`No linked docs for session ${sessionId.slice(0, 8)}.`);
525
+ else for (const p of paths) console.log(p);
353
526
  return 0;
354
527
  } catch (e) { reportCliError(e); return 1; }
355
528
  }
@@ -375,10 +548,11 @@ function parseLimit(args, { fallback, allowAll = false }) {
375
548
  return { ok: true, limit: n };
376
549
  }
377
550
 
378
- async function fetchSessionsList(limit, pinnedIds = []) {
551
+ async function fetchSessionsList(limit, pinnedIds = [], project = null) {
379
552
  const q = limit === null ? 'all' : String(limit);
380
553
  const pinnedQ = pinnedIds.length ? `&pinned=${pinnedIds.join(',')}` : '';
381
- const res = await cliFetch(`/api/sessions?limit=${q}${pinnedQ}`);
554
+ const projectQ = project ? `&project=${encodeURIComponent(project)}` : '';
555
+ const res = await cliFetch(`/api/sessions?limit=${q}${pinnedQ}${projectQ}`);
382
556
  if (!res.ok) throw new Error(`Failed to fetch sessions (${res.status})`);
383
557
  return res.json();
384
558
  }
@@ -424,19 +598,18 @@ async function runSessionListCli(args) {
424
598
  const daysArg = getArgValue(args, 'days');
425
599
  const days = daysArg !== null ? parseFloat(daysArg) : null;
426
600
  if (daysArg !== null && (Number.isNaN(days) || days <= 0)) {
427
- console.error(`Invalid --days value: ${daysArg}`);
428
- return 1;
601
+ return usageError(COMMANDS.session.verbs.list, `Invalid --days value: ${daysArg}`);
429
602
  }
430
603
  const parsed = parseLimit(args, { fallback: 10, allowAll: true });
431
- if (!parsed.ok) { console.error(parsed.error); return 1; }
604
+ if (!parsed.ok) return usageError(COMMANDS.session.verbs.list, parsed.error);
432
605
  const limit = parsed.limit;
433
606
  const asJson = args.includes('--json');
434
607
  const pinsMap = noPins ? {} : await fetchPinsMap();
435
608
  const pinnedIds = Object.keys(pinsMap);
436
- const hasClientFilter = activeOnly || days !== null || projectFilter;
609
+ const hasClientFilter = activeOnly || days !== null;
437
610
  let list;
438
611
  try {
439
- list = await fetchSessionsList(hasClientFilter ? null : limit, pinnedIds);
612
+ list = await fetchSessionsList(hasClientFilter ? null : limit, pinnedIds, projectFilter);
440
613
  } catch (e) {
441
614
  reportCliError(e);
442
615
  return 1;
@@ -447,10 +620,6 @@ async function runSessionListCli(args) {
447
620
  const cutoff = Date.now() - days * 86_400_000;
448
621
  list = list.filter(s => pinOf(s.id) || (s.modifiedAt && new Date(s.modifiedAt).getTime() >= cutoff));
449
622
  }
450
- if (projectFilter) {
451
- const needle = projectFilter.toLowerCase();
452
- list = list.filter(s => (s.project || '').toLowerCase().includes(needle));
453
- }
454
623
  const pinRank = id => pinOf(id) === 'sticky' ? 0 : pinOf(id) === 'pinned' ? 1 : 2;
455
624
  list.sort((a, b) => {
456
625
  const r = pinRank(a.id) - pinRank(b.id);
@@ -510,7 +679,7 @@ function formatAge(ms) {
510
679
  async function runSessionOpenCli(args) {
511
680
  const idArg = args.find(a => !a.startsWith('--'));
512
681
  if (!idArg) {
513
- printLeafHelp('session open', COMMANDS.session.verbs.open);
682
+ printLeafHelp(COMMANDS.session.verbs.open);
514
683
  return 1;
515
684
  }
516
685
  const resolved = await resolveSessionByIdOrPrefix(idArg);
@@ -525,7 +694,7 @@ async function runSessionOpenCli(args) {
525
694
  async function runSessionPinCli(args) {
526
695
  const idArg = args.find(a => !a.startsWith('--'));
527
696
  if (!idArg) {
528
- printLeafHelp('session pin', COMMANDS.session.verbs.pin);
697
+ printLeafHelp(COMMANDS.session.verbs.pin);
529
698
  return 1;
530
699
  }
531
700
  const state = args.includes('--unpin') ? 'none' : args.includes('--sticky') ? 'sticky' : 'pinned';
@@ -555,7 +724,7 @@ async function runSessionPinsCli(args) {
555
724
  sessions = await fetchSessionsList(items.length, items.map(p => p.id));
556
725
  } catch (e) { reportCliError(e); return 1; }
557
726
  const byId = new Map(sessions.map(s => [s.id, s]));
558
- let rows = items
727
+ const rows = items
559
728
  .map(p => {
560
729
  const s = byId.get(p.id) || {};
561
730
  return {
@@ -589,7 +758,7 @@ async function runSessionPinsCli(args) {
589
758
  async function runSessionViewCli(args) {
590
759
  const idArg = args.find(a => !a.startsWith('--'));
591
760
  if (!idArg) {
592
- printLeafHelp('session view', COMMANDS.session.verbs.view);
761
+ printLeafHelp(COMMANDS.session.verbs.view);
593
762
  return 1;
594
763
  }
595
764
  const asJson = args.includes('--json');
@@ -644,11 +813,11 @@ async function runSessionViewCli(args) {
644
813
  async function runSessionPeekCli(args) {
645
814
  const idArg = args.find(a => !a.startsWith('--'));
646
815
  if (!idArg) {
647
- printLeafHelp('session peek', COMMANDS.session.verbs.peek);
816
+ printLeafHelp(COMMANDS.session.verbs.peek);
648
817
  return 1;
649
818
  }
650
819
  const parsed = parseLimit(args, { fallback: 10 });
651
- if (!parsed.ok) { console.error(parsed.error); return 1; }
820
+ if (!parsed.ok) return usageError(COMMANDS.session.verbs.peek, parsed.error);
652
821
  const limit = parsed.limit;
653
822
  const asJson = args.includes('--json');
654
823
  const resolved = await resolveSessionByIdOrPrefix(idArg);
@@ -680,6 +849,168 @@ async function runSessionPeekCli(args) {
680
849
  } catch (e) { reportCliError(e); return 1; }
681
850
  }
682
851
 
852
+ function printTable(header, rows) {
853
+ const last = header.length - 1;
854
+ const width = header.map((h, i) => Math.max(h.length, ...rows.map(r => String(r[i]).length)));
855
+ const line = cells => cells.map((c, i) => (i === last ? String(c) : String(c).padEnd(width[i]))).join(' ');
856
+ console.log(line(header));
857
+ for (const r of rows) console.log(line(r));
858
+ }
859
+
860
+ const ageOf = (iso) => (iso ? formatAge(Date.now() - new Date(iso).getTime()) : '-');
861
+
862
+ async function cliGetJson(urlPath, label) {
863
+ const res = await cliFetch(urlPath);
864
+ if (!res.ok) throw new Error(`${label} failed (${res.status}): ${await res.text()}`);
865
+ return res.json();
866
+ }
867
+
868
+ async function runSessionSearchCli(args) {
869
+ const entry = COMMANDS.session.verbs.search;
870
+ const text = positionals(args, ['--limit']).join(' ').trim();
871
+ if (!text) {
872
+ printLeafHelp(entry);
873
+ return 1;
874
+ }
875
+ if (text.length < 3) return usageError(entry, 'Search text needs at least 3 characters.');
876
+ const parsed = parseLimit(args, { fallback: 20 });
877
+ if (!parsed.ok) return usageError(entry, parsed.error);
878
+ try {
879
+ const ids = (await cliGetJson(`/api/sessions/search?q=${encodeURIComponent(text)}`, 'Search')).slice(0, parsed.limit);
880
+ const list = ids.length ? await cliGetJson(`/api/sessions?limit=1&include=${ids.join(',')}`, 'Search') : [];
881
+ const byId = new Map(list.map(s => [s.id, s]));
882
+ const rows = ids.map(id => byId.get(id) || { id });
883
+ if (args.includes('--json')) {
884
+ console.log(JSON.stringify(rows, null, 2));
885
+ return 0;
886
+ }
887
+ if (!rows.length) {
888
+ console.log(`No sessions match "${text}".`);
889
+ return 0;
890
+ }
891
+ printTable(['ID', 'STATUS', 'AGE', 'PROJECT', 'TITLE'], rows.map(s => [
892
+ s.id.slice(0, 8),
893
+ byId.has(s.id) ? sessionStatus(s) : '-',
894
+ ageOf(s.modifiedAt),
895
+ path.basename(s.project || ''),
896
+ s.customTitle || s.name || s.slug || '',
897
+ ]));
898
+ return 0;
899
+ } catch (e) { reportCliError(e); return 1; }
900
+ }
901
+
902
+ async function runSessionPlanCli(args) {
903
+ const [idArg] = positionals(args, []);
904
+ if (!idArg) {
905
+ printLeafHelp(COMMANDS.session.verbs.plan);
906
+ return 1;
907
+ }
908
+ const resolved = await resolveSessionByIdOrPrefix(idArg);
909
+ if (!resolved) return 1;
910
+ try {
911
+ const plan = await cliGetJson(`/api/sessions/${resolved.id}/plan`, 'Plan');
912
+ if (args.includes('--json')) console.log(JSON.stringify(plan, null, 2));
913
+ else if (!plan.content) console.log(`No plan saved for session ${resolved.id.slice(0, 8)}.`);
914
+ else process.stdout.write(plan.content.endsWith('\n') ? plan.content : `${plan.content}\n`);
915
+ return 0;
916
+ } catch (e) { reportCliError(e); return 1; }
917
+ }
918
+
919
+ async function runSessionAgentsCli(args) {
920
+ const [idArg] = positionals(args, []);
921
+ if (!idArg) {
922
+ printLeafHelp(COMMANDS.session.verbs.agents);
923
+ return 1;
924
+ }
925
+ const resolved = await resolveSessionByIdOrPrefix(idArg);
926
+ if (!resolved) return 1;
927
+ try {
928
+ const out = await cliGetJson(`/api/sessions/${resolved.id}/agents`, 'Agents');
929
+ if (args.includes('--json')) {
930
+ console.log(JSON.stringify(out, null, 2));
931
+ return 0;
932
+ }
933
+ const agents = out.agents || [];
934
+ if (!agents.length) console.log(`No agents for session ${resolved.id.slice(0, 8)}.`);
935
+ else {
936
+ printTable(['AGENT', 'STATUS', 'AGE', 'TYPE', 'DESCRIPTION'], agents.map(a => [
937
+ String(a.agentId || '').slice(0, 8),
938
+ a.status || '-',
939
+ ageOf(a.updatedAt || a.startedAt),
940
+ a.type || a.agentType || '',
941
+ (a.description || a.name || '').replace(/\s+/g, ' ').trim(),
942
+ ]));
943
+ }
944
+ if (out.waitingForUser) console.log('Waiting for the user.');
945
+ return 0;
946
+ } catch (e) { reportCliError(e); return 1; }
947
+ }
948
+
949
+ async function runTaskListCli(args) {
950
+ const entry = COMMANDS.task.verbs.list;
951
+ const [sessionArg] = positionals(args, ['--project', '--status']);
952
+ const project = getArgValue(args, 'project');
953
+ const all = args.includes('--all');
954
+ const status = getArgValue(args, 'status');
955
+ const sources = [sessionArg, project, all || null].filter(Boolean).length;
956
+ if (sources === 0) {
957
+ printLeafHelp(entry);
958
+ return 1;
959
+ }
960
+ if (sources > 1) return usageError(entry, 'Give one of <session>, --project or --all.');
961
+ if (args.includes('--status') && !status) return usageError(entry, '--status needs a value, e.g. in_progress');
962
+ let tasks;
963
+ let resolved = null;
964
+ try {
965
+ if (sessionArg) {
966
+ resolved = await resolveSessionByIdOrPrefix(sessionArg);
967
+ if (!resolved) return 1;
968
+ tasks = await cliGetJson(`/api/sessions/${resolved.id}`, 'Task list');
969
+ } else if (project) {
970
+ const encoded = Buffer.from(canonicalDir(project), 'utf8').toString('base64');
971
+ tasks = await cliGetJson(`/api/projects/${encodeURIComponent(encoded)}/tasks`, 'Task list');
972
+ } else {
973
+ tasks = await cliGetJson('/api/tasks/all', 'Task list');
974
+ }
975
+ } catch (e) { reportCliError(e); return 1; }
976
+ if (status) tasks = tasks.filter(t => t.status === status);
977
+ if (args.includes('--json')) {
978
+ console.log(JSON.stringify(tasks, null, 2));
979
+ return 0;
980
+ }
981
+ if (!tasks.length) {
982
+ console.log('No tasks match.');
983
+ return 0;
984
+ }
985
+ const showSource = !resolved;
986
+ const header = ['ID', 'STATUS', ...(showSource ? ['SESSION'] : []), 'SUBJECT'];
987
+ printTable(header, tasks.map(t => [
988
+ t.id,
989
+ t.status || '-',
990
+ ...(showSource ? [String(t.sessionId || t._taskDir || '').slice(0, 8)] : []),
991
+ `${t.subject || ''}${t.blockedBy?.length ? ` (blocked by ${t.blockedBy.join(', ')})` : ''}`,
992
+ ]));
993
+ return 0;
994
+ }
995
+
996
+ async function runProjectListCli(args) {
997
+ let projects;
998
+ try {
999
+ projects = await cliGetJson('/api/projects', 'Project list');
1000
+ } catch (e) { reportCliError(e); return 1; }
1001
+ projects.sort((a, b) => new Date(b.modifiedAt || 0) - new Date(a.modifiedAt || 0));
1002
+ if (args.includes('--json')) {
1003
+ console.log(JSON.stringify(projects, null, 2));
1004
+ return 0;
1005
+ }
1006
+ if (!projects.length) {
1007
+ console.log('No projects.');
1008
+ return 0;
1009
+ }
1010
+ printTable(['AGE', 'PATH'], projects.map(p => [ageOf(p.modifiedAt), `${p.path}${p.temp ? ' (temp)' : ''}`]));
1011
+ return 0;
1012
+ }
1013
+
683
1014
  function positionals(args, valueFlags) {
684
1015
  return args.filter((a, i) => !a.startsWith('--') && !valueFlags.includes(args[i - 1]));
685
1016
  }
@@ -708,23 +1039,27 @@ function printDispatch(r) {
708
1039
  }
709
1040
 
710
1041
  async function runDispatchStartCli(args) {
711
- const token = readCckJson('terminal-token.json')?.token;
1042
+ const port = cliTargetPort();
1043
+ if (port === null) {
1044
+ console.error(unreachable());
1045
+ return 1;
1046
+ }
1047
+ const token = port && readCckJson(`terminal-tokens/${port}.json`)?.token;
712
1048
  if (!token) {
713
- console.error(`No terminal token for ${displayPath(getClaudeDir())}. The cck server must be running with the terminal enabled.`);
1049
+ console.error(`No terminal token for ${displayPath(getClaudeDir())} at ${cliBaseUrl()}. The cck server must be running with the terminal enabled.`);
714
1050
  return 1;
715
1051
  }
716
1052
  let spec;
717
1053
  try { spec = textArg(args, 'spec'); } catch (e) { console.error(e.message); return 1; }
718
1054
  if (!spec) {
719
- printLeafHelp('dispatch start', COMMANDS.dispatch.verbs.start);
1055
+ printLeafHelp(COMMANDS.dispatch.verbs.start);
720
1056
  return 1;
721
1057
  }
722
1058
  const hasGroup = args.some(a => a === '--group' || a.startsWith('--group='));
723
1059
  const group = hasGroup ? getArgValue(args, 'group') || '' : null;
724
1060
  if (hasGroup && !isGroupName(group)) {
725
1061
  const hint = suggestGroupName(group);
726
- console.error(`Group names are kebab-case${hint ? `: try --group ${hint}` : ', e.g. auth-refactor'}`);
727
- return 1;
1062
+ return usageError(COMMANDS.dispatch.verbs.start, `Group names are kebab-case${hint ? `: try --group ${hint}` : ', e.g. auth-refactor'}`);
728
1063
  }
729
1064
  const worktree = args.includes('--worktree') ? getArgValue(args, 'worktree') || true : false;
730
1065
  const body = {
@@ -753,7 +1088,7 @@ async function runDispatchDoneCli(args) {
753
1088
  try { summary = textArg(args, 'summary'); } catch (e) { console.error(e.message); return 1; }
754
1089
  const body = { cap: getArgValue(args, 'cap'), outcome: getArgValue(args, 'outcome'), summary };
755
1090
  if (!id || !body.cap || !body.outcome) {
756
- printLeafHelp('dispatch done', COMMANDS.dispatch.verbs.done);
1091
+ printLeafHelp(COMMANDS.dispatch.verbs.done);
757
1092
  return 1;
758
1093
  }
759
1094
  try {
@@ -773,10 +1108,7 @@ function dispatchQuery(ids, all = false) {
773
1108
  async function runDispatchWaitCli(args) {
774
1109
  const timeoutRaw = getArgValue(args, 'timeout');
775
1110
  const timeoutSec = parseDuration(timeoutRaw, 600);
776
- if (timeoutSec === null) {
777
- console.error(`Invalid --timeout value: ${timeoutRaw}`);
778
- return 1;
779
- }
1111
+ if (timeoutSec === null) return usageError(COMMANDS.dispatch.verbs.wait, `Invalid --timeout value: ${timeoutRaw}`);
780
1112
  const q = dispatchQuery(positionals(args, ['--timeout']));
781
1113
  const deadline = Date.now() + timeoutSec * 1000;
782
1114
  try {
@@ -813,8 +1145,7 @@ async function runSkillsGetCli(args) {
813
1145
  const file = name && /^[a-z][a-z-]*$/.test(name) ? path.join(__dirname, 'skill-guides', `${name}.md`) : null;
814
1146
  if (!file || !fs.existsSync(file)) {
815
1147
  const known = fs.readdirSync(path.join(__dirname, 'skill-guides')).map(f => f.replace(/\.md$/, ''));
816
- console.error(`Unknown skill guide: ${name || '(none)'}. Known: ${known.join(', ')}`);
817
- return 1;
1148
+ return usageError(COMMANDS.skills.verbs.get, `Unknown skill guide: ${name || '(none)'}. Known: ${known.join(', ')}`);
818
1149
  }
819
1150
  process.stdout.write(fs.readFileSync(file, 'utf8'));
820
1151
  return 0;