Fork Horse repository, listed by its owner. Publisher snapshot.

packages/forkhorse-cli/src/cli.ts

Snapshot at 34e3175

Containing directory

13,937 bytes · typescriptView raw
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