@hellcoder/companion 0.96.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.
Files changed (242) hide show
  1. package/bin/cli.ts +168 -0
  2. package/bin/ctl.ts +528 -0
  3. package/bin/generate-token.ts +28 -0
  4. package/dist/apple-touch-icon.png +0 -0
  5. package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
  6. package/dist/assets/CronManager-EGwLJONv.js +1 -0
  7. package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
  8. package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
  9. package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
  10. package/dist/assets/Playground-BV3k0RbV.js +109 -0
  11. package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
  12. package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
  13. package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
  14. package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
  15. package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
  16. package/dist/assets/index-BhUa1e6X.css +1 -0
  17. package/dist/assets/index-DkqeP-R9.js +134 -0
  18. package/dist/assets/sw-register-BibwRdvC.js +1 -0
  19. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  20. package/dist/favicon.svg +8 -0
  21. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  22. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  23. package/dist/icon-192.png +0 -0
  24. package/dist/icon-512.png +0 -0
  25. package/dist/index.html +20 -0
  26. package/dist/logo-codex.svg +14 -0
  27. package/dist/logo-docker.svg +4 -0
  28. package/dist/logo.svg +14 -0
  29. package/dist/manifest.json +24 -0
  30. package/dist/sw.js +2 -0
  31. package/package.json +104 -0
  32. package/server/agent-cron-migrator.test.ts +610 -0
  33. package/server/agent-cron-migrator.ts +85 -0
  34. package/server/agent-executor.test.ts +1108 -0
  35. package/server/agent-executor.ts +346 -0
  36. package/server/agent-store.test.ts +588 -0
  37. package/server/agent-store.ts +185 -0
  38. package/server/agent-types.ts +138 -0
  39. package/server/ai-validation-settings.test.ts +128 -0
  40. package/server/ai-validation-settings.ts +35 -0
  41. package/server/ai-validator.test.ts +387 -0
  42. package/server/ai-validator.ts +271 -0
  43. package/server/auth-manager.test.ts +83 -0
  44. package/server/auth-manager.ts +150 -0
  45. package/server/auto-namer.test.ts +252 -0
  46. package/server/auto-namer.ts +78 -0
  47. package/server/backend-adapter.test.ts +38 -0
  48. package/server/backend-adapter.ts +54 -0
  49. package/server/cache-headers.test.ts +98 -0
  50. package/server/cache-headers.ts +61 -0
  51. package/server/claude-adapter.test.ts +1363 -0
  52. package/server/claude-adapter.ts +889 -0
  53. package/server/claude-container-auth.test.ts +44 -0
  54. package/server/claude-container-auth.ts +30 -0
  55. package/server/claude-protocol-contract.test.ts +71 -0
  56. package/server/claude-protocol-drift.test.ts +78 -0
  57. package/server/claude-session-discovery.test.ts +132 -0
  58. package/server/claude-session-discovery.ts +157 -0
  59. package/server/claude-session-history.test.ts +158 -0
  60. package/server/claude-session-history.ts +410 -0
  61. package/server/cli-launcher.test.ts +1343 -0
  62. package/server/cli-launcher.ts +1298 -0
  63. package/server/cli.test.ts +16 -0
  64. package/server/codex-adapter.test.ts +5545 -0
  65. package/server/codex-adapter.ts +3062 -0
  66. package/server/codex-container-auth.test.ts +50 -0
  67. package/server/codex-container-auth.ts +24 -0
  68. package/server/codex-home.test.ts +61 -0
  69. package/server/codex-home.ts +26 -0
  70. package/server/codex-protocol-contract.test.ts +96 -0
  71. package/server/codex-protocol-drift.test.ts +123 -0
  72. package/server/codex-ws-proxy.cjs +226 -0
  73. package/server/commands-discovery.test.ts +179 -0
  74. package/server/commands-discovery.ts +81 -0
  75. package/server/constants.ts +7 -0
  76. package/server/container-manager.test.ts +1211 -0
  77. package/server/container-manager.ts +1053 -0
  78. package/server/cron-scheduler.test.ts +957 -0
  79. package/server/cron-scheduler.ts +243 -0
  80. package/server/cron-store.test.ts +422 -0
  81. package/server/cron-store.ts +148 -0
  82. package/server/cron-types.ts +63 -0
  83. package/server/env-manager.test.ts +268 -0
  84. package/server/env-manager.ts +161 -0
  85. package/server/event-bus-types.ts +64 -0
  86. package/server/event-bus.test.ts +244 -0
  87. package/server/event-bus.ts +124 -0
  88. package/server/execution-store.test.ts +307 -0
  89. package/server/execution-store.ts +170 -0
  90. package/server/fs-utils.ts +15 -0
  91. package/server/git-utils.test.ts +938 -0
  92. package/server/git-utils.ts +421 -0
  93. package/server/github-pr.test.ts +498 -0
  94. package/server/github-pr.ts +379 -0
  95. package/server/image-pull-manager.test.ts +303 -0
  96. package/server/image-pull-manager.ts +279 -0
  97. package/server/index.ts +396 -0
  98. package/server/linear-agent-bridge.test.ts +1157 -0
  99. package/server/linear-agent-bridge.ts +629 -0
  100. package/server/linear-agent.test.ts +473 -0
  101. package/server/linear-agent.ts +479 -0
  102. package/server/linear-cache.test.ts +136 -0
  103. package/server/linear-cache.ts +113 -0
  104. package/server/linear-connections.test.ts +350 -0
  105. package/server/linear-connections.ts +231 -0
  106. package/server/linear-credential-migration.test.ts +337 -0
  107. package/server/linear-credential-migration.ts +63 -0
  108. package/server/linear-oauth-connections-migration.test.ts +268 -0
  109. package/server/linear-oauth-connections.test.ts +365 -0
  110. package/server/linear-oauth-connections.ts +294 -0
  111. package/server/linear-project-manager.test.ts +162 -0
  112. package/server/linear-project-manager.ts +111 -0
  113. package/server/linear-prompt-builder.test.ts +74 -0
  114. package/server/linear-prompt-builder.ts +61 -0
  115. package/server/linear-staging.test.ts +276 -0
  116. package/server/linear-staging.ts +142 -0
  117. package/server/logger.test.ts +393 -0
  118. package/server/logger.ts +259 -0
  119. package/server/metrics-collector.test.ts +413 -0
  120. package/server/metrics-collector.ts +350 -0
  121. package/server/metrics-types.ts +108 -0
  122. package/server/middleware/managed-auth.test.ts +264 -0
  123. package/server/middleware/managed-auth.ts +195 -0
  124. package/server/novnc-proxy.test.ts +333 -0
  125. package/server/novnc-proxy.ts +99 -0
  126. package/server/path-resolver.test.ts +552 -0
  127. package/server/path-resolver.ts +186 -0
  128. package/server/paths.test.ts +31 -0
  129. package/server/paths.ts +11 -0
  130. package/server/pr-poller.test.ts +191 -0
  131. package/server/pr-poller.ts +162 -0
  132. package/server/prompt-manager.test.ts +211 -0
  133. package/server/prompt-manager.ts +211 -0
  134. package/server/protocol/claude-upstream/README.md +19 -0
  135. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  136. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  137. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  138. package/server/protocol/codex-upstream/README.md +18 -0
  139. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  140. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  141. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  142. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  143. package/server/protocol-monitor.ts +50 -0
  144. package/server/recorder.test.ts +454 -0
  145. package/server/recorder.ts +374 -0
  146. package/server/recording-hub/compat-validator.test.ts +150 -0
  147. package/server/recording-hub/compat-validator.ts +284 -0
  148. package/server/recording-hub/diagnostics.test.ts +140 -0
  149. package/server/recording-hub/diagnostics.ts +299 -0
  150. package/server/recording-hub/hub-config.test.ts +44 -0
  151. package/server/recording-hub/hub-config.ts +19 -0
  152. package/server/recording-hub/hub-routes.test.ts +417 -0
  153. package/server/recording-hub/hub-routes.ts +236 -0
  154. package/server/recording-hub/hub-store.test.ts +262 -0
  155. package/server/recording-hub/hub-store.ts +265 -0
  156. package/server/recording-hub/replay-adapter.test.ts +294 -0
  157. package/server/recording-hub/replay-adapter.ts +207 -0
  158. package/server/relay-client.test.ts +337 -0
  159. package/server/relay-client.ts +320 -0
  160. package/server/replay.test.ts +200 -0
  161. package/server/replay.ts +78 -0
  162. package/server/routes/agent-routes.test.ts +1400 -0
  163. package/server/routes/agent-routes.ts +409 -0
  164. package/server/routes/cron-routes.test.ts +881 -0
  165. package/server/routes/cron-routes.ts +103 -0
  166. package/server/routes/env-routes.test.ts +383 -0
  167. package/server/routes/env-routes.ts +95 -0
  168. package/server/routes/fs-routes.test.ts +1198 -0
  169. package/server/routes/fs-routes.ts +605 -0
  170. package/server/routes/git-routes.test.ts +813 -0
  171. package/server/routes/git-routes.ts +97 -0
  172. package/server/routes/linear-agent-routes.test.ts +721 -0
  173. package/server/routes/linear-agent-routes.ts +304 -0
  174. package/server/routes/linear-connection-routes.test.ts +927 -0
  175. package/server/routes/linear-connection-routes.ts +244 -0
  176. package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
  177. package/server/routes/linear-oauth-connection-routes.ts +129 -0
  178. package/server/routes/linear-routes.test.ts +1510 -0
  179. package/server/routes/linear-routes.ts +953 -0
  180. package/server/routes/metrics-routes.test.ts +103 -0
  181. package/server/routes/metrics-routes.ts +13 -0
  182. package/server/routes/prompt-routes.ts +67 -0
  183. package/server/routes/sandbox-routes.test.ts +513 -0
  184. package/server/routes/sandbox-routes.ts +127 -0
  185. package/server/routes/settings-routes.ts +270 -0
  186. package/server/routes/skills-routes.test.ts +690 -0
  187. package/server/routes/skills-routes.ts +100 -0
  188. package/server/routes/system-routes.test.ts +637 -0
  189. package/server/routes/system-routes.ts +228 -0
  190. package/server/routes/tailscale-routes.test.ts +176 -0
  191. package/server/routes/tailscale-routes.ts +22 -0
  192. package/server/routes.test.ts +4655 -0
  193. package/server/routes.ts +1277 -0
  194. package/server/sandbox-manager.test.ts +378 -0
  195. package/server/sandbox-manager.ts +168 -0
  196. package/server/service.test.ts +1419 -0
  197. package/server/service.ts +718 -0
  198. package/server/session-creation-service.test.ts +661 -0
  199. package/server/session-creation-service.ts +473 -0
  200. package/server/session-git-info.ts +104 -0
  201. package/server/session-linear-issues.test.ts +118 -0
  202. package/server/session-linear-issues.ts +88 -0
  203. package/server/session-names.test.ts +94 -0
  204. package/server/session-names.ts +67 -0
  205. package/server/session-orchestrator.test.ts +1784 -0
  206. package/server/session-orchestrator.ts +973 -0
  207. package/server/session-state-machine.test.ts +606 -0
  208. package/server/session-state-machine.ts +207 -0
  209. package/server/session-store.test.ts +290 -0
  210. package/server/session-store.ts +146 -0
  211. package/server/session-types.ts +509 -0
  212. package/server/settings-manager.test.ts +275 -0
  213. package/server/settings-manager.ts +173 -0
  214. package/server/tailscale-manager.test.ts +553 -0
  215. package/server/tailscale-manager.ts +451 -0
  216. package/server/terminal-manager.ts +240 -0
  217. package/server/update-checker.test.ts +306 -0
  218. package/server/update-checker.ts +197 -0
  219. package/server/usage-limits.test.ts +536 -0
  220. package/server/usage-limits.ts +225 -0
  221. package/server/worktree-tracker.test.ts +243 -0
  222. package/server/worktree-tracker.ts +84 -0
  223. package/server/ws-auth.test.ts +59 -0
  224. package/server/ws-auth.ts +41 -0
  225. package/server/ws-bridge-browser-ingest.test.ts +272 -0
  226. package/server/ws-bridge-browser-ingest.ts +72 -0
  227. package/server/ws-bridge-browser.ts +112 -0
  228. package/server/ws-bridge-cli-ingest.test.ts +302 -0
  229. package/server/ws-bridge-cli-ingest.ts +81 -0
  230. package/server/ws-bridge-codex.test.ts +1837 -0
  231. package/server/ws-bridge-codex.ts +266 -0
  232. package/server/ws-bridge-controls.test.ts +124 -0
  233. package/server/ws-bridge-controls.ts +20 -0
  234. package/server/ws-bridge-persist.test.ts +296 -0
  235. package/server/ws-bridge-persist.ts +66 -0
  236. package/server/ws-bridge-publish.test.ts +234 -0
  237. package/server/ws-bridge-publish.ts +79 -0
  238. package/server/ws-bridge-replay.test.ts +44 -0
  239. package/server/ws-bridge-replay.ts +61 -0
  240. package/server/ws-bridge-types.ts +106 -0
  241. package/server/ws-bridge.test.ts +4777 -0
  242. package/server/ws-bridge.ts +1279 -0
package/bin/cli.ts ADDED
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env bun
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ // Package root so the server can find dist/ regardless of CWD
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ process.env.__COMPANION_PACKAGE_ROOT = resolve(__dirname, "..");
8
+
9
+ const command = process.argv[2];
10
+
11
+ // Management subcommands that delegate to ctl.ts
12
+ const CTL_COMMANDS = new Set([
13
+ "sessions", "envs", "cron", "skills", "settings", "assistant", "ctl-help",
14
+ ]);
15
+
16
+ function printUsage(): void {
17
+ console.log(`
18
+ Usage: companion [command]
19
+
20
+ Server commands:
21
+ (none) Start the server in foreground (default)
22
+ serve Start the server in foreground
23
+ start Start the background service
24
+ install Install as a background service (launchd/systemd)
25
+ stop Stop the background service
26
+ restart Restart the background service
27
+ uninstall Remove the background service
28
+ status Show service status (or use 'companion status' when server is running)
29
+ logs Tail service log files
30
+ help Show this help message
31
+
32
+ Management commands (requires running server):
33
+ sessions Manage sessions (list, create, kill, relaunch, archive, rename, send-message)
34
+ envs Manage environment profiles (list, get, create, update, delete)
35
+ cron Manage scheduled jobs (list, get, create, update, delete, toggle, run)
36
+ skills Manage Claude Code skills (list, get, create, update, delete)
37
+ settings Manage settings (get, set)
38
+ assistant Manage the Companion Assistant (status, launch, stop, config)
39
+
40
+ Options:
41
+ --port <n> Override the default port (default: 3456)
42
+ `);
43
+ }
44
+
45
+ switch (command) {
46
+ case "help":
47
+ case "-h":
48
+ case "--help":
49
+ printUsage();
50
+ break;
51
+
52
+ case "serve": {
53
+ process.env.NODE_ENV = process.env.NODE_ENV || "production";
54
+ await import("../server/index.ts");
55
+ break;
56
+ }
57
+
58
+ case "start": {
59
+ // Internal service process should stay in foreground server mode.
60
+ const forceForeground = process.argv.includes("--foreground");
61
+ const launchedByInit = (() => {
62
+ if (process.ppid === 1) return true;
63
+ // User-level systemd (systemctl --user) spawns services from a
64
+ // per-user systemd process whose ppid != 1. Detect it via /proc.
65
+ try {
66
+ const { readFileSync } = require("node:fs");
67
+ const comm = readFileSync(`/proc/${process.ppid}/comm`, "utf-8").trim();
68
+ return comm === "systemd";
69
+ } catch {
70
+ return false;
71
+ }
72
+ })();
73
+ if (forceForeground || launchedByInit) {
74
+ process.env.NODE_ENV = process.env.NODE_ENV || "production";
75
+ await import("../server/index.ts");
76
+ break;
77
+ }
78
+ const { start } = await import("../server/service.js");
79
+ await start();
80
+ break;
81
+ }
82
+
83
+ case "install": {
84
+ const { install } = await import("../server/service.js");
85
+ const portIdx = process.argv.indexOf("--port");
86
+ const rawPort = portIdx !== -1 ? Number(process.argv[portIdx + 1]) : undefined;
87
+ const port = rawPort && !Number.isNaN(rawPort) ? rawPort : undefined;
88
+ await install({ port });
89
+ break;
90
+ }
91
+
92
+ case "uninstall": {
93
+ const { uninstall } = await import("../server/service.js");
94
+ await uninstall();
95
+ break;
96
+ }
97
+
98
+ case "status": {
99
+ // Try management API first (server running), fall back to service status
100
+ try {
101
+ const { handleCtlCommand } = await import("./ctl.js");
102
+ await handleCtlCommand("status", process.argv.slice(3));
103
+ } catch {
104
+ // Server not running — show service status
105
+ const { status } = await import("../server/service.js");
106
+ const result = await status();
107
+ if (!result.installed) {
108
+ console.log("The Companion is not installed as a service.");
109
+ console.log("Run: companion install");
110
+ } else if (result.running) {
111
+ console.log(`The Companion is running (PID: ${result.pid})`);
112
+ console.log(` URL: http://localhost:${result.port}`);
113
+ } else {
114
+ console.log("The Companion is installed but not running.");
115
+ console.log("Check logs at ~/.companion/logs/");
116
+ }
117
+ }
118
+ break;
119
+ }
120
+
121
+ case "stop": {
122
+ const { stop } = await import("../server/service.js");
123
+ await stop();
124
+ break;
125
+ }
126
+
127
+ case "restart": {
128
+ const { restart } = await import("../server/service.js");
129
+ await restart();
130
+ break;
131
+ }
132
+
133
+ case "logs": {
134
+ const { join } = await import("node:path");
135
+ const { homedir } = await import("node:os");
136
+ const { spawn } = await import("node:child_process");
137
+ const logFile = join(homedir(), ".companion/logs/companion.log");
138
+ const errFile = join(homedir(), ".companion/logs/companion.error.log");
139
+ const { existsSync } = await import("node:fs");
140
+ if (!existsSync(logFile) && !existsSync(errFile)) {
141
+ console.error("No log files found at ~/.companion/logs/");
142
+ console.error("The service may not have been started yet.");
143
+ process.exit(1);
144
+ }
145
+ console.log("Tailing logs from ~/.companion/logs/");
146
+ const tail = spawn("tail", ["-f", logFile, errFile], { stdio: "inherit" });
147
+ tail.on("exit", () => process.exit(0));
148
+ break;
149
+ }
150
+
151
+ case undefined: {
152
+ // Default: start server in foreground
153
+ process.env.NODE_ENV = process.env.NODE_ENV || "production";
154
+ await import("../server/index.ts");
155
+ break;
156
+ }
157
+
158
+ default: {
159
+ if (command && CTL_COMMANDS.has(command)) {
160
+ const { handleCtlCommand } = await import("./ctl.js");
161
+ await handleCtlCommand(command, process.argv.slice(3));
162
+ } else {
163
+ console.error(`Unknown command: ${command}`);
164
+ printUsage();
165
+ process.exit(1);
166
+ }
167
+ }
168
+ }
package/bin/ctl.ts ADDED
@@ -0,0 +1,528 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * CLI handler module for `companion` management subcommands.
4
+ * Each subcommand maps 1:1 to a Companion REST API endpoint.
5
+ * All output is JSON to stdout for easy parsing by both humans and AI agents.
6
+ */
7
+
8
+ const DEFAULT_PORT = 3456;
9
+
10
+ function getPort(argv: string[]): number {
11
+ const idx = argv.indexOf("--port");
12
+ if (idx !== -1 && argv[idx + 1]) {
13
+ const p = Number(argv[idx + 1]);
14
+ if (!Number.isNaN(p) && p > 0) return p;
15
+ }
16
+ return Number(process.env.COMPANION_PORT) || DEFAULT_PORT;
17
+ }
18
+
19
+ function getBase(argv: string[]): string {
20
+ return `http://localhost:${getPort(argv)}/api`;
21
+ }
22
+
23
+ /** Strip --port <n> from argv so subcommand parsers don't see it */
24
+ function stripGlobalFlags(argv: string[]): string[] {
25
+ const result: string[] = [];
26
+ let i = 0;
27
+ while (i < argv.length) {
28
+ if (argv[i] === "--port" && argv[i + 1]) {
29
+ i += 2;
30
+ continue;
31
+ }
32
+ result.push(argv[i]);
33
+ i++;
34
+ }
35
+ return result;
36
+ }
37
+
38
+ async function apiGet(base: string, path: string): Promise<unknown> {
39
+ const res = await fetch(`${base}${path}`);
40
+ if (!res.ok) {
41
+ const body = await res.json().catch(() => ({ error: res.statusText }));
42
+ throw new Error((body as { error?: string }).error || `HTTP ${res.status}`);
43
+ }
44
+ return res.json();
45
+ }
46
+
47
+ async function apiPost(base: string, path: string, body?: unknown): Promise<unknown> {
48
+ const res = await fetch(`${base}${path}`, {
49
+ method: "POST",
50
+ headers: { "Content-Type": "application/json" },
51
+ body: body ? JSON.stringify(body) : undefined,
52
+ });
53
+ if (!res.ok) {
54
+ const data = await res.json().catch(() => ({ error: res.statusText }));
55
+ throw new Error((data as { error?: string }).error || `HTTP ${res.status}`);
56
+ }
57
+ return res.json();
58
+ }
59
+
60
+ async function apiPut(base: string, path: string, body: unknown): Promise<unknown> {
61
+ const res = await fetch(`${base}${path}`, {
62
+ method: "PUT",
63
+ headers: { "Content-Type": "application/json" },
64
+ body: JSON.stringify(body),
65
+ });
66
+ if (!res.ok) {
67
+ const data = await res.json().catch(() => ({ error: res.statusText }));
68
+ throw new Error((data as { error?: string }).error || `HTTP ${res.status}`);
69
+ }
70
+ return res.json();
71
+ }
72
+
73
+ async function apiPatch(base: string, path: string, body: unknown): Promise<unknown> {
74
+ const res = await fetch(`${base}${path}`, {
75
+ method: "PATCH",
76
+ headers: { "Content-Type": "application/json" },
77
+ body: JSON.stringify(body),
78
+ });
79
+ if (!res.ok) {
80
+ const data = await res.json().catch(() => ({ error: res.statusText }));
81
+ throw new Error((data as { error?: string }).error || `HTTP ${res.status}`);
82
+ }
83
+ return res.json();
84
+ }
85
+
86
+ async function apiDelete(base: string, path: string, body?: unknown): Promise<unknown> {
87
+ const res = await fetch(`${base}${path}`, {
88
+ method: "DELETE",
89
+ headers: { "Content-Type": "application/json" },
90
+ body: body ? JSON.stringify(body) : undefined,
91
+ });
92
+ if (!res.ok) {
93
+ const data = await res.json().catch(() => ({ error: res.statusText }));
94
+ throw new Error((data as { error?: string }).error || `HTTP ${res.status}`);
95
+ }
96
+ return res.json();
97
+ }
98
+
99
+ function out(data: unknown): void {
100
+ console.log(JSON.stringify(data, null, 2));
101
+ }
102
+
103
+ function err(message: string): never {
104
+ console.error(JSON.stringify({ error: message }));
105
+ process.exit(1);
106
+ }
107
+
108
+ /** Parse --key value pairs from argv. Supports --flag (boolean true). */
109
+ function parseFlags(argv: string[]): Record<string, string | boolean> {
110
+ const flags: Record<string, string | boolean> = {};
111
+ let i = 0;
112
+ while (i < argv.length) {
113
+ const arg = argv[i];
114
+ if (arg.startsWith("--")) {
115
+ const key = arg.slice(2);
116
+ const next = argv[i + 1];
117
+ if (next && !next.startsWith("--")) {
118
+ flags[key] = next;
119
+ i += 2;
120
+ } else {
121
+ flags[key] = true;
122
+ i++;
123
+ }
124
+ } else {
125
+ i++;
126
+ }
127
+ }
128
+ return flags;
129
+ }
130
+
131
+ /** Collect all --var KEY=VALUE pairs from argv */
132
+ function parseVars(argv: string[]): Record<string, string> {
133
+ const vars: Record<string, string> = {};
134
+ let i = 0;
135
+ while (i < argv.length) {
136
+ if (argv[i] === "--var" && argv[i + 1]) {
137
+ const eq = argv[i + 1].indexOf("=");
138
+ if (eq > 0) {
139
+ vars[argv[i + 1].slice(0, eq)] = argv[i + 1].slice(eq + 1);
140
+ }
141
+ i += 2;
142
+ } else {
143
+ i++;
144
+ }
145
+ }
146
+ return vars;
147
+ }
148
+
149
+ // ─── Subcommand handlers ────────────────────────────────────────────────────
150
+
151
+ async function handleStatus(base: string): Promise<void> {
152
+ const [sessions, backends] = await Promise.all([
153
+ apiGet(base, "/sessions") as Promise<unknown[]>,
154
+ apiGet(base, "/backends") as Promise<unknown[]>,
155
+ ]);
156
+ const active = (sessions as Array<{ state?: string; archived?: boolean }>).filter(
157
+ (s) => !s.archived && s.state !== "exited",
158
+ );
159
+ out({
160
+ activeSessions: active.length,
161
+ totalSessions: sessions.length,
162
+ backends,
163
+ });
164
+ }
165
+
166
+ async function handleSessions(base: string, args: string[]): Promise<void> {
167
+ const sub = args[0];
168
+ const rest = args.slice(1);
169
+
170
+ switch (sub) {
171
+ case "list": {
172
+ const sessions = await apiGet(base, "/sessions");
173
+ out(sessions);
174
+ break;
175
+ }
176
+ case "get": {
177
+ const id = rest[0];
178
+ if (!id) err("Usage: companion sessions get <sessionId>");
179
+ out(await apiGet(base, `/sessions/${encodeURIComponent(id)}`));
180
+ break;
181
+ }
182
+ case "create": {
183
+ const flags = parseFlags(rest);
184
+ const body: Record<string, unknown> = {};
185
+ if (flags.cwd) body.cwd = flags.cwd;
186
+ if (flags.model) body.model = flags.model;
187
+ if (flags["permission-mode"]) body.permissionMode = flags["permission-mode"];
188
+ if (flags.env) body.envSlug = flags.env;
189
+ if (flags.backend) body.backend = flags.backend;
190
+ if (flags.worktree) body.useWorktree = true;
191
+ if (flags.branch) body.branch = flags.branch;
192
+ if (flags["create-branch"]) body.createBranch = true;
193
+ out(await apiPost(base, "/sessions/create", body));
194
+ break;
195
+ }
196
+ case "kill": {
197
+ const id = rest[0];
198
+ if (!id) err("Usage: companion sessions kill <sessionId>");
199
+ out(await apiPost(base, `/sessions/${encodeURIComponent(id)}/kill`));
200
+ break;
201
+ }
202
+ case "relaunch": {
203
+ const id = rest[0];
204
+ if (!id) err("Usage: companion sessions relaunch <sessionId>");
205
+ out(await apiPost(base, `/sessions/${encodeURIComponent(id)}/relaunch`));
206
+ break;
207
+ }
208
+ case "archive": {
209
+ const id = rest[0];
210
+ if (!id) err("Usage: companion sessions archive <sessionId>");
211
+ out(await apiPost(base, `/sessions/${encodeURIComponent(id)}/archive`));
212
+ break;
213
+ }
214
+ case "rename": {
215
+ const id = rest[0];
216
+ const name = rest.slice(1).join(" ");
217
+ if (!id || !name) err("Usage: companion sessions rename <sessionId> <name>");
218
+ out(await apiPatch(base, `/sessions/${encodeURIComponent(id)}/name`, { name }));
219
+ break;
220
+ }
221
+ case "send-message": {
222
+ const id = rest[0];
223
+ const content = rest.slice(1).join(" ");
224
+ if (!id || !content) err("Usage: companion sessions send-message <sessionId> <message>");
225
+ out(await apiPost(base, `/sessions/${encodeURIComponent(id)}/message`, { content }));
226
+ break;
227
+ }
228
+ default:
229
+ err(`Unknown sessions subcommand: ${sub}. Available: list, get, create, kill, relaunch, archive, rename, send-message`);
230
+ }
231
+ }
232
+
233
+ async function handleEnvs(base: string, args: string[]): Promise<void> {
234
+ const sub = args[0];
235
+ const rest = args.slice(1);
236
+
237
+ switch (sub) {
238
+ case "list": {
239
+ out(await apiGet(base, "/envs"));
240
+ break;
241
+ }
242
+ case "get": {
243
+ const slug = rest[0];
244
+ if (!slug) err("Usage: companion envs get <slug>");
245
+ out(await apiGet(base, `/envs/${encodeURIComponent(slug)}`));
246
+ break;
247
+ }
248
+ case "create": {
249
+ const flags = parseFlags(rest);
250
+ const vars = parseVars(rest);
251
+ if (!flags.name) err("Usage: companion envs create --name <name> [--var KEY=VALUE ...]");
252
+ out(await apiPost(base, "/envs", { name: flags.name, variables: vars }));
253
+ break;
254
+ }
255
+ case "update": {
256
+ const slug = rest[0];
257
+ if (!slug) err("Usage: companion envs update <slug> [--name <name>] [--var KEY=VALUE ...]");
258
+ const flagArgs = rest.slice(1);
259
+ const flags = parseFlags(flagArgs);
260
+ const vars = parseVars(flagArgs);
261
+ const body: Record<string, unknown> = {};
262
+ if (flags.name) body.name = flags.name;
263
+ if (Object.keys(vars).length > 0) body.variables = vars;
264
+ out(await apiPut(base, `/envs/${encodeURIComponent(slug)}`, body));
265
+ break;
266
+ }
267
+ case "delete": {
268
+ const slug = rest[0];
269
+ if (!slug) err("Usage: companion envs delete <slug>");
270
+ out(await apiDelete(base, `/envs/${encodeURIComponent(slug)}`));
271
+ break;
272
+ }
273
+ default:
274
+ err(`Unknown envs subcommand: ${sub}. Available: list, get, create, update, delete`);
275
+ }
276
+ }
277
+
278
+ async function handleCron(base: string, args: string[]): Promise<void> {
279
+ const sub = args[0];
280
+ const rest = args.slice(1);
281
+
282
+ switch (sub) {
283
+ case "list": {
284
+ out(await apiGet(base, "/cron/jobs"));
285
+ break;
286
+ }
287
+ case "get": {
288
+ const id = rest[0];
289
+ if (!id) err("Usage: companion cron get <jobId>");
290
+ out(await apiGet(base, `/cron/jobs/${encodeURIComponent(id)}`));
291
+ break;
292
+ }
293
+ case "create": {
294
+ const flags = parseFlags(rest);
295
+ if (!flags.name || !flags.schedule || !flags.prompt)
296
+ err("Usage: companion cron create --name <name> --schedule <cron|datetime> --prompt <prompt> [--cwd <path>] [--model <model>] [--env <slug>] [--recurring] [--backend <type>] [--permission-mode <mode>]");
297
+ const body: Record<string, unknown> = {
298
+ name: flags.name,
299
+ schedule: flags.schedule,
300
+ prompt: flags.prompt,
301
+ };
302
+ if (flags.cwd) body.cwd = flags.cwd;
303
+ if (flags.model) body.model = flags.model;
304
+ if (flags.env) body.envSlug = flags.env;
305
+ if (flags.backend) body.backendType = flags.backend;
306
+ if (flags["permission-mode"]) body.permissionMode = flags["permission-mode"];
307
+ // Default: recurring=true for cron expressions, false if looks like a datetime
308
+ body.recurring = flags.recurring === true || flags.recurring === "true"
309
+ || (flags.recurring === undefined && !(flags.schedule as string).includes("T"));
310
+ out(await apiPost(base, "/cron/jobs", body));
311
+ break;
312
+ }
313
+ case "update": {
314
+ const id = rest[0];
315
+ if (!id) err("Usage: companion cron update <jobId> [--name <n>] [--schedule <s>] [--prompt <p>] ...");
316
+ const flagArgs = rest.slice(1);
317
+ const flags = parseFlags(flagArgs);
318
+ const body: Record<string, unknown> = {};
319
+ if (flags.name) body.name = flags.name;
320
+ if (flags.schedule) body.schedule = flags.schedule;
321
+ if (flags.prompt) body.prompt = flags.prompt;
322
+ if (flags.cwd) body.cwd = flags.cwd;
323
+ if (flags.model) body.model = flags.model;
324
+ if (flags.env) body.envSlug = flags.env;
325
+ if (flags.backend) body.backendType = flags.backend;
326
+ if (flags["permission-mode"]) body.permissionMode = flags["permission-mode"];
327
+ if (flags.recurring !== undefined) body.recurring = flags.recurring === true || flags.recurring === "true";
328
+ out(await apiPut(base, `/cron/jobs/${encodeURIComponent(id)}`, body));
329
+ break;
330
+ }
331
+ case "delete": {
332
+ const id = rest[0];
333
+ if (!id) err("Usage: companion cron delete <jobId>");
334
+ out(await apiDelete(base, `/cron/jobs/${encodeURIComponent(id)}`));
335
+ break;
336
+ }
337
+ case "toggle": {
338
+ const id = rest[0];
339
+ if (!id) err("Usage: companion cron toggle <jobId>");
340
+ out(await apiPost(base, `/cron/jobs/${encodeURIComponent(id)}/toggle`));
341
+ break;
342
+ }
343
+ case "run": {
344
+ const id = rest[0];
345
+ if (!id) err("Usage: companion cron run <jobId>");
346
+ out(await apiPost(base, `/cron/jobs/${encodeURIComponent(id)}/run`));
347
+ break;
348
+ }
349
+ case "executions": {
350
+ const id = rest[0];
351
+ if (!id) err("Usage: companion cron executions <jobId>");
352
+ out(await apiGet(base, `/cron/jobs/${encodeURIComponent(id)}/executions`));
353
+ break;
354
+ }
355
+ default:
356
+ err(`Unknown cron subcommand: ${sub}. Available: list, get, create, update, delete, toggle, run, executions`);
357
+ }
358
+ }
359
+
360
+ async function handleSettings(base: string, args: string[]): Promise<void> {
361
+ const sub = args[0];
362
+
363
+ switch (sub) {
364
+ case "get": {
365
+ out(await apiGet(base, "/settings"));
366
+ break;
367
+ }
368
+ case "set": {
369
+ const flags = parseFlags(args.slice(1));
370
+ const body: Record<string, unknown> = {};
371
+ if (flags["anthropic-key"]) body.anthropicApiKey = flags["anthropic-key"];
372
+ if (flags["anthropic-model"]) body.anthropicModel = flags["anthropic-model"];
373
+ if (Object.keys(body).length === 0) err("Usage: companion settings set --anthropic-key <key> or --anthropic-model <model>");
374
+ out(await apiPut(base, "/settings", body));
375
+ break;
376
+ }
377
+ default:
378
+ err(`Unknown settings subcommand: ${sub}. Available: get, set`);
379
+ }
380
+ }
381
+
382
+ async function handleAssistant(base: string, args: string[]): Promise<void> {
383
+ const sub = args[0];
384
+
385
+ switch (sub) {
386
+ case "status": {
387
+ out(await apiGet(base, "/assistant/status"));
388
+ break;
389
+ }
390
+ case "launch": {
391
+ out(await apiPost(base, "/assistant/launch"));
392
+ break;
393
+ }
394
+ case "stop": {
395
+ out(await apiPost(base, "/assistant/stop"));
396
+ break;
397
+ }
398
+ case "config": {
399
+ const action = args[1];
400
+ if (action === "set") {
401
+ const flags = parseFlags(args.slice(2));
402
+ const body: Record<string, unknown> = {};
403
+ if (flags.model) body.model = flags.model;
404
+ if (flags["permission-mode"]) body.permissionMode = flags["permission-mode"];
405
+ if (flags.enabled !== undefined) body.enabled = flags.enabled === true || flags.enabled === "true";
406
+ out(await apiPut(base, "/assistant/config", body));
407
+ } else {
408
+ out(await apiGet(base, "/assistant/config"));
409
+ }
410
+ break;
411
+ }
412
+ default:
413
+ err(`Unknown assistant subcommand: ${sub}. Available: status, launch, stop, config`);
414
+ }
415
+ }
416
+
417
+ async function handleSkills(base: string, args: string[]): Promise<void> {
418
+ const sub = args[0];
419
+ const rest = args.slice(1);
420
+
421
+ switch (sub) {
422
+ case "list": {
423
+ out(await apiGet(base, "/skills"));
424
+ break;
425
+ }
426
+ case "get": {
427
+ const slug = rest[0];
428
+ if (!slug) err("Usage: companion skills get <slug>");
429
+ out(await apiGet(base, `/skills/${encodeURIComponent(slug)}`));
430
+ break;
431
+ }
432
+ case "create": {
433
+ const flags = parseFlags(rest);
434
+ if (!flags.name) err("Usage: companion skills create --name <name> [--description <desc>] [--content <markdown>]");
435
+ const body: Record<string, unknown> = { name: flags.name };
436
+ if (flags.description) body.description = flags.description;
437
+ if (flags.content) body.content = flags.content;
438
+ out(await apiPost(base, "/skills", body));
439
+ break;
440
+ }
441
+ case "update": {
442
+ const slug = rest[0];
443
+ if (!slug) err("Usage: companion skills update <slug> --content <markdown>");
444
+ const flags = parseFlags(rest.slice(1));
445
+ if (!flags.content) err("Usage: companion skills update <slug> --content <full SKILL.md content>");
446
+ out(await apiPut(base, `/skills/${encodeURIComponent(slug)}`, { content: flags.content }));
447
+ break;
448
+ }
449
+ case "delete": {
450
+ const slug = rest[0];
451
+ if (!slug) err("Usage: companion skills delete <slug>");
452
+ out(await apiDelete(base, `/skills/${encodeURIComponent(slug)}`));
453
+ break;
454
+ }
455
+ default:
456
+ err(`Unknown skills subcommand: ${sub}. Available: list, get, create, update, delete`);
457
+ }
458
+ }
459
+
460
+ // ─── Main dispatch ──────────────────────────────────────────────────────────
461
+
462
+ function printCtlUsage(): void {
463
+ console.log(`
464
+ Management commands:
465
+
466
+ companion status Overall Companion status
467
+ companion sessions <subcommand> Manage sessions
468
+ companion envs <subcommand> Manage environment profiles
469
+ companion cron <subcommand> Manage scheduled jobs
470
+ companion skills <subcommand> Manage Claude Code skills
471
+ companion settings <subcommand> Manage settings
472
+ companion assistant <subcommand> Manage the Companion Assistant
473
+
474
+ Global options:
475
+ --port <n> Override the Companion API port (default: 3456, or COMPANION_PORT env)
476
+
477
+ Run 'companion <command>' without subcommand for available subcommands.
478
+ `);
479
+ }
480
+
481
+ export async function handleCtlCommand(command: string, rawArgv: string[]): Promise<void> {
482
+ const argv = stripGlobalFlags(rawArgv);
483
+ const base = getBase(rawArgv);
484
+
485
+ try {
486
+ switch (command) {
487
+ case "status":
488
+ await handleStatus(base);
489
+ break;
490
+ case "sessions":
491
+ if (argv.length === 0) err("Usage: companion sessions <list|get|create|kill|relaunch|archive|rename|send-message>");
492
+ await handleSessions(base, argv);
493
+ break;
494
+ case "envs":
495
+ if (argv.length === 0) err("Usage: companion envs <list|get|create|update|delete>");
496
+ await handleEnvs(base, argv);
497
+ break;
498
+ case "cron":
499
+ if (argv.length === 0) err("Usage: companion cron <list|get|create|update|delete|toggle|run|executions>");
500
+ await handleCron(base, argv);
501
+ break;
502
+ case "settings":
503
+ if (argv.length === 0) err("Usage: companion settings <get|set>");
504
+ await handleSettings(base, argv);
505
+ break;
506
+ case "skills":
507
+ if (argv.length === 0) err("Usage: companion skills <list|get|create|update|delete>");
508
+ await handleSkills(base, argv);
509
+ break;
510
+ case "assistant":
511
+ if (argv.length === 0) err("Usage: companion assistant <status|launch|stop|config>");
512
+ await handleAssistant(base, argv);
513
+ break;
514
+ case "ctl-help":
515
+ printCtlUsage();
516
+ break;
517
+ default:
518
+ err(`Unknown command: ${command}`);
519
+ }
520
+ } catch (e) {
521
+ const message = e instanceof Error ? e.message : String(e);
522
+ // Check if it's a connection error
523
+ if (message.includes("ECONNREFUSED") || message.includes("fetch failed")) {
524
+ err(`Cannot connect to The Companion at ${base}. Is the server running?`);
525
+ }
526
+ err(message);
527
+ }
528
+ }