talking-stick 0.1.0-alpha

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/dist/cli.js ADDED
@@ -0,0 +1,701 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { clearCliSessionLease, createSystemProcessInspector, deriveHarnessCliIdentity, deriveHumanCliIdentity, findCliSessionForContextPath, isProtocolError, resolveCliSessionPath, runStdioServer, TalkingStickCommands, TalkingStickService, terminateKnownProcess, upsertCliSession } from "./index.js";
7
+ import { SUPPORTED_HARNESSES, detectHarness, parseHarnessList, planInstall, planUninstall, runAction } from "./install.js";
8
+ import { planSkillInstall, planSkillUninstall } from "./skill-install.js";
9
+ import { resolveContextPath } from "./path-resolution.js";
10
+ const GUARD_READY = "READY";
11
+ const STALE_GUARD_ERRORS = new Set(["stale_lease", "turn_mismatch", "room_not_found"]);
12
+ export async function runCli(argv = process.argv.slice(2)) {
13
+ const parsed = parseCommand(argv);
14
+ if (!parsed.name || parsed.name === "help" || parsed.name === "--help") {
15
+ printHelp();
16
+ return;
17
+ }
18
+ if (parsed.name === "mcp") {
19
+ await runStdioServer();
20
+ return;
21
+ }
22
+ if (parsed.name === "guard") {
23
+ await runGuardCommand(parsed);
24
+ return;
25
+ }
26
+ if (parsed.name === "install") {
27
+ await runInstallCommand(parsed);
28
+ return;
29
+ }
30
+ if (parsed.name === "uninstall") {
31
+ await runUninstallCommand(parsed);
32
+ return;
33
+ }
34
+ if (parsed.name === "install-skill") {
35
+ await runInstallSkillCommand(parsed);
36
+ return;
37
+ }
38
+ if (parsed.name === "uninstall-skill") {
39
+ await runUninstallSkillCommand(parsed);
40
+ return;
41
+ }
42
+ const runtime = createRuntime();
43
+ try {
44
+ switch (parsed.name) {
45
+ case "list":
46
+ handleListCommand(runtime, parsed);
47
+ return;
48
+ case "join":
49
+ handleJoinCommand(runtime, parsed);
50
+ return;
51
+ case "state":
52
+ handleStateCommand(runtime, parsed);
53
+ return;
54
+ case "events":
55
+ handleEventsCommand(runtime, parsed);
56
+ return;
57
+ case "wait":
58
+ await handleWaitCommand(runtime, parsed, false);
59
+ return;
60
+ case "try":
61
+ await handleWaitCommand(runtime, parsed, true);
62
+ return;
63
+ case "takeover":
64
+ await handleTakeoverCommand(runtime, parsed);
65
+ return;
66
+ case "release":
67
+ handleReleaseCommand(runtime, parsed);
68
+ return;
69
+ case "pass":
70
+ handlePassCommand(runtime, parsed);
71
+ return;
72
+ default:
73
+ throw new Error(`Unknown command: ${parsed.name}`);
74
+ }
75
+ }
76
+ finally {
77
+ runtime.close();
78
+ }
79
+ }
80
+ function createRuntime() {
81
+ const service = new TalkingStickService();
82
+ return {
83
+ commands: new TalkingStickCommands(service),
84
+ close: () => service.close()
85
+ };
86
+ }
87
+ function handleListCommand(runtime, parsed) {
88
+ const contextPath = parsed.positionals[0] ?? process.cwd();
89
+ const result = runtime.commands.listRooms({ context_path: contextPath });
90
+ printResult(parsed, result, () => {
91
+ if (result.rooms.length === 0) {
92
+ return "No rooms found.";
93
+ }
94
+ return result.rooms
95
+ .map((room) => {
96
+ const owner = room.owner ? ` owner=${room.owner}` : "";
97
+ const reserved = room.reserved_for
98
+ ? ` reserved_for=${room.reserved_for}`
99
+ : "";
100
+ return `${room.state} ${room.canonical_path}${owner}${reserved}`;
101
+ })
102
+ .join("\n");
103
+ });
104
+ }
105
+ function handleJoinCommand(runtime, parsed) {
106
+ const contextPath = parsed.positionals[0] ?? process.cwd();
107
+ const identity = deriveCliIdentity(parsed);
108
+ const joined = runtime.commands.joinPath(identity, {
109
+ context_path: contextPath,
110
+ force_new: hasOption(parsed, "force-new")
111
+ });
112
+ upsertSessionFromJoin(identity, joined);
113
+ printResult(parsed, joined, () => {
114
+ return `Joined ${joined.canonical_path} as ${joined.agent_id}`;
115
+ });
116
+ }
117
+ function handleStateCommand(runtime, parsed) {
118
+ const identity = deriveCliIdentity(parsed);
119
+ const session = resolveSessionForReads(runtime, parsed, identity);
120
+ const state = runtime.commands.getRoomState({ room_id: session.room_id });
121
+ printResult(parsed, { room: state.room, members: state.members }, () => {
122
+ const owner = state.room.owner ? ` owner=${state.room.owner}` : "";
123
+ const reserved = state.room.reserved_for
124
+ ? ` reserved_for=${state.room.reserved_for}`
125
+ : "";
126
+ return `${state.room.state} ${session.canonical_path}${owner}${reserved}`;
127
+ });
128
+ }
129
+ function handleEventsCommand(runtime, parsed) {
130
+ const identity = deriveCliIdentity(parsed);
131
+ const session = resolveSessionForReads(runtime, parsed, identity);
132
+ const events = runtime.commands.getRoomEvents({
133
+ room_id: session.room_id,
134
+ after_event_seq: parseOptionalInteger(parsed, "after"),
135
+ limit: parseOptionalInteger(parsed, "limit")
136
+ });
137
+ printResult(parsed, events, () => {
138
+ if (events.length === 0) {
139
+ return "No events.";
140
+ }
141
+ return events
142
+ .map((event) => `${event.event_seq} ${event.event_type} ${event.from_agent_id ?? "-"} -> ${event.to_agent_id ?? "-"}`)
143
+ .join("\n");
144
+ });
145
+ }
146
+ async function handleWaitCommand(runtime, parsed, isTry) {
147
+ const contextPath = parsed.positionals[0] ?? process.cwd();
148
+ const identity = deriveCliIdentity(parsed);
149
+ const joined = runtime.commands.joinPath(identity, { context_path: contextPath });
150
+ upsertSessionFromJoin(identity, joined);
151
+ const waitResult = await runtime.commands.waitForTurn(identity, {
152
+ room_id: joined.room_id,
153
+ max_wait_ms: isTry ? 0 : parseWaitTimeout(parsed)
154
+ });
155
+ if (waitResult.status === "your_turn") {
156
+ const guardianPid = await spawnGuardian({
157
+ agentId: identity.agent_id,
158
+ canonicalPath: joined.canonical_path,
159
+ roomId: joined.room_id,
160
+ leaseId: waitResult.lease_id,
161
+ turnId: waitResult.turn_id
162
+ });
163
+ upsertCliSession(resolveCliSessionPath(), {
164
+ agent_id: identity.agent_id,
165
+ room_id: joined.room_id,
166
+ canonical_path: joined.canonical_path,
167
+ workspace_root: joined.workspace_root,
168
+ lease_id: waitResult.lease_id,
169
+ turn_id: waitResult.turn_id,
170
+ guardian_pid: guardianPid.pid,
171
+ guardian_process_started_at: guardianPid.process_started_at,
172
+ updated_at: new Date().toISOString()
173
+ });
174
+ printResult(parsed, { ...waitResult, guardian_pid: guardianPid.pid }, () => `Your turn. Guardian ${guardianPid.pid} is holding the lease.`);
175
+ return;
176
+ }
177
+ printResult(parsed, waitResult, () => formatWaitResult(waitResult));
178
+ }
179
+ async function handleTakeoverCommand(runtime, parsed) {
180
+ const contextPath = parsed.positionals[0] ?? process.cwd();
181
+ const identity = deriveCliIdentity(parsed);
182
+ const joined = runtime.commands.joinPath(identity, { context_path: contextPath });
183
+ upsertSessionFromJoin(identity, joined);
184
+ const availability = await runtime.commands.waitForTurn(identity, {
185
+ room_id: joined.room_id,
186
+ max_wait_ms: 0
187
+ });
188
+ if (availability.status !== "takeover_available") {
189
+ throw new Error(`Takeover is not available: ${formatWaitResult(availability)}`);
190
+ }
191
+ const result = runtime.commands.takeoverStick(identity, {
192
+ room_id: joined.room_id,
193
+ expected_turn_id: availability.turn_id,
194
+ reason: requireStringOption(parsed, "reason")
195
+ });
196
+ const guardianPid = await spawnGuardian({
197
+ agentId: identity.agent_id,
198
+ canonicalPath: joined.canonical_path,
199
+ roomId: joined.room_id,
200
+ leaseId: result.lease_id,
201
+ turnId: result.turn_id
202
+ });
203
+ upsertCliSession(resolveCliSessionPath(), {
204
+ agent_id: identity.agent_id,
205
+ room_id: joined.room_id,
206
+ canonical_path: joined.canonical_path,
207
+ workspace_root: joined.workspace_root,
208
+ lease_id: result.lease_id,
209
+ turn_id: result.turn_id,
210
+ guardian_pid: guardianPid.pid,
211
+ guardian_process_started_at: guardianPid.process_started_at,
212
+ updated_at: new Date().toISOString()
213
+ });
214
+ printResult(parsed, { ...result, guardian_pid: guardianPid.pid }, () => `Takeover succeeded. Guardian ${guardianPid.pid} is holding the lease.`);
215
+ }
216
+ function handleReleaseCommand(runtime, parsed) {
217
+ const identity = deriveCliIdentity(parsed);
218
+ const contextPath = parsed.positionals[0] ?? process.cwd();
219
+ const session = requireLeaseSession(identity, contextPath);
220
+ const handoff = requireHandoff(parsed);
221
+ const result = runtime.commands.releaseStick(identity, {
222
+ room_id: session.room_id,
223
+ lease_id: session.lease_id,
224
+ expected_turn_id: session.turn_id,
225
+ handoff
226
+ });
227
+ clearCliSessionLease(resolveCliSessionPath(), identity.agent_id, session.room_id);
228
+ stopGuardian(session.guardian_pid, session.guardian_process_started_at ?? null);
229
+ printResult(parsed, result, () => {
230
+ const target = result.reserved_for ? ` to ${result.reserved_for}` : "";
231
+ return `Released${target}.`;
232
+ });
233
+ }
234
+ function handlePassCommand(runtime, parsed) {
235
+ const identity = deriveCliIdentity(parsed);
236
+ const contextPath = parsed.positionals[1] ?? process.cwd();
237
+ const session = requireLeaseSession(identity, contextPath);
238
+ const handoff = requireHandoff(parsed);
239
+ const target = parsed.positionals[0];
240
+ if (!target) {
241
+ const result = runtime.commands.releaseStick(identity, {
242
+ room_id: session.room_id,
243
+ lease_id: session.lease_id,
244
+ expected_turn_id: session.turn_id,
245
+ handoff
246
+ });
247
+ clearCliSessionLease(resolveCliSessionPath(), identity.agent_id, session.room_id);
248
+ stopGuardian(session.guardian_pid, session.guardian_process_started_at ?? null);
249
+ printResult(parsed, result, () => {
250
+ const reserved = result.reserved_for ? ` to ${result.reserved_for}` : "";
251
+ return `Passed${reserved}.`;
252
+ });
253
+ return;
254
+ }
255
+ const result = runtime.commands.passStick(identity, {
256
+ room_id: session.room_id,
257
+ lease_id: session.lease_id,
258
+ expected_turn_id: session.turn_id,
259
+ to_agent_id: target,
260
+ handoff
261
+ });
262
+ clearCliSessionLease(resolveCliSessionPath(), identity.agent_id, session.room_id);
263
+ stopGuardian(session.guardian_pid, session.guardian_process_started_at ?? null);
264
+ printResult(parsed, result, () => `Passed to ${result.reserved_for}.`);
265
+ }
266
+ async function runGuardCommand(parsed) {
267
+ const identity = deriveHumanCliIdentity({
268
+ agentId: requireStringOption(parsed, "agent"),
269
+ displayName: requireStringOption(parsed, "agent").replace(/^human:/, ""),
270
+ sessionKind: "human_guardian"
271
+ });
272
+ const runtime = createRuntime();
273
+ try {
274
+ const joined = runtime.commands.joinPath(identity, {
275
+ context_path: requireStringOption(parsed, "context-path")
276
+ });
277
+ const heartbeatInput = {
278
+ room_id: requireStringOption(parsed, "room-id"),
279
+ lease_id: requireStringOption(parsed, "lease-id"),
280
+ expected_turn_id: parseRequiredInteger(parsed, "turn-id")
281
+ };
282
+ const intervalMs = joined.policy.heartbeatIntervalMs;
283
+ process.stdout.write(`${GUARD_READY}\n`);
284
+ const timer = setInterval(() => {
285
+ try {
286
+ runtime.commands.heartbeat(identity, heartbeatInput);
287
+ }
288
+ catch (error) {
289
+ if (isProtocolError(error) && STALE_GUARD_ERRORS.has(error.code)) {
290
+ process.exit(0);
291
+ }
292
+ process.exit(1);
293
+ }
294
+ }, intervalMs);
295
+ const exit = () => {
296
+ clearInterval(timer);
297
+ process.exit(0);
298
+ };
299
+ process.on("SIGINT", exit);
300
+ process.on("SIGTERM", exit);
301
+ await new Promise(() => undefined);
302
+ }
303
+ finally {
304
+ runtime.close();
305
+ }
306
+ }
307
+ function deriveCliIdentity(parsed) {
308
+ const agentIdOption = getStringOption(parsed, "agent");
309
+ if (agentIdOption) {
310
+ const displayName = agentIdOption.replace(/^[^:]+:/, "");
311
+ return deriveHumanCliIdentity({
312
+ agentId: agentIdOption,
313
+ displayName
314
+ });
315
+ }
316
+ const harnessIdentity = deriveHarnessCliIdentity();
317
+ if (harnessIdentity) {
318
+ return harnessIdentity;
319
+ }
320
+ return deriveHumanCliIdentity();
321
+ }
322
+ function resolveSessionForReads(runtime, parsed, identity) {
323
+ const contextPath = parsed.positionals[0] ?? process.cwd();
324
+ const resolvedPath = resolveContextPath(contextPath);
325
+ const sessionPath = resolveCliSessionPath();
326
+ const existing = findCliSessionForContextPath(sessionPath, identity.agent_id, contextPath);
327
+ if (existing) {
328
+ return existing;
329
+ }
330
+ const rooms = runtime.commands.listRooms({ context_path: contextPath }).rooms;
331
+ const room = pickDeepestRoom(rooms);
332
+ if (!room) {
333
+ throw new Error("No room found for this path. Run `tt join` first.");
334
+ }
335
+ const session = {
336
+ agent_id: identity.agent_id,
337
+ room_id: room.room_id,
338
+ canonical_path: room.canonical_path,
339
+ workspace_root: resolvedPath.workspace_root,
340
+ updated_at: new Date().toISOString()
341
+ };
342
+ upsertCliSession(sessionPath, session);
343
+ return session;
344
+ }
345
+ function requireLeaseSession(identity, contextPath) {
346
+ const session = findCliSessionForContextPath(resolveCliSessionPath(), identity.agent_id, contextPath);
347
+ if (!session?.lease_id || session.turn_id === null || session.turn_id === undefined) {
348
+ throw new Error("No active lease for this path. Run `tt wait` or `tt takeover` first.");
349
+ }
350
+ return session;
351
+ }
352
+ function upsertSessionFromJoin(identity, joined) {
353
+ upsertCliSession(resolveCliSessionPath(), {
354
+ agent_id: identity.agent_id,
355
+ room_id: joined.room_id,
356
+ canonical_path: joined.canonical_path,
357
+ workspace_root: joined.workspace_root,
358
+ updated_at: new Date().toISOString()
359
+ });
360
+ }
361
+ function parseCommand(argv) {
362
+ const [name = "", ...rest] = argv;
363
+ const options = new Map();
364
+ const positionals = [];
365
+ for (let index = 0; index < rest.length; index += 1) {
366
+ const token = rest[index];
367
+ if (!token.startsWith("--")) {
368
+ positionals.push(token);
369
+ continue;
370
+ }
371
+ const key = token.slice(2);
372
+ const next = rest[index + 1];
373
+ if (!next || next.startsWith("--")) {
374
+ options.set(key, true);
375
+ continue;
376
+ }
377
+ options.set(key, next);
378
+ index += 1;
379
+ }
380
+ return { name, positionals, options };
381
+ }
382
+ function hasOption(parsed, key) {
383
+ return parsed.options.has(key);
384
+ }
385
+ function getStringOption(parsed, key) {
386
+ const value = parsed.options.get(key);
387
+ return typeof value === "string" ? value : undefined;
388
+ }
389
+ function requireStringOption(parsed, key) {
390
+ const value = getStringOption(parsed, key);
391
+ if (!value) {
392
+ throw new Error(`Missing required option --${key}`);
393
+ }
394
+ return value;
395
+ }
396
+ function parseOptionalInteger(parsed, key) {
397
+ const value = getStringOption(parsed, key);
398
+ if (!value) {
399
+ return undefined;
400
+ }
401
+ const parsedValue = Number.parseInt(value, 10);
402
+ if (!Number.isInteger(parsedValue)) {
403
+ throw new Error(`--${key} must be an integer.`);
404
+ }
405
+ return parsedValue;
406
+ }
407
+ function parseRequiredInteger(parsed, key) {
408
+ const value = parseOptionalInteger(parsed, key);
409
+ if (value === undefined) {
410
+ throw new Error(`Missing required option --${key}`);
411
+ }
412
+ return value;
413
+ }
414
+ function parseWaitTimeout(parsed) {
415
+ const value = getStringOption(parsed, "timeout");
416
+ if (!value) {
417
+ return undefined;
418
+ }
419
+ return parseDurationMs(value);
420
+ }
421
+ const DEFAULT_CLI_HANDOFF_STATUS = "(human handoff — no structured status provided)";
422
+ const DEFAULT_CLI_HANDOFF_NEXT_ACTION = "(no explicit guidance — proceed as previously established)";
423
+ function requireHandoff(parsed) {
424
+ return {
425
+ status: getStringOption(parsed, "status") ?? DEFAULT_CLI_HANDOFF_STATUS,
426
+ next_action: getStringOption(parsed, "next-action") ?? DEFAULT_CLI_HANDOFF_NEXT_ACTION
427
+ };
428
+ }
429
+ function parseDurationMs(value) {
430
+ if (/^\d+$/.test(value)) {
431
+ return Number.parseInt(value, 10) * 1000;
432
+ }
433
+ const match = value.match(/^(\d+)(ms|s|m|h)$/);
434
+ if (!match) {
435
+ throw new Error("Timeout values must be bare seconds or use ms/s/m/h suffixes.");
436
+ }
437
+ const amount = Number.parseInt(match[1], 10);
438
+ const unit = match[2];
439
+ switch (unit) {
440
+ case "ms":
441
+ return amount;
442
+ case "s":
443
+ return amount * 1000;
444
+ case "m":
445
+ return amount * 60 * 1000;
446
+ case "h":
447
+ return amount * 60 * 60 * 1000;
448
+ default:
449
+ throw new Error(`Unsupported duration unit: ${unit}`);
450
+ }
451
+ }
452
+ function pickDeepestRoom(rooms) {
453
+ if (rooms.length === 0) {
454
+ return null;
455
+ }
456
+ return rooms
457
+ .slice()
458
+ .sort((left, right) => right.canonical_path.length - left.canonical_path.length)[0];
459
+ }
460
+ async function spawnGuardian(input) {
461
+ const self = resolveSelfSpawn();
462
+ const child = spawn(self.command, [
463
+ ...self.args,
464
+ "guard",
465
+ "--agent",
466
+ input.agentId,
467
+ "--context-path",
468
+ input.canonicalPath,
469
+ "--room-id",
470
+ input.roomId,
471
+ "--lease-id",
472
+ input.leaseId,
473
+ "--turn-id",
474
+ String(input.turnId)
475
+ ], {
476
+ detached: true,
477
+ stdio: ["ignore", "pipe", "pipe"],
478
+ env: process.env
479
+ });
480
+ return await new Promise((resolve, reject) => {
481
+ const inspector = createSystemProcessInspector();
482
+ let stdout = "";
483
+ let stderr = "";
484
+ const timeout = setTimeout(() => {
485
+ reject(new Error("Guardian did not signal readiness in time."));
486
+ }, 3_000);
487
+ child.stdout?.setEncoding("utf8");
488
+ child.stderr?.setEncoding("utf8");
489
+ child.stdout?.on("data", (chunk) => {
490
+ stdout += chunk;
491
+ if (!stdout.includes(GUARD_READY)) {
492
+ return;
493
+ }
494
+ clearTimeout(timeout);
495
+ child.stdout?.destroy();
496
+ child.stderr?.destroy();
497
+ child.unref();
498
+ if (!child.pid) {
499
+ reject(new Error("Guardian started without a PID."));
500
+ return;
501
+ }
502
+ resolve({
503
+ pid: child.pid,
504
+ process_started_at: inspector.inspect(child.pid)?.startTime ?? null
505
+ });
506
+ });
507
+ child.stderr?.on("data", (chunk) => {
508
+ stderr += chunk;
509
+ });
510
+ child.on("exit", (code) => {
511
+ clearTimeout(timeout);
512
+ reject(new Error(`Guardian exited before readiness (code ${code ?? "unknown"}): ${stderr.trim()}`));
513
+ });
514
+ });
515
+ }
516
+ function resolveSelfSpawn() {
517
+ const scriptPath = fileURLToPath(import.meta.url);
518
+ if (scriptPath.endsWith(".ts")) {
519
+ const tsxBin = path.join(process.cwd(), "node_modules", ".bin", "tsx");
520
+ if (fs.existsSync(tsxBin)) {
521
+ return { command: tsxBin, args: [scriptPath] };
522
+ }
523
+ }
524
+ return { command: process.execPath, args: [scriptPath] };
525
+ }
526
+ function stopGuardian(guardianPid, guardianProcessStartedAt) {
527
+ if (!guardianPid) {
528
+ return;
529
+ }
530
+ terminateKnownProcess({
531
+ pid: guardianPid,
532
+ process_started_at: guardianProcessStartedAt ?? null
533
+ }, {
534
+ inspector: createSystemProcessInspector()
535
+ });
536
+ }
537
+ function formatWaitResult(result) {
538
+ switch (result.status) {
539
+ case "not_yet":
540
+ return "Not your turn yet.";
541
+ case "closed":
542
+ return "The room is closed.";
543
+ case "takeover_available":
544
+ return `Takeover available: ${result.reason ?? "unknown"}.`;
545
+ case "your_turn":
546
+ return "Your turn.";
547
+ default:
548
+ return result.status;
549
+ }
550
+ }
551
+ function printResult(parsed, result, renderText) {
552
+ if (hasOption(parsed, "json")) {
553
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
554
+ return;
555
+ }
556
+ process.stdout.write(`${renderText()}\n`);
557
+ }
558
+ async function runInstallCommand(parsed) {
559
+ normalizeBooleanFlag(parsed, "print");
560
+ const harnesses = selectHarnesses(parsed);
561
+ const dryRun = hasOption(parsed, "print");
562
+ const actions = harnesses.map((harness) => planInstall(harness));
563
+ if (dryRun) {
564
+ for (const action of actions) {
565
+ printActionPlan(action);
566
+ }
567
+ return;
568
+ }
569
+ const results = await Promise.all(actions.map((action) => runAction(action)));
570
+ reportInstallResults(results, "install");
571
+ }
572
+ async function runUninstallCommand(parsed) {
573
+ normalizeBooleanFlag(parsed, "print");
574
+ const harnesses = selectHarnesses(parsed);
575
+ const dryRun = hasOption(parsed, "print");
576
+ const actions = harnesses.map((harness) => planUninstall(harness));
577
+ if (dryRun) {
578
+ for (const action of actions) {
579
+ printActionPlan(action);
580
+ }
581
+ return;
582
+ }
583
+ const results = await Promise.all(actions.map((action) => runAction(action)));
584
+ reportInstallResults(results, "uninstall");
585
+ }
586
+ async function runInstallSkillCommand(parsed) {
587
+ normalizeBooleanFlag(parsed, "print");
588
+ normalizeBooleanFlag(parsed, "copy");
589
+ normalizeBooleanFlag(parsed, "link");
590
+ const harnesses = selectHarnesses(parsed);
591
+ const dryRun = hasOption(parsed, "print");
592
+ const link = resolveSkillInstallLinkMode(parsed);
593
+ const actions = harnesses.map((harness) => planSkillInstall(harness, { link }));
594
+ if (dryRun) {
595
+ for (const action of actions) {
596
+ printActionPlan(action);
597
+ }
598
+ return;
599
+ }
600
+ const results = await Promise.all(actions.map((action) => runAction(action)));
601
+ reportInstallResults(results, "install");
602
+ }
603
+ async function runUninstallSkillCommand(parsed) {
604
+ normalizeBooleanFlag(parsed, "print");
605
+ const harnesses = selectHarnesses(parsed);
606
+ const dryRun = hasOption(parsed, "print");
607
+ const actions = harnesses.map((harness) => planSkillUninstall(harness));
608
+ if (dryRun) {
609
+ for (const action of actions) {
610
+ printActionPlan(action);
611
+ }
612
+ return;
613
+ }
614
+ const results = await Promise.all(actions.map((action) => runAction(action)));
615
+ reportInstallResults(results, "uninstall");
616
+ }
617
+ function normalizeBooleanFlag(parsed, key) {
618
+ const value = parsed.options.get(key);
619
+ if (typeof value === "string") {
620
+ parsed.positionals.unshift(value);
621
+ parsed.options.set(key, true);
622
+ }
623
+ }
624
+ function resolveSkillInstallLinkMode(parsed) {
625
+ const wantsCopy = hasOption(parsed, "copy");
626
+ const wantsLink = hasOption(parsed, "link");
627
+ if (wantsCopy && wantsLink) {
628
+ throw new Error("Pass only one of --copy or --link.");
629
+ }
630
+ if (wantsCopy) {
631
+ return false;
632
+ }
633
+ return true;
634
+ }
635
+ function selectHarnesses(parsed) {
636
+ if (hasOption(parsed, "all")) {
637
+ const detected = SUPPORTED_HARNESSES.filter((harness) => detectHarness(harness).detected);
638
+ if (detected.length === 0) {
639
+ throw new Error(`No supported harnesses detected. Install one of: ${SUPPORTED_HARNESSES.join(", ")}, or pass harnesses explicitly.`);
640
+ }
641
+ return [...detected];
642
+ }
643
+ if (parsed.positionals.length === 0) {
644
+ throw new Error(`Specify at least one harness (${SUPPORTED_HARNESSES.join(", ")}) or pass --all to target every detected one.`);
645
+ }
646
+ return parseHarnessList(parsed.positionals);
647
+ }
648
+ function printActionPlan(action) {
649
+ if (action.kind === "exec") {
650
+ process.stdout.write(`[${action.harness}] ${action.description}\n`);
651
+ return;
652
+ }
653
+ process.stdout.write(`[${action.harness}] ${action.description}\n`);
654
+ }
655
+ function reportInstallResults(results, mode) {
656
+ let anyFailed = false;
657
+ for (const result of results) {
658
+ const status = result.ok ? "ok" : "FAIL";
659
+ process.stdout.write(`[${result.harness}] ${status}: ${result.message}\n`);
660
+ if (!result.ok)
661
+ anyFailed = true;
662
+ }
663
+ if (anyFailed) {
664
+ throw new Error(`${mode} completed with failures.`);
665
+ }
666
+ }
667
+ function printHelp() {
668
+ process.stdout.write(`Usage: tt <command> [options]
669
+
670
+ Commands:
671
+ tt list [path]
672
+ tt join [path] [--force-new]
673
+ tt wait [path] [--timeout 30s]
674
+ tt try [path]
675
+ tt state [path]
676
+ tt events [path] [--after N] [--limit N]
677
+ tt release [path] --status TEXT --next-action TEXT
678
+ tt pass [target] [path] --status TEXT --next-action TEXT
679
+ tt takeover [path] --reason TEXT
680
+ tt mcp
681
+ tt install <harness...> | --all [--print]
682
+ tt uninstall <harness...> | --all [--print]
683
+ tt install-skill <harness...> | --all [--print] [--copy] [--link]
684
+ tt uninstall-skill <harness...> | --all [--print]
685
+
686
+ Harnesses: ${SUPPORTED_HARNESSES.join(", ")}
687
+
688
+ Common options:
689
+ --agent ID Override the default human identity
690
+ --json Print JSON instead of text
691
+ `);
692
+ }
693
+ await runCli().catch((error) => {
694
+ const message = isProtocolError(error)
695
+ ? JSON.stringify(error.toJSON(), null, 2)
696
+ : error instanceof Error
697
+ ? error.message
698
+ : String(error);
699
+ process.stderr.write(`${message}\n`);
700
+ process.exit(1);
701
+ });