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