Fork Horse repository, listed by its owner. Publisher snapshot.
packages/forkhorse-cli/src/cli.ts
Snapshot at 34e3175
1import { open, readFile, unlink } from "node:fs/promises";
2import type { FileHandle } from "node:fs/promises";
3import { setTimeout as delay } from "node:timers/promises";
4import { Api, CliError, endpoint, record, redact, slug } from "./safety.ts";
5import { packet, smallFile } from "./packet.ts";
6
7type IO = { out: (text: string) => void; err: (text: string) => void };
8type Options = Map<string, string | boolean>;
9const booleanOptions = new Set(["show-once", "follow", "help"]);
10const common = ["base-url", "config", "project", "help"];
11const commandOptions: Record<string, string[]> = {
12 "project create": ["source", "agents", "output", "show-once"],
13 "agent enroll": ["output", "show-once"],
14 "agent rotate": ["output", "show-once"],
15 "packet create": [],
16 state: [],
17 activity: ["after", "limit", "follow", "interval-ms"],
18 "connect claude": ["mcp-path"],
19 "connect codex": ["mcp-path"],
20 "demo seed": ["output", "show-once"],
21};
22
23function args(argv: string[]): { words: string[]; options: Options } {
24 const words: string[] = [];
25 const options: Options = new Map();
26 for (let i = 0; i < argv.length; i++) {
27 const arg = argv[i]!;
28 if (!arg.startsWith("--")) {
29 words.push(arg);
30 continue;
31 }
32 const key = arg.slice(2);
33 if (options.has(key)) throw new CliError("Duplicate option.");
34 if (booleanOptions.has(key)) options.set(key, true);
35 else {
36 const value = argv[++i];
37 if (!value || value.startsWith("--")) throw new CliError("Option requires a value.");
38 options.set(key, value);
39 }
40 }
41 return { words, options };
42}
43
44function option(options: Options, name: string): string | undefined {
45 const value = options.get(name);
46 return typeof value === "string" ? value : undefined;
47}
48
49function integer(value: string | undefined, fallback: number, min: number, max: number): number {
50 const result = value === undefined ? fallback : /^\d+$/.test(value) ? Number(value) : NaN;
51 if (!Number.isSafeInteger(result) || result < min || result > max)
52 throw new CliError("Numeric option is outside its allowed range.");
53 return result;
54}
55
56function exact(words: string[], count: number): void {
57 if (words.length !== count)
58 throw new CliError("Unexpected or missing command arguments; use --help.");
59}
60
61function shellQuote(text: string): string {
62 return "'" + text.replaceAll("'", "'\\''") + "'";
63}
64
65type Credential = { agent: string; apiToken: string };
66function credentials(
67 response: Record<string, unknown>,
68 expected: string[],
69 owner: string,
70): Credential[] {
71 const entries = Array.isArray(response.agents) ? response.agents : [response];
72 if (entries.length !== expected.length)
73 throw new CliError("Core returned invalid agent credentials; inspect state before retrying.");
74 const found: Credential[] = [];
75 for (const entry of entries) {
76 if (
77 !record(entry) ||
78 typeof entry.agent !== "string" ||
79 !expected.includes(entry.agent) ||
80 found.some((item) => item.agent === entry.agent) ||
81 typeof entry.apiToken !== "string" ||
82 !/^api_[a-f0-9]{64}$/.test(entry.apiToken) ||
83 entry.apiToken.includes(owner)
84 )
85 throw new CliError("Core returned invalid agent credentials; inspect state before retrying.");
86 found.push({ agent: entry.agent, apiToken: entry.apiToken });
87 }
88 return found;
89}
90
91class TokenSink {
92 private file?: FileHandle;
93 private path?: string;
94 private saved = false;
95
96 static async create(options: Options): Promise<TokenSink> {
97 const sink = new TokenSink();
98 const path = option(options, "output");
99 if (Boolean(path) === Boolean(options.has("show-once")))
100 throw new CliError(
101 "Issuing credentials requires exactly one of --output <new-file> or --show-once.",
102 );
103 if (path) {
104 // Reserve before making any write request. Exclusive creation refuses files and symlinks.
105 sink.file = await open(path, "wx", 0o600);
106 sink.path = path;
107 try {
108 await sink.file.chmod(0o600);
109 } catch (error) {
110 await sink.file.close();
111 await unlink(path);
112 throw error;
113 }
114 }
115 return sink;
116 }
117
118 async save(project: string, entries: Credential[], io: IO): Promise<void> {
119 const text = JSON.stringify({ project, agents: entries }, null, 2) + "\n";
120 if (this.file) {
121 await this.file.writeFile(text, "utf8");
122 await this.file.sync();
123 this.saved = true;
124 io.out("Agent credentials saved to the requested private file (mode 0600).\n");
125 } else {
126 this.saved = true;
127 io.out(text);
128 }
129 }
130
131 async close(): Promise<void> {
132 await this.file?.close();
133 // Preserve a file after a partial write for owner recovery; never silently discard tokens.
134 if (this.path && !this.saved) {
135 const handle = await open(this.path, "r");
136 const empty = (await handle.stat()).size === 0;
137 await handle.close();
138 if (empty) await unlink(this.path);
139 }
140 }
141}
142
143function help(name: string, command: string): string {
144 return `${name} owner CLI\n
145Usage: ${command} <command> [--base-url <https-origin> | --config <json-file>] [--project <id>]
146 project create <id> [--source <https-url>] --agents a,b,c (--output <new-file> | --show-once)
147 agent enroll|rotate <agent> (--output <new-file> | --show-once)
148 packet create <json-or-yaml-file>
149 state
150 activity [--after <sequence>] [--limit <1-100>] [--follow] [--interval-ms <250-60000>]
151 connect claude|codex <agent> [--mcp-path /mcp]
152 demo seed (--output <new-file> | --show-once)
153Config JSON: {"baseUrl":"<owner-origin>","project":"<id>","mcpPath":"/mcp"}
154API commands require FORKHORSE_OWNER_API_TOKEN in the environment. Never put it in config.
155Only --show-once permits stdout agent credentials. Demo content is synthetic; it performs real API writes.
156`;
157}
158
159export async function runCli(
160 argv: string[],
161 env: NodeJS.ProcessEnv,
162 io: IO,
163 signal: AbortSignal,
164): Promise<number> {
165 const owner = env.FORKHORSE_OWNER_API_TOKEN ?? "";
166 const originalIO = io;
167 const hideOwner = (text: string) => (owner ? text.split(owner).join("[REDACTED]") : text);
168 io = {
169 out: (text) => originalIO.out(hideOwner(text)),
170 err: (text) => originalIO.err(hideOwner(text)),
171 };
172 let sink: TokenSink | undefined;
173 const output = (value: unknown) => io.out(JSON.stringify(redact(value, owner), null, 2) + "\n");
174 try {
175 const brand: unknown = JSON.parse(
176 await readFile(new URL("../../../brand.json", import.meta.url), "utf8"),
177 );
178 if (!record(brand) || typeof brand.name !== "string" || typeof brand.slug !== "string")
179 throw new CliError("Brand configuration is invalid.");
180 const { words, options } = args(argv);
181 if (!words.length || options.has("help")) {
182 io.out(String(redact(help(brand.name, brand.slug), owner)));
183 return 0;
184 }
185 const command = ["state", "activity"].includes(words[0]!)
186 ? words[0]!
187 : words.slice(0, 2).join(" ");
188 const allowed = commandOptions[command];
189 if (!allowed || [...options.keys()].some((key) => ![...common, ...allowed].includes(key)))
190 throw new CliError("Unknown command or unsupported option; use --help.");
191 exact(
192 words,
193 command === "state" || command === "activity" ? 1 : command === "demo seed" ? 2 : 3,
194 );
195 let config: Record<string, unknown> = {};
196 const configPath = option(options, "config");
197 if (configPath) {
198 const parsed: unknown = JSON.parse(await smallFile(configPath));
199 if (
200 !record(parsed) ||
201 Object.keys(parsed).some((key) => !["baseUrl", "project", "mcpPath"].includes(key))
202 )
203 throw new CliError(
204 "Config permits only baseUrl, project and mcpPath; credentials belong in the environment.",
205 );
206 config = parsed;
207 }
208 const base = endpoint(option(options, "base-url") ?? config.baseUrl, true);
209 if (command.startsWith("connect ")) {
210 const agent = slug(words[2], 32);
211 const path = option(options, "mcp-path") ?? config.mcpPath ?? "/mcp";
212 if (
213 typeof path !== "string" ||
214 !/^\/[a-zA-Z0-9_-]+$/.test(path) ||
215 ["/projects", "/app", "/authorize", "/oauth"].includes(path)
216 )
217 throw new CliError("MCP path must be one nonreserved path segment.");
218 const url = new URL(path, base).href;
219 const name = `${slug(brand.slug)}_${agent.replaceAll("-", "_")}`;
220 if (command === "connect codex") {
221 // JSON quoting is not shell quoting. URLs are embedded as single-quoted shell words.
222 io.out(
223 String(
224 redact(
225 `codex mcp add ${name} --url ${shellQuote(url)} --bearer-token-env-var AGENT_CORE_API_TOKEN\n`,
226 owner,
227 ),
228 ),
229 );
230 } else {
231 // This is a literal env placeholder, never a response credential. Sanitize endpoint
232 // text separately so ordinary bearer redaction cannot erase the required placeholder.
233 io.out(
234 JSON.stringify(
235 {
236 mcpServers: {
237 [name]: {
238 type: "http",
239 url: redact(url, owner),
240 headers: { Authorization: "Bearer ${AGENT_CORE_API_TOKEN}" },
241 },
242 },
243 },
244 null,
245 2,
246 ) + "\n",
247 );
248 }
249 return 0;
250 }
251 if (!/^[^\s]{1,512}$/.test(owner))
252 throw new CliError("Set a valid FORKHORSE_OWNER_API_TOKEN in the environment.");
253 const configuredProject =
254 option(options, "project") ??
255 (typeof config.project === "string" ? config.project : undefined);
256 const project = slug(
257 command === "project create"
258 ? words[2]
259 : command === "demo seed"
260 ? (configuredProject ?? "demo")
261 : configuredProject,
262 );
263 const prefix = `/projects/${project}`;
264 const api = new Api(base, owner, signal);
265 if (command === "state") output(await api.request(`${prefix}/state`));
266 else if (command === "activity") {
267 let after = integer(option(options, "after"), 0, 0, Number.MAX_SAFE_INTEGER);
268 const limit = integer(option(options, "limit"), 100, 1, 100);
269 const interval = integer(option(options, "interval-ms"), 1000, 250, 60000);
270 do {
271 const response = await api.request(`${prefix}/activity?after=${after}&limit=${limit}`);
272 if (
273 !Array.isArray(response.activity) ||
274 response.activity.length > limit ||
275 !Number.isSafeInteger(response.nextAfter) ||
276 (response.nextAfter as number) < after ||
277 (response.activity.length > 0 && (response.nextAfter as number) <= after)
278 )
279 throw new CliError("Core returned an invalid activity cursor.");
280 output(response);
281 after = response.nextAfter as number;
282 if (!options.has("follow")) break;
283 if (response.activity.length < limit) await delay(interval, undefined, { signal });
284 } while (!signal.aborted);
285 } else if (command === "packet create") {
286 output(await api.request(`${prefix}/packets`, packet(await smallFile(words[2]!))));
287 } else if (command === "agent enroll" || command === "agent rotate") {
288 const agent = slug(words[2], 32);
289 sink = await TokenSink.create(options);
290 const response = await api.request(
291 command === "agent enroll" ? `${prefix}/agents` : `${prefix}/agents/${agent}/rotate`,
292 command === "agent enroll" ? { agent } : {},
293 );
294 await sink.save(project, credentials(response, [agent], owner), io);
295 output({ project, agent, operation: command });
296 } else {
297 const agents =
298 command === "demo seed"
299 ? ["claude", "codex", "cfos"]
300 : option(options, "agents")?.split(",");
301 if (!agents || !agents.length || agents.length > 50 || new Set(agents).size !== agents.length)
302 throw new CliError("Supply 1 to 50 unique agent IDs with --agents a,b,c.");
303 agents.forEach((agent) => slug(agent, 32));
304 const data: Record<string, unknown> = { id: project, agents };
305 const source = option(options, "source");
306 if (source) {
307 const url = endpoint(source);
308 if (url.protocol !== "https:")
309 throw new CliError("Source must be public HTTPS; core validates its host.");
310 data.sourceUrl = url.href;
311 }
312 sink = await TokenSink.create(options);
313 const response = await api.request("/projects", data);
314 await sink.save(project, credentials(response, agents, owner), io);
315 output(response.project);
316 if (command === "demo seed") {
317 io.out(
318 "Synthetic demo: overlapping packet paths intentionally conflict when different agents claim them. No claims or pushes were made.\n",
319 );
320 for (const [id, paths] of [
321 ["demo-ui", ["src/shared/**", "src/ui/**"]],
322 ["demo-api", ["src/shared/**", "src/api/**"]],
323 ["demo-docs", ["docs/**"]],
324 ] as const) {
325 output(
326 await api.request(`${prefix}/packets`, {
327 id,
328 title: `Synthetic demo: ${id}`,
329 goal: "Synthetic packet for offline/demo workflow exploration.",
330 paths,
331 acceptance: ["Demo fixture only; independently run the assigned checks."],
332 }),
333 );
334 }
335 }
336 }
337 return 0;
338 } catch (error) {
339 if (signal.aborted && argv[0] === "activity") return 0;
340 // Only our fixed messages are eligible for display; neither native errors nor API bodies.
341 io.err(
342 String(
343 redact(
344 error instanceof CliError
345 ? error.message
346 : "CLI operation failed; inspect state before retrying writes.",
347 owner,
348 ),
349 ) + "\n",
350 );
351 return 1;
352 } finally {
353 try {
354 await sink?.close();
355 } catch {
356 io.err("Credential file cleanup failed; inspect the requested file.\n");
357 }
358 }
359}
360