claude-code-kanban 7.21.0 → 7.22.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 +334 -19
- package/hub-app.json +6 -0
- package/lib/addon-runner.js +177 -0
- package/lib/addons.js +238 -0
- package/lib/panes.js +14 -1
- package/lib/retention.js +2 -1
- package/lib/terminal.js +5 -3
- package/lib/write-json-atomic.js +16 -0
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/skills/addon/SKILL.md +18 -0
- package/plugin/plugins/claude-code-kanban/skills/addon/references/build.md +77 -0
- package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +1 -1
- package/plugin/plugins/claude-code-kanban/skills/dispatch/references/orchestration-patterns.md +2 -2
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +1 -0
- package/public/app.js +833 -127
- package/public/index.html +6 -1
- package/public/style.css +215 -3
- package/server.js +159 -24
- package/skill-guides/dispatch.md +5 -2
package/cli.js
CHANGED
|
@@ -3,6 +3,9 @@ const path = require('node:path');
|
|
|
3
3
|
const { getClaudeDir, displayPath } = require('./lib/claude-dir');
|
|
4
4
|
const { linkUrl } = require('./public/link-url');
|
|
5
5
|
const { sessionProjectKey } = require('./public/project-match');
|
|
6
|
+
const { MODELS } = require('./lib/terminal');
|
|
7
|
+
|
|
8
|
+
const MODEL_HELP = [...MODELS].join(', ').replace(/, (\w+)$/, ' or $1');
|
|
6
9
|
// Help is auto-generated from this table — keep flags/usage in sync with `run` behavior.
|
|
7
10
|
const SESSION_FLAG = '--session <id>';
|
|
8
11
|
const SESSION_FLAG_HELP = 'Session, full id or unique prefix (default: $PREVIEW_SESSION, else $CLAUDE_CODE_SESSION_ID)';
|
|
@@ -98,6 +101,68 @@ const COMMANDS = {
|
|
|
98
101
|
},
|
|
99
102
|
},
|
|
100
103
|
},
|
|
104
|
+
addon: {
|
|
105
|
+
summary: 'Put web pages on the dock rail beside the embedded terminal, for this session or every session',
|
|
106
|
+
verbs: {
|
|
107
|
+
add: {
|
|
108
|
+
summary: 'Add a URL to the dock rail; the user opens it from the rail',
|
|
109
|
+
usage: 'claude-code-kanban addon add <url> | --manifest <dir> [--title <text>] [--icon <glyph>] [--param <k=v> ...] [--global [--run <cmd> [--cwd <dir>]] [--offline]] [--session <id>] [--json]',
|
|
110
|
+
flags: {
|
|
111
|
+
'<url>': 'An http(s) URL. {sessionId}, {cwd} and {project} are filled for each session, {<name>} from --param',
|
|
112
|
+
'--manifest <dir>': 'Add the add-on in <dir> from its addon.json (url, title, icon, run), as a global entry that runs in <dir>. Its id names the entry, so a repeat add updates it',
|
|
113
|
+
'--offline': 'With --global: write the board\'s addons.json directly, with no board running and no terminal token. A running board picks the change up',
|
|
114
|
+
'--title <text>': 'Rail and header title (default: the host)',
|
|
115
|
+
'--icon <glyph>': 'One or two characters on the rail button (default: the first letter of the title)',
|
|
116
|
+
'--param <k=v>': 'A value for {k} in the URL; repeat for each. `addon set` changes it for one session',
|
|
117
|
+
'--global': 'Show it on the rail of every session. Use only when the user asks for every session',
|
|
118
|
+
'--run <cmd>': 'With --global: the command that starts the page\'s server. The board runs it when the rail opens the page and its localhost port does not answer, with PORT set',
|
|
119
|
+
'--cwd <dir>': 'Absolute folder to run --run in (default: the board\'s folder)',
|
|
120
|
+
[SESSION_FLAG]: SESSION_FLAG_HELP,
|
|
121
|
+
'--json': 'Output JSON (the add-on entry)',
|
|
122
|
+
},
|
|
123
|
+
notes: 'Quote the URL so the shell keeps the braces. Each value is URL-encoded when filled. A placeholder with no value is refused. A session add-on goes when the session expires; a global one stays until `addon rm`. The same URL added again prints the entry it already has; with --run it takes the new command. --run needs the board\'s terminal, like `dispatch start`, unless --offline. The server stops with the board; its output goes to .cck/addons/<id>.log.',
|
|
124
|
+
examples: [
|
|
125
|
+
'claude-code-kanban addon add --manifest ~/.claude-hub/extensions/git-changes/examples/git-changes --offline',
|
|
126
|
+
'claude-code-kanban addon add "http://localhost:4100/?cwd={cwd}" --title Changes',
|
|
127
|
+
'claude-code-kanban addon add "http://localhost:4200/{sessionId}" --title Notes --global',
|
|
128
|
+
'claude-code-kanban addon add "http://localhost:4117/?cwd={cwd}" --title Changes --global --run "node server.js" --cwd C:/tools/changes',
|
|
129
|
+
],
|
|
130
|
+
run: runAddonAddCli,
|
|
131
|
+
},
|
|
132
|
+
list: {
|
|
133
|
+
summary: 'List the add-ons on a session\'s rail: the global ones, then the session\'s',
|
|
134
|
+
usage: 'claude-code-kanban addon list [--session <id>] [--json]',
|
|
135
|
+
flags: {
|
|
136
|
+
[SESSION_FLAG]: SESSION_FLAG_HELP,
|
|
137
|
+
'--json': 'Output JSON ({global, session, params: {<id>: {k: v}}})',
|
|
138
|
+
},
|
|
139
|
+
run: runAddonListCli,
|
|
140
|
+
},
|
|
141
|
+
set: {
|
|
142
|
+
summary: 'Set a param of an add-on for one session; an empty value goes back to the add-on\'s own',
|
|
143
|
+
usage: 'claude-code-kanban addon set <id> <k=v> ... [--session <id>]',
|
|
144
|
+
flags: {
|
|
145
|
+
'<id>': 'The id `addon add` or `addon list` printed',
|
|
146
|
+
'<k=v>': 'The new value of {k}; k= with no value drops this session\'s value',
|
|
147
|
+
[SESSION_FLAG]: SESSION_FLAG_HELP,
|
|
148
|
+
},
|
|
149
|
+
examples: ['claude-code-kanban addon set a3 repo=claude-code-hub'],
|
|
150
|
+
run: runAddonSetCli,
|
|
151
|
+
},
|
|
152
|
+
rm: {
|
|
153
|
+
summary: 'Take an add-on off the rail: a global one from every session',
|
|
154
|
+
usage: 'claude-code-kanban addon rm <id> | --key <key> [--offline] [--session <id>]',
|
|
155
|
+
flags: {
|
|
156
|
+
'<id>': 'The id `addon add` or `addon list` printed',
|
|
157
|
+
'--key <key>': 'The global add-on with this key, the id in its addon.json. No such add-on is not an error',
|
|
158
|
+
'--offline': 'Remove a global add-on from the board\'s addons.json directly, with no board running',
|
|
159
|
+
[SESSION_FLAG]: SESSION_FLAG_HELP,
|
|
160
|
+
},
|
|
161
|
+
examples: ['claude-code-kanban addon rm a3', 'claude-code-kanban addon rm --key git-changes --offline'],
|
|
162
|
+
run: runAddonRmCli,
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
},
|
|
101
166
|
session: {
|
|
102
167
|
summary: 'List, search, open, inspect and group Claude Code sessions',
|
|
103
168
|
verbs: {
|
|
@@ -333,23 +398,24 @@ const COMMANDS = {
|
|
|
333
398
|
verbs: {
|
|
334
399
|
start: {
|
|
335
400
|
summary: 'Start claude in cck\'s terminal with a task; prints the session id',
|
|
336
|
-
usage: 'claude-code-kanban dispatch start --cwd <dir> (--spec <text> | --spec-file <path>)
|
|
401
|
+
usage: 'claude-code-kanban dispatch start --cwd <dir> (--spec <text> | --spec-file <path>) (--child | --group <g>) [--name <n>] [--model <m>] [--worktree [name]] [--task-list [id]] [--json] [-- <claude args>...]',
|
|
337
402
|
flags: {
|
|
338
403
|
'--cwd <dir>': 'Folder to run in (any existing folder, default: current dir)',
|
|
339
404
|
'--spec <text>': 'The task, self-contained; sent as the first message',
|
|
340
405
|
'--spec-file <path>': 'Read the task from a file',
|
|
341
406
|
'--name <n>': 'Session name; also its peer name for SendMessage',
|
|
342
|
-
'--
|
|
343
|
-
'--
|
|
407
|
+
'--child': 'Part of your work: its card shows under yours, wherever yours is. Needs a Claude Code session',
|
|
408
|
+
'--group <g>': 'Separate work: add it to this group, a name, path or id. The groups it makes are transient (see `help group list`)',
|
|
409
|
+
'--model <m>': MODEL_HELP,
|
|
344
410
|
'--worktree [name]': 'Run in a new git worktree',
|
|
345
411
|
'--task-list [id]': 'Use this task list instead of its own (default: this session\'s list), so its cards show on one board with yours. Off unless given',
|
|
346
412
|
'--json': 'Output JSON',
|
|
347
413
|
'-- <claude args>': 'Passed to claude as they are, e.g. --permission-mode auto. No quotes, % or control characters; cck sets --session-id, --name, --model and --worktree',
|
|
348
414
|
},
|
|
349
|
-
notes: 'Needs the terminal token, so it runs on the machine of the cck server. cck sends no report: say in the spec how the session reports back. Run `claude-code-kanban skills get dispatch` for how to write the spec.',
|
|
415
|
+
notes: 'Pass one of --child or --group. Needs the terminal token, so it runs on the machine of the cck server. cck sends no report: say in the spec how the session reports back. Run `claude-code-kanban skills get dispatch` for how to write the spec.',
|
|
350
416
|
examples: [
|
|
351
|
-
'claude-code-kanban dispatch start --cwd . --spec-file spec.md --name fix-login-redirect --
|
|
352
|
-
'claude-code-kanban dispatch start --cwd . --spec-file spec.md --name api-worker --task-list',
|
|
417
|
+
'claude-code-kanban dispatch start --cwd . --spec-file spec.md --name fix-login-redirect --child --model sonnet -- --permission-mode auto',
|
|
418
|
+
'claude-code-kanban dispatch start --cwd . --spec-file spec.md --name api-worker --group auth-refactor --task-list',
|
|
353
419
|
],
|
|
354
420
|
run: runDispatchStartCli,
|
|
355
421
|
},
|
|
@@ -376,6 +442,24 @@ const COMMANDS = {
|
|
|
376
442
|
},
|
|
377
443
|
},
|
|
378
444
|
},
|
|
445
|
+
new: {
|
|
446
|
+
summary: 'Start claude in the board\'s terminal from any shell, and show it there',
|
|
447
|
+
usage: 'claude-code-kanban new [prompt...] [--cwd <dir>] [--name <n>] [--model <m>] [--json] [-- <claude args>...]',
|
|
448
|
+
flags: {
|
|
449
|
+
'[prompt...]': 'First message; the words are joined with spaces. None starts an empty session',
|
|
450
|
+
'--cwd <dir>': 'Folder to run in (default: current dir)',
|
|
451
|
+
'--name <n>': 'Session name',
|
|
452
|
+
'--model <m>': MODEL_HELP,
|
|
453
|
+
'--json': 'Output JSON: {session, cwd}',
|
|
454
|
+
'-- <claude args>': 'Passed to claude as they are, e.g. --permission-mode auto',
|
|
455
|
+
},
|
|
456
|
+
notes: 'For the user at a shell, not for agents: agents use `dispatch start`. A board tab with the terminal on must be open, else the session is ended; the tab shows it, and under the hub the hub switches to Kanban. Needs the terminal token, so it runs on the machine of the cck server.',
|
|
457
|
+
examples: [
|
|
458
|
+
'claude-code-kanban new',
|
|
459
|
+
'claude-code-kanban new fix the login redirect -- --permission-mode auto',
|
|
460
|
+
],
|
|
461
|
+
run: runNewCli,
|
|
462
|
+
},
|
|
379
463
|
terminal: {
|
|
380
464
|
summary: 'Read the terminals running in cck',
|
|
381
465
|
verbs: {
|
|
@@ -408,8 +492,16 @@ for (const [noun, cmd] of Object.entries(COMMANDS)) {
|
|
|
408
492
|
cmd.name = noun;
|
|
409
493
|
for (const [verb, v] of Object.entries(cmd.verbs || {})) v.name = `${noun} ${verb}`;
|
|
410
494
|
}
|
|
495
|
+
// Words after `--` belong to claude, so cck's own --help and --version are looked for before it.
|
|
496
|
+
function ownArgs(argv) {
|
|
497
|
+
const sep = argv.indexOf('--');
|
|
498
|
+
return sep === -1 ? argv : argv.slice(0, sep);
|
|
499
|
+
}
|
|
500
|
+
const hasOwnFlag = (a, ...flags) => ownArgs(a).some((x) => flags.includes(x));
|
|
501
|
+
const hasHelp = (a) => hasOwnFlag(a, '--help', '-h');
|
|
502
|
+
|
|
411
503
|
function runCli(argv) {
|
|
412
|
-
if (argv
|
|
504
|
+
if (hasOwnFlag(argv, '--version', '-v')) {
|
|
413
505
|
console.log(require('./package.json').version);
|
|
414
506
|
process.exit(0);
|
|
415
507
|
}
|
|
@@ -435,10 +527,10 @@ function runCli(argv) {
|
|
|
435
527
|
}
|
|
436
528
|
if (cli.kind === 'noun') {
|
|
437
529
|
printNounHelp(cli.noun);
|
|
438
|
-
process.exit(
|
|
530
|
+
process.exit(hasHelp(argv) ? 0 : 1);
|
|
439
531
|
}
|
|
440
532
|
if (cli.kind === 'leaf') {
|
|
441
|
-
if (
|
|
533
|
+
if (hasHelp(cli.args)) {
|
|
442
534
|
printLeafHelp(cli.entry);
|
|
443
535
|
process.exit(0);
|
|
444
536
|
}
|
|
@@ -452,7 +544,6 @@ function runCli(argv) {
|
|
|
452
544
|
|
|
453
545
|
function resolveCliCommand(argv) {
|
|
454
546
|
const noun = argv[2] && !argv[2].startsWith('-') ? argv[2] : null;
|
|
455
|
-
const hasHelp = (a) => a.includes('--help') || a.includes('-h');
|
|
456
547
|
if (!noun) return hasHelp(argv) ? { kind: 'help' } : { kind: 'server' };
|
|
457
548
|
if (noun === 'help') return { kind: 'help', target: argv[3], verb: argv[4] };
|
|
458
549
|
if (!Object.hasOwn(COMMANDS, noun)) return { kind: 'unknown-noun', noun };
|
|
@@ -506,8 +597,8 @@ function printTopHelp() {
|
|
|
506
597
|
|
|
507
598
|
function printNounHelp(noun) {
|
|
508
599
|
const entry = COMMANDS[noun];
|
|
509
|
-
console.log(`${entry.summary}\n`);
|
|
510
600
|
if (entry.verbs) {
|
|
601
|
+
console.log(`${entry.summary}\n`);
|
|
511
602
|
console.log(`Usage: claude-code-kanban ${noun} <subcommand> [args] [--flags]\n`);
|
|
512
603
|
console.log('Subcommands:');
|
|
513
604
|
for (const [vName, v] of Object.entries(entry.verbs)) {
|
|
@@ -678,9 +769,11 @@ async function postDocLink(target, sessionId, { unlink = false, open = false } =
|
|
|
678
769
|
} catch (e) { reportCliError(e); return 1; }
|
|
679
770
|
}
|
|
680
771
|
|
|
772
|
+
const sessionArgOf = (args) => getArgValue(args, 'session') || process.env.PREVIEW_SESSION || process.env.CLAUDE_CODE_SESSION_ID;
|
|
773
|
+
|
|
681
774
|
// Resolved to the full id because the browser keys linked docs by it, so a prefix won't match.
|
|
682
775
|
async function resolveSessionArg(entry, args, why) {
|
|
683
|
-
const sessionArg =
|
|
776
|
+
const sessionArg = sessionArgOf(args);
|
|
684
777
|
if (!sessionArg) {
|
|
685
778
|
usageError(entry, `--session is required: ${why}`);
|
|
686
779
|
return null;
|
|
@@ -763,7 +856,181 @@ async function runPaneListCli(args, entry) {
|
|
|
763
856
|
const layout = await cliGetJson(panesPath(resolved.id), 'Pane list');
|
|
764
857
|
if (args.includes('--json')) console.log(JSON.stringify(layout, null, 2));
|
|
765
858
|
else if (!layout.panes.length) console.log(`No panes for session ${resolved.id.slice(0, 8)}.`);
|
|
766
|
-
else printTable(['ID', 'KIND', 'TITLE', 'TARGET'], layout.panes.map((p) => [p.id, p.kind, p.title, p.target]));
|
|
859
|
+
else printTable(['ID', 'KIND', 'TITLE', 'TARGET'], layout.panes.map((p) => [p.id, p.dock ? `${p.kind} (dock)` : p.kind, p.title, p.target]));
|
|
860
|
+
return 0;
|
|
861
|
+
} catch (e) { reportCliError(e); return 1; }
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const ADDONS_WHY = 'a session add-on is stored with its session.';
|
|
865
|
+
|
|
866
|
+
// `k=v` words; a repeated `--param` takes one each. Returns null after printing the error.
|
|
867
|
+
function addonParams(entry, words) {
|
|
868
|
+
const params = {};
|
|
869
|
+
for (const w of words) {
|
|
870
|
+
const i = w.indexOf('=');
|
|
871
|
+
if (i < 1) {
|
|
872
|
+
usageError(entry, `Expected k=v, got: ${w}`);
|
|
873
|
+
return null;
|
|
874
|
+
}
|
|
875
|
+
params[w.slice(0, i)] = w.slice(i + 1);
|
|
876
|
+
}
|
|
877
|
+
return params;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const argValues = (args, name) =>
|
|
881
|
+
args.flatMap((a, i) => {
|
|
882
|
+
if (a.startsWith(`--${name}=`)) return [a.slice(name.length + 3)];
|
|
883
|
+
return a === `--${name}` && args[i + 1] && !args[i + 1].startsWith('--') ? [args[i + 1]] : [];
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
// A global add-on needs no session; take one only when one is named or set.
|
|
887
|
+
async function optionalSession(entry, args) {
|
|
888
|
+
return sessionArgOf(args) ? resolveSessionArg(entry, args, ADDONS_WHY) : { id: null };
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const sessionQuery = (resolved) => (resolved.id ? `?session=${encodeURIComponent(resolved.id)}` : '');
|
|
892
|
+
|
|
893
|
+
// The add-on's own manifest (addon.json); the store checks the fields it keeps.
|
|
894
|
+
function readAddonManifest(entry, dir) {
|
|
895
|
+
const file = path.join(dir, 'addon.json');
|
|
896
|
+
let m;
|
|
897
|
+
try {
|
|
898
|
+
m = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
899
|
+
} catch (e) {
|
|
900
|
+
usageError(entry, `Cannot read ${file}: ${e.code || e.message}`);
|
|
901
|
+
return null;
|
|
902
|
+
}
|
|
903
|
+
if (m?.kind !== 'addon' || typeof m.id !== 'string' || typeof m.url !== 'string') {
|
|
904
|
+
usageError(entry, `${file} is not an add-on manifest: it needs "kind": "addon", an id and a url`);
|
|
905
|
+
return null;
|
|
906
|
+
}
|
|
907
|
+
return m;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// With no board: the store writes addons.json itself, and a running board reads the change.
|
|
911
|
+
function offlineAddons() {
|
|
912
|
+
const { createAddonStore } = require('./lib/addons');
|
|
913
|
+
const { stampedJsonFile } = require('./lib/stamped-json-file');
|
|
914
|
+
const { writeJsonAtomic } = require('./lib/write-json-atomic');
|
|
915
|
+
return createAddonStore(stampedJsonFile(path.join(getClaudeDir(), '.cck', 'addons.json'), writeJsonAtomic));
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
async function runAddonAddCli(args, entry) {
|
|
919
|
+
const [urlArg] = positionals(args, ['--session', '--title', '--icon', '--param', '--run', '--cwd', '--manifest']);
|
|
920
|
+
const manifestDir = getArgValue(args, 'manifest');
|
|
921
|
+
if (!urlArg && !manifestDir) {
|
|
922
|
+
printLeafHelp(entry);
|
|
923
|
+
return 1;
|
|
924
|
+
}
|
|
925
|
+
if (urlArg && manifestDir) return usageError(entry, '--manifest takes the URL from addon.json');
|
|
926
|
+
let spec;
|
|
927
|
+
if (manifestDir) {
|
|
928
|
+
if (getArgValue(args, 'run') || getArgValue(args, 'cwd')) return usageError(entry, '--manifest takes run from addon.json and runs it in that folder');
|
|
929
|
+
const dir = path.resolve(manifestDir);
|
|
930
|
+
const m = readAddonManifest(entry, dir);
|
|
931
|
+
if (!m) return 1;
|
|
932
|
+
spec = { url: m.url, run: m.run, cwd: m.run && dir, title: m.title, icon: m.icon, key: m.id, isGlobal: true };
|
|
933
|
+
} else {
|
|
934
|
+
const cwd = getArgValue(args, 'cwd');
|
|
935
|
+
spec = { url: urlArg, run: getArgValue(args, 'run'), cwd: cwd && path.resolve(cwd), isGlobal: args.includes('--global') };
|
|
936
|
+
}
|
|
937
|
+
const { url, run, cwd, key, isGlobal } = spec;
|
|
938
|
+
const params = addonParams(entry, argValues(args, 'param'));
|
|
939
|
+
if (!params) return 1;
|
|
940
|
+
const offline = args.includes('--offline');
|
|
941
|
+
if (offline && !isGlobal) return usageError(entry, '--offline needs --global');
|
|
942
|
+
if (run && !isGlobal) return usageError(entry, '--run needs --global');
|
|
943
|
+
if (cwd && !run) return usageError(entry, '--cwd needs --run');
|
|
944
|
+
const needsToken = run && !offline;
|
|
945
|
+
const token = needsToken ? readTerminalToken() : null;
|
|
946
|
+
if (needsToken && !token) return 1;
|
|
947
|
+
const resolved = isGlobal ? { id: 'global' } : await resolveSessionArg(entry, args, ADDONS_WHY);
|
|
948
|
+
if (!resolved) return 1;
|
|
949
|
+
try {
|
|
950
|
+
const icon = getArgValue(args, 'icon') || spec.icon;
|
|
951
|
+
const title = getArgValue(args, 'title') || spec.title || '';
|
|
952
|
+
const fields = { ...(key && { key }), url, title, ...(icon && { icon }), params, ...(run && { run, ...(cwd && { cwd }) }) };
|
|
953
|
+
const out = offline
|
|
954
|
+
? offlineAddons().add(resolved.id, fields)
|
|
955
|
+
: await cliPostJson('/api/addons', { scope: resolved.id, ...fields }, 'Add-on add', token ? { 'x-terminal-token': token } : undefined);
|
|
956
|
+
if (!out) return 1;
|
|
957
|
+
if (args.includes('--json')) console.log(JSON.stringify(out.addon, null, 2));
|
|
958
|
+
else console.log(`${out.addon.id} ${out.addon.title} (${isGlobal ? 'every session' : 'this session'})${out.added ? '' : ' (already there)'}`);
|
|
959
|
+
return 0;
|
|
960
|
+
} catch (e) { reportCliError(e); return 1; }
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
async function runAddonListCli(args, entry) {
|
|
964
|
+
const resolved = await optionalSession(entry, args);
|
|
965
|
+
if (!resolved) return 1;
|
|
966
|
+
try {
|
|
967
|
+
const list = await cliGetJson(`/api/addons${sessionQuery(resolved)}`, 'Add-on list');
|
|
968
|
+
if (args.includes('--json')) {
|
|
969
|
+
console.log(JSON.stringify(list, null, 2));
|
|
970
|
+
return 0;
|
|
971
|
+
}
|
|
972
|
+
const rows = [...list.global.map((a) => ['global', a]), ...list.session.map((a) => ['session', a])].map(([scope, a]) => {
|
|
973
|
+
const params = Object.entries({ ...a.params, ...list.params[a.id] }).map(([k, v]) => `${k}=${v}`).join(' ');
|
|
974
|
+
return [a.id, scope, a.title, a.url, params];
|
|
975
|
+
});
|
|
976
|
+
if (!rows.length) console.log('No add-ons on the rail.');
|
|
977
|
+
else printTable(['ID', 'SCOPE', 'TITLE', 'URL', 'PARAMS'], rows);
|
|
978
|
+
return 0;
|
|
979
|
+
} catch (e) { reportCliError(e); return 1; }
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
async function runAddonSetCli(args, entry) {
|
|
983
|
+
const [id, ...words] = positionals(args, ['--session']);
|
|
984
|
+
if (!id || !words.length) {
|
|
985
|
+
printLeafHelp(entry);
|
|
986
|
+
return 1;
|
|
987
|
+
}
|
|
988
|
+
const params = addonParams(entry, words);
|
|
989
|
+
if (!params) return 1;
|
|
990
|
+
const resolved = await resolveSessionArg(entry, args, 'a param value is set per session.');
|
|
991
|
+
if (!resolved) return 1;
|
|
992
|
+
try {
|
|
993
|
+
if (!await cliSendJson('PATCH', `/api/addons/${encodeURIComponent(id)}`, { session: resolved.id, params }, 'Add-on set')) return 1;
|
|
994
|
+
console.log(`Set ${Object.keys(params).join(', ')} of ${id} for session ${resolved.id.slice(0, 8)}`);
|
|
995
|
+
return 0;
|
|
996
|
+
} catch (e) { reportCliError(e); return 1; }
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
async function runAddonRmCli(args, entry) {
|
|
1000
|
+
const [idArg] = positionals(args, ['--session', '--key']);
|
|
1001
|
+
const key = getArgValue(args, 'key');
|
|
1002
|
+
if (!idArg && !key) {
|
|
1003
|
+
printLeafHelp(entry);
|
|
1004
|
+
return 1;
|
|
1005
|
+
}
|
|
1006
|
+
if (idArg && key) return usageError(entry, 'Pass an id or --key, not both');
|
|
1007
|
+
const offline = args.includes('--offline');
|
|
1008
|
+
const resolved = offline ? {} : await optionalSession(entry, args);
|
|
1009
|
+
if (!resolved) return 1;
|
|
1010
|
+
try {
|
|
1011
|
+
const store = offline ? offlineAddons() : null;
|
|
1012
|
+
const global = key && (store ? store.list(null) : await cliGetJson('/api/addons', 'Add-on list')).global;
|
|
1013
|
+
const id = idArg ?? global.find((a) => a.key === key)?.id;
|
|
1014
|
+
if (!id) {
|
|
1015
|
+
console.log(`No add-on has the key ${key}`);
|
|
1016
|
+
return 0;
|
|
1017
|
+
}
|
|
1018
|
+
if (store) {
|
|
1019
|
+
if (!store.remove(null, id)) {
|
|
1020
|
+
console.error(`No global add-on ${id}`);
|
|
1021
|
+
return 1;
|
|
1022
|
+
}
|
|
1023
|
+
console.log(`Removed add-on ${id} from every session`);
|
|
1024
|
+
return 0;
|
|
1025
|
+
}
|
|
1026
|
+
const out = await cliSendJson(
|
|
1027
|
+
'DELETE',
|
|
1028
|
+
`/api/addons/${encodeURIComponent(id)}${sessionQuery(resolved)}`,
|
|
1029
|
+
undefined,
|
|
1030
|
+
'Add-on rm',
|
|
1031
|
+
);
|
|
1032
|
+
if (!out) return 1;
|
|
1033
|
+
console.log(`Removed add-on ${id}${out.scope === 'global' ? ' from every session' : ''}`);
|
|
767
1034
|
return 0;
|
|
768
1035
|
} catch (e) { reportCliError(e); return 1; }
|
|
769
1036
|
}
|
|
@@ -1426,10 +1693,28 @@ function readTerminalToken() {
|
|
|
1426
1693
|
return token;
|
|
1427
1694
|
}
|
|
1428
1695
|
|
|
1696
|
+
function splitClaudeArgs(argv) {
|
|
1697
|
+
const args = ownArgs(argv);
|
|
1698
|
+
return { args, claudeArgs: argv.slice(args.length + 1) };
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
// A help key is `--name`, `--name <value>` or `--name [value]`; anything else is a positional
|
|
1702
|
+
// or the `--` separator. test/cli-flags.test.js checks every key in COMMANDS against this.
|
|
1703
|
+
const FLAG_KEY = /^(--[a-z][\w-]*)(?: (<[^>]+>|\[[^\]]+\]))?$/;
|
|
1704
|
+
function flagSpec(entry) {
|
|
1705
|
+
const names = [];
|
|
1706
|
+
const valueFlags = [];
|
|
1707
|
+
for (const key of Object.keys(entry.flags || {})) {
|
|
1708
|
+
const m = key.match(FLAG_KEY);
|
|
1709
|
+
if (!m) continue;
|
|
1710
|
+
names.push(m[1]);
|
|
1711
|
+
if (m[2]?.startsWith('<')) valueFlags.push(m[1]);
|
|
1712
|
+
}
|
|
1713
|
+
return { names, valueFlags };
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1429
1716
|
async function runDispatchStartCli(argv) {
|
|
1430
|
-
const
|
|
1431
|
-
const args = sep === -1 ? argv : argv.slice(0, sep);
|
|
1432
|
-
const claudeArgs = sep === -1 ? [] : argv.slice(sep + 1);
|
|
1717
|
+
const { args, claudeArgs } = splitClaudeArgs(argv);
|
|
1433
1718
|
const token = readTerminalToken();
|
|
1434
1719
|
if (!token) return 1;
|
|
1435
1720
|
let spec;
|
|
@@ -1441,6 +1726,10 @@ async function runDispatchStartCli(argv) {
|
|
|
1441
1726
|
const hasGroup = args.some(a => a === '--group' || a.startsWith('--group='));
|
|
1442
1727
|
const group = hasGroup ? getArgValue(args, 'group') || '' : null;
|
|
1443
1728
|
if (hasGroup && !group.trim()) return usageError(COMMANDS.dispatch.verbs.start, '--group needs a group name, path or id');
|
|
1729
|
+
const child = args.includes('--child');
|
|
1730
|
+
if (child === hasGroup) return usageError(COMMANDS.dispatch.verbs.start, 'Pass one of --child (part of your work) or --group <g> (separate work)');
|
|
1731
|
+
const parent = process.env.CLAUDE_CODE_SESSION_ID || null;
|
|
1732
|
+
if (child && !parent) return usageError(COMMANDS.dispatch.verbs.start, '--child runs only inside a Claude Code session; pass --group <g>');
|
|
1444
1733
|
const worktree = args.includes('--worktree') ? getArgValue(args, 'worktree') || true : false;
|
|
1445
1734
|
const hasTaskList = args.some(a => a === '--task-list' || a.startsWith('--task-list='));
|
|
1446
1735
|
const ownList = process.env.CLAUDE_CODE_TASK_LIST_ID || process.env.CLAUDE_CODE_SESSION_ID;
|
|
@@ -1456,19 +1745,45 @@ async function runDispatchStartCli(argv) {
|
|
|
1456
1745
|
worktree,
|
|
1457
1746
|
taskList,
|
|
1458
1747
|
group,
|
|
1748
|
+
child,
|
|
1459
1749
|
claudeArgs,
|
|
1460
|
-
parent
|
|
1750
|
+
parent,
|
|
1461
1751
|
};
|
|
1462
1752
|
try {
|
|
1463
1753
|
const out = await cliPostJson('/api/dispatch', body, 'Dispatch', { 'x-terminal-token': token });
|
|
1464
1754
|
if (!out) return 1;
|
|
1465
1755
|
if (args.includes('--json')) console.log(JSON.stringify(out, null, 2));
|
|
1466
|
-
else console.log(`Started session ${out.session} in ${out.cwd}${out.group ? ` [${out.group}${out.transient ? ', transient' : ''}]` : ''}`);
|
|
1756
|
+
else console.log(`Started session ${out.session} in ${out.cwd}${child ? ' [child]' : ''}${out.group ? ` [${out.group}${out.transient ? ', transient' : ''}]` : ''}`);
|
|
1467
1757
|
if (out.groupError) console.error(`Not grouped: ${out.groupError}`);
|
|
1468
1758
|
return 0;
|
|
1469
1759
|
} catch (e) { reportCliError(e); return 1; }
|
|
1470
1760
|
}
|
|
1471
1761
|
|
|
1762
|
+
async function runNewCli(argv) {
|
|
1763
|
+
const { args, claudeArgs } = splitClaudeArgs(argv);
|
|
1764
|
+
const { names, valueFlags } = flagSpec(COMMANDS.new);
|
|
1765
|
+
const unknown = args.find(a => a.startsWith('--') && !names.includes(a.split('=')[0]));
|
|
1766
|
+
if (unknown) return usageError(COMMANDS.new, `Unknown flag ${unknown}; put claude's own flags after --`);
|
|
1767
|
+
const words = positionals(args, valueFlags);
|
|
1768
|
+
const token = readTerminalToken();
|
|
1769
|
+
if (!token) return 1;
|
|
1770
|
+
const body = {
|
|
1771
|
+
cwd: canonicalDir(getArgValue(args, 'cwd') || '.'),
|
|
1772
|
+
prompt: words.join(' '),
|
|
1773
|
+
name: getArgValue(args, 'name'),
|
|
1774
|
+
model: getArgValue(args, 'model'),
|
|
1775
|
+
claudeArgs,
|
|
1776
|
+
show: true,
|
|
1777
|
+
};
|
|
1778
|
+
try {
|
|
1779
|
+
const out = await cliPostJson('/api/terminal/start', body, 'Start', { 'x-terminal-token': token });
|
|
1780
|
+
if (!out) return 1;
|
|
1781
|
+
if (args.includes('--json')) console.log(JSON.stringify(out, null, 2));
|
|
1782
|
+
else console.log(`Started session ${out.session} in ${out.cwd}; it shows on the board.`);
|
|
1783
|
+
return 0;
|
|
1784
|
+
} catch (e) { reportCliError(e); return 1; }
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1472
1787
|
async function fetchDispatches(all) {
|
|
1473
1788
|
const q = new URLSearchParams();
|
|
1474
1789
|
if (!all && process.env.CLAUDE_CODE_SESSION_ID) q.set('parent', process.env.CLAUDE_CODE_SESSION_ID);
|
|
@@ -1555,4 +1870,4 @@ async function runSkillsGetCli(args) {
|
|
|
1555
1870
|
return 0;
|
|
1556
1871
|
}
|
|
1557
1872
|
|
|
1558
|
-
module.exports = { runCli, topHelp, COMMANDS, SERVER_FLAGS, AGENT_ID_RE };
|
|
1873
|
+
module.exports = { runCli, topHelp, COMMANDS, SERVER_FLAGS, AGENT_ID_RE, FLAG_KEY, flagSpec, splitClaudeArgs };
|
package/hub-app.json
CHANGED
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
"icon": "columns",
|
|
6
6
|
"run": { "entry": "server.js", "defaultPort": 3541 },
|
|
7
7
|
"publishes": ["session.changed"],
|
|
8
|
+
"actions": {
|
|
9
|
+
"handles": {
|
|
10
|
+
"session.open": { "params": { "session": "string" }, "mode": "message" }
|
|
11
|
+
}
|
|
12
|
+
},
|
|
8
13
|
"settings": [
|
|
9
14
|
{
|
|
10
15
|
"key": "zenCentered",
|
|
@@ -38,6 +43,7 @@
|
|
|
38
43
|
"projects": { "path": "/api/projects" },
|
|
39
44
|
"terminal": { "liveWork": "/api/terminals" }
|
|
40
45
|
},
|
|
46
|
+
"launcher": { "items": "/api/hub/launcher-items" },
|
|
41
47
|
"loading": {
|
|
42
48
|
"verbs": [
|
|
43
49
|
"Shuffling cards…",
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Starts a global add-on's own server (`run` in its entry) the first time the board opens it and
|
|
2
|
+
// the URL's port does not answer. The process is cck's child, so it ends with the board; under
|
|
3
|
+
// the hub, `taskkill /f /t` on cck takes it too. A second board on the config dir finds the
|
|
4
|
+
// port answering and starts nothing.
|
|
5
|
+
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const net = require('node:net');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
const { spawn, spawnSync } = require('node:child_process');
|
|
10
|
+
const { sampleUrl } = require('./addons');
|
|
11
|
+
const { portOf } = require('./panes');
|
|
12
|
+
|
|
13
|
+
const MAX_STARTS = 3;
|
|
14
|
+
const STARTS_WINDOW_MS = 60_000;
|
|
15
|
+
const READY_MS = 20_000;
|
|
16
|
+
const POLL_MS = 300;
|
|
17
|
+
|
|
18
|
+
function urlPort(url) {
|
|
19
|
+
const u = new URL(url);
|
|
20
|
+
return { host: u.hostname.replace(/^\[|\]$/g, ''), port: portOf(u) };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const targetOf = (entry) => urlPort(sampleUrl(entry.url));
|
|
24
|
+
|
|
25
|
+
const exited = (child) => child.exitCode !== null || child.signalCode !== null;
|
|
26
|
+
|
|
27
|
+
function portAnswers({ host, port }, timeoutMs = 500) {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
const socket = net.connect({ host, port });
|
|
30
|
+
const done = (ok) => {
|
|
31
|
+
socket.destroy();
|
|
32
|
+
resolve(ok);
|
|
33
|
+
};
|
|
34
|
+
socket.setTimeout(timeoutMs, () => done(false));
|
|
35
|
+
socket.once('connect', () => done(true));
|
|
36
|
+
socket.once('error', () => done(false));
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Sync only on exit, where nothing async runs; else taskkill would hold the event loop for its run.
|
|
41
|
+
function killTree(child, sync = false) {
|
|
42
|
+
if (exited(child)) return;
|
|
43
|
+
if (process.platform !== 'win32') {
|
|
44
|
+
// The shell's own group: SIGTERM to the shell alone leaves the server it started running.
|
|
45
|
+
try {
|
|
46
|
+
process.kill(-child.pid, 'SIGTERM');
|
|
47
|
+
} catch {
|
|
48
|
+
child.kill('SIGTERM');
|
|
49
|
+
}
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const args = ['/f', '/t', '/pid', String(child.pid)];
|
|
53
|
+
const opts = { windowsHide: true, stdio: 'ignore' };
|
|
54
|
+
if (sync) spawnSync('taskkill', args, opts);
|
|
55
|
+
else spawn('taskkill', args, opts).once('error', () => {});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {object} o
|
|
60
|
+
* @param {string} o.logDir one `<id>.log` per add-on, rewritten on each start
|
|
61
|
+
* @param {(target: {host: string, port: number}) => Promise<boolean>} [o.probe]
|
|
62
|
+
*/
|
|
63
|
+
function createAddonRunner({ logDir, probe = portAnswers, now = Date.now }) {
|
|
64
|
+
const byId = new Map();
|
|
65
|
+
const logPath = (id) => path.join(logDir, `${id}.log`);
|
|
66
|
+
|
|
67
|
+
async function waitReady(target, child) {
|
|
68
|
+
const until = now() + READY_MS;
|
|
69
|
+
while (now() < until) {
|
|
70
|
+
if (exited(child)) return false;
|
|
71
|
+
if (await probe(target)) return true;
|
|
72
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function start(entry, target, state) {
|
|
78
|
+
const t = now();
|
|
79
|
+
state.starts = state.starts.filter((s) => t - s < STARTS_WINDOW_MS);
|
|
80
|
+
if (state.starts.length >= MAX_STARTS) {
|
|
81
|
+
return { state: 'failed', error: `Started ${MAX_STARTS} times in a minute; see the log`, log: logPath(entry.id) };
|
|
82
|
+
}
|
|
83
|
+
state.starts.push(t);
|
|
84
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
85
|
+
const fd = fs.openSync(logPath(entry.id), 'w');
|
|
86
|
+
let child;
|
|
87
|
+
try {
|
|
88
|
+
child = spawn(entry.run, {
|
|
89
|
+
shell: true,
|
|
90
|
+
detached: process.platform !== 'win32',
|
|
91
|
+
cwd: entry.cwd || undefined,
|
|
92
|
+
env: { ...process.env, PORT: String(target.port) },
|
|
93
|
+
stdio: ['ignore', fd, fd],
|
|
94
|
+
windowsHide: true,
|
|
95
|
+
});
|
|
96
|
+
} catch (e) {
|
|
97
|
+
return { state: 'failed', error: e.message, log: logPath(entry.id) };
|
|
98
|
+
} finally {
|
|
99
|
+
fs.closeSync(fd);
|
|
100
|
+
}
|
|
101
|
+
child.once('error', () => {});
|
|
102
|
+
state.child = child;
|
|
103
|
+
state.started = entry;
|
|
104
|
+
if (await waitReady(target, child)) return { state: 'up' };
|
|
105
|
+
const gone = exited(child);
|
|
106
|
+
killTree(child);
|
|
107
|
+
return { state: 'failed', error: gone ? `Exited with code ${child.exitCode}` : `No answer on port ${target.port} in ${READY_MS / 1000} s`, log: logPath(entry.id) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// One start at a time per add-on: a second open while it starts waits for the same answer.
|
|
111
|
+
async function ensure(entry) {
|
|
112
|
+
if (!entry.run) return { state: 'none' };
|
|
113
|
+
const target = targetOf(entry);
|
|
114
|
+
if (!byId.has(entry.id)) byId.set(entry.id, { starts: [], child: null, pending: null });
|
|
115
|
+
const state = byId.get(entry.id);
|
|
116
|
+
if (state.pending) return state.pending;
|
|
117
|
+
state.pending = probe(target)
|
|
118
|
+
.then((up) => (up ? { state: 'up' } : start(entry, target, state)))
|
|
119
|
+
.finally(() => {
|
|
120
|
+
state.pending = null;
|
|
121
|
+
});
|
|
122
|
+
return state.pending;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function ownChild(id) {
|
|
126
|
+
const child = byId.get(id)?.child;
|
|
127
|
+
return child && !exited(child) ? child : null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 'running' (this board's child, or `external`: another process answers the port) or 'stopped'.
|
|
131
|
+
async function status(entry) {
|
|
132
|
+
if (ownChild(entry.id)) return 'running';
|
|
133
|
+
return (await probe(targetOf(entry))) ? 'external' : 'stopped';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Kills this board's child and starts it again. A server this board did not start is left alone.
|
|
137
|
+
async function restart(entry) {
|
|
138
|
+
const child = ownChild(entry.id);
|
|
139
|
+
if (child) {
|
|
140
|
+
await new Promise((resolve) => {
|
|
141
|
+
child.once('exit', resolve);
|
|
142
|
+
killTree(child);
|
|
143
|
+
});
|
|
144
|
+
} else if ((await status(entry)) === 'external') {
|
|
145
|
+
return { state: 'external', error: 'Its server was not started by this board, so the board cannot restart it' };
|
|
146
|
+
}
|
|
147
|
+
const target = targetOf(entry);
|
|
148
|
+
const until = now() + READY_MS;
|
|
149
|
+
while (now() < until && (await probe(target))) await new Promise((r) => setTimeout(r, POLL_MS));
|
|
150
|
+
return ensure(entry);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function stop(id) {
|
|
154
|
+
const state = byId.get(id);
|
|
155
|
+
if (state?.child) killTree(state.child);
|
|
156
|
+
byId.delete(id);
|
|
157
|
+
fs.rmSync(logPath(id), { force: true });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// After another process changed addons.json: stops each server whose entry is gone or now starts
|
|
161
|
+
// another way, so a removed add-on lets go of its folder and the next open runs the new command.
|
|
162
|
+
function retain(entries) {
|
|
163
|
+
for (const [id, state] of byId) {
|
|
164
|
+
const e = entries.find((x) => x.id === id);
|
|
165
|
+
const was = state.started;
|
|
166
|
+
if (!e || (was && (e.run !== was.run || e.cwd !== was.cwd || e.url !== was.url))) stop(id);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function shutdown() {
|
|
171
|
+
for (const state of byId.values()) if (state.child) killTree(state.child, true);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { ensure, status, restart, stop, retain, shutdown };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = { createAddonRunner, urlPort };
|