Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

gluecron.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

gluecron.tsBlame704 lines · 2 contributors
eae38d1Claude1#!/usr/bin/env bun
2/**
3 * Block G3 — `gluecron` CLI.
4 *
5 * A dependency-free Bun executable that talks to a Gluecron server using
6 * either the REST `/api/*` endpoints or the GraphQL endpoint at `/api/graphql`.
7 *
8 * gluecron login — store a PAT in ~/.gluecron/config.json
9 * gluecron whoami — print the logged-in user
10 * gluecron repo ls — list repos for the logged-in user
11 * gluecron repo show <owner/name> — pretty-print a repo
12 * gluecron repo create <name> — create a repo for the logged-in user
13 * gluecron issues ls <owner/name> — list open issues
14 * gluecron gql '<query>' — run a GraphQL query verbatim
15 *
16 * Build: bun build cli/gluecron.ts --compile --outfile gluecron
17 * Install: cp gluecron /usr/local/bin/
18 */
19
20import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
21import { homedir } from "node:os";
22import { join } from "node:path";
23
24const VERSION = "0.1.0";
25const DEFAULT_HOST = process.env.GLUECRON_HOST || "http://localhost:3000";
26const CONFIG_DIR = join(homedir(), ".gluecron");
27const CONFIG_FILE = join(CONFIG_DIR, "config.json");
28
29// ---------- Config ----------
30
31interface Config {
32 host: string;
33 token?: string;
34 username?: string;
f764c07Claude35 githubToken?: string;
36 defaultRepo?: string;
eae38d1Claude37}
38
39export function loadConfig(): Config {
40 try {
41 if (existsSync(CONFIG_FILE)) {
42 const raw = readFileSync(CONFIG_FILE, "utf8");
43 const parsed = JSON.parse(raw) as Partial<Config>;
44 return {
45 host: parsed.host || DEFAULT_HOST,
46 token: parsed.token,
47 username: parsed.username,
f764c07Claude48 githubToken: parsed.githubToken,
49 defaultRepo: parsed.defaultRepo,
eae38d1Claude50 };
51 }
52 } catch {
53 // fall through
54 }
55 return { host: DEFAULT_HOST };
56}
57
58export function saveConfig(cfg: Config) {
59 mkdirSync(CONFIG_DIR, { recursive: true });
60 writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), {
61 mode: 0o600,
62 });
63}
64
65// ---------- HTTP ----------
66
67export async function http(
68 cfg: Config,
69 method: string,
70 path: string,
71 body?: unknown
72): Promise<any> {
73 const url = cfg.host.replace(/\/+$/, "") + path;
74 const headers: Record<string, string> = {
75 "content-type": "application/json",
76 accept: "application/json",
77 };
78 if (cfg.token) headers.authorization = `Bearer ${cfg.token}`;
79 const res = await fetch(url, {
80 method,
81 headers,
82 body: body ? JSON.stringify(body) : undefined,
83 });
84 const text = await res.text();
85 let json: any;
86 try {
87 json = text ? JSON.parse(text) : {};
88 } catch {
89 json = { _raw: text };
90 }
91 if (!res.ok) {
92 const msg = json?.error || res.statusText || "request failed";
93 throw new Error(`[${res.status}] ${msg}`);
94 }
95 return json;
96}
97
3b585e0Dictation App98// ---------- GraphQL Utilities ----------
99
100function sanitizeGraphQLString(input: string): string {
101 return input.replace(/["\\]/g, '\\$&');
102}
103
eae38d1Claude104// ---------- Commands ----------
105
106export const HELP = `gluecron CLI v${VERSION}
107
108Usage:
109 gluecron login Save a personal access token
110 gluecron whoami Print the logged-in user
111 gluecron repo ls [--user <name>] List repos
112 gluecron repo show <owner/name> Show a repo
113 gluecron repo create <name> [--private]
114 Create a repo
115 gluecron issues ls <owner/name> List open issues
116 gluecron gql '<query>' Run a GraphQL query
117 gluecron host [url] Get or set the server URL
f764c07Claude118 gluecron deploy [--repo owner/name] [--workflow id] [--ref branch] [--no-watch]
119 Trigger a Hetzner deploy via GitHub Actions
120 gluecron config set <key> <value> Set a config value (e.g. github-token)
eae38d1Claude121 gluecron version Print version
122 gluecron help Print this help
123
124Env:
f764c07Claude125 GLUECRON_HOST override the server URL (default: ${DEFAULT_HOST})
126 GLUECRON_GITHUB_TOKEN GitHub PAT (repo+workflow scopes) for \`deploy\`
eae38d1Claude127`;
128
129export async function cmdLogin(
130 cfg: Config,
131 prompt: (q: string) => Promise<string>
132): Promise<Config> {
133 const host =
134 (await prompt(`Server URL [${cfg.host}]: `)) || cfg.host;
135 const token = await prompt("Personal access token (glc_...): ");
136 if (!token) throw new Error("token is required");
137 const next: Config = { host, token };
138 // Probe /api/user/me to confirm
139 const me = await http(next, "GET", "/api/user/me").catch(() => null);
140 if (me?.username) next.username = me.username;
141 saveConfig(next);
142 return next;
143}
144
145export async function cmdWhoami(cfg: Config): Promise<string> {
146 if (!cfg.token) return "(not logged in)";
147 const me = await http(cfg, "GET", "/api/user/me").catch(() => null);
148 if (!me?.username) return cfg.username || "(unknown)";
149 return `${me.username} (${me.email || "no email"})`;
150}
151
152export async function cmdRepoLs(
153 cfg: Config,
154 user?: string
155): Promise<Array<{ owner: string; name: string; visibility: string }>> {
156 const username = user || cfg.username;
157 if (!username) throw new Error("no user context — log in or pass --user");
3b585e0Dictation App158 const q = `{ user(username:"${sanitizeGraphQLString(username)}") { repos { name visibility } } }`;
eae38d1Claude159 const r = await http(cfg, "POST", "/api/graphql", { query: q });
160 const repos = r?.data?.user?.repos || [];
161 return repos.map((x: any) => ({
162 owner: username,
163 name: x.name,
164 visibility: x.visibility,
165 }));
166}
167
168export async function cmdRepoShow(
169 cfg: Config,
170 slug: string
171): Promise<Record<string, any>> {
172 const [owner, name] = slug.split("/");
173 if (!owner || !name) throw new Error("expected owner/name");
3b585e0Dictation App174 const q = `{ repository(owner:"${sanitizeGraphQLString(owner)}", name:"${sanitizeGraphQLString(name)}") {
eae38d1Claude175 name description visibility starCount forkCount
176 owner { username }
177 issues(state:"open", limit:5) { number title }
178 } }`;
179 const r = await http(cfg, "POST", "/api/graphql", { query: q });
180 return r?.data?.repository || null;
181}
182
183export async function cmdRepoCreate(
184 cfg: Config,
185 name: string,
186 isPrivate = false
187): Promise<any> {
188 if (!cfg.username) throw new Error("log in first (gluecron login)");
189 return http(cfg, "POST", "/api/repos", {
190 name,
191 owner: cfg.username,
192 isPrivate,
193 });
194}
195
196export async function cmdIssuesLs(
197 cfg: Config,
198 slug: string
199): Promise<Array<{ number: number; title: string }>> {
200 const [owner, name] = slug.split("/");
3b585e0Dictation App201 const q = `{ repository(owner:"${sanitizeGraphQLString(owner)}", name:"${sanitizeGraphQLString(name)}") { issues(state:"open", limit:50) { number title } } }`;
eae38d1Claude202 const r = await http(cfg, "POST", "/api/graphql", { query: q });
203 return r?.data?.repository?.issues || [];
204}
205
206export async function cmdGql(cfg: Config, query: string): Promise<any> {
207 return http(cfg, "POST", "/api/graphql", { query });
208}
209
f764c07Claude210// ---------- Deploy (GitHub Actions workflow_dispatch) ----------
211
212export interface DeployArgs {
213 repo: string; // "owner/name"
214 workflow: string; // file name (e.g. "hetzner-deploy.yml") OR numeric id
215 ref: string; // "main"
216 githubToken: string;
217}
218
219export interface DeployDispatchResult {
220 runId: number;
221 runUrl: string;
222 htmlUrl: string;
223}
224
225export type FetchLike = (
226 url: string,
227 init?: { method?: string; headers?: Record<string, string>; body?: string }
228) => Promise<{
229 status: number;
230 ok: boolean;
231 text: () => Promise<string>;
232 json?: () => Promise<any>;
233}>;
234
235const GITHUB_API = "https://api.github.com";
236
237function ghHeaders(token: string): Record<string, string> {
238 return {
239 accept: "application/vnd.github+json",
240 authorization: `Bearer ${token}`,
241 "x-github-api-version": "2022-11-28",
242 "user-agent": "gluecron-cli",
243 };
244}
245
246async function parseBody(res: { text: () => Promise<string> }): Promise<any> {
247 const t = await res.text();
248 if (!t) return {};
249 try {
250 return JSON.parse(t);
251 } catch {
252 return { _raw: t };
253 }
254}
255
256function friendlyGhError(status: number, body: any): string {
257 const msg = (body && (body.message || body.error)) || "";
258 if (status === 401) {
259 return "GitHub auth failed (401). Check your token has `repo` and `workflow` scopes.";
260 }
261 if (status === 403) {
262 return `GitHub forbade the request (403). ${msg || "Token may lack `workflow` scope or you don't have write access."}`;
263 }
264 if (status === 404) {
265 return `Workflow or repo not found (404). ${msg || "Check --repo, --workflow, and that the token can see this repo."}`;
266 }
267 if (status === 422) {
268 return `GitHub rejected the dispatch (422). ${msg || "Branch may not exist, or the workflow has no workflow_dispatch trigger on that ref."}`;
269 }
270 return `GitHub error [${status}]: ${msg || "request failed"}`;
271}
272
273/**
274 * Trigger a `workflow_dispatch` on GitHub Actions and resolve the run id.
275 *
276 * Step 1: POST /repos/:o/:r/actions/workflows/:wf/dispatches (expects 204)
277 * Step 2: GET /repos/:o/:r/actions/workflows/:wf/runs?event=workflow_dispatch&branch=:ref
278 * and pick the newest run created within the last 60s.
279 */
280export async function triggerWorkflowDispatch(
281 args: DeployArgs,
282 opts: { fetchImpl?: FetchLike; now?: () => number } = {}
283): Promise<DeployDispatchResult> {
284 const f: FetchLike = opts.fetchImpl ?? (fetch as unknown as FetchLike);
285 const [owner, name] = args.repo.split("/");
286 if (!owner || !name) throw new Error("expected --repo owner/name");
287 if (!args.githubToken) {
288 throw new Error(
289 "no GitHub token — set GLUECRON_GITHUB_TOKEN or run `gluecron config set github-token <token>`"
290 );
291 }
292
293 const dispatchedAt = (opts.now ?? Date.now)();
294 const dispatchUrl = `${GITHUB_API}/repos/${owner}/${name}/actions/workflows/${encodeURIComponent(args.workflow)}/dispatches`;
295 const dispatchRes = await f(dispatchUrl, {
296 method: "POST",
297 headers: { ...ghHeaders(args.githubToken), "content-type": "application/json" },
298 body: JSON.stringify({ ref: args.ref }),
299 });
300 if (dispatchRes.status !== 204) {
301 const body = await parseBody(dispatchRes);
302 throw new Error(friendlyGhError(dispatchRes.status, body));
303 }
304
305 // GitHub does not return the run id on dispatch — query for the latest run.
306 const runsUrl =
307 `${GITHUB_API}/repos/${owner}/${name}/actions/workflows/${encodeURIComponent(args.workflow)}/runs` +
308 `?event=workflow_dispatch&branch=${encodeURIComponent(args.ref)}&per_page=5`;
309 // Try a handful of times — the run may take a moment to register.
310 let lastErr: string | null = null;
311 for (let i = 0; i < 6; i++) {
312 const r = await f(runsUrl, { method: "GET", headers: ghHeaders(args.githubToken) });
313 if (!r.ok) {
314 const body = await parseBody(r);
315 lastErr = friendlyGhError(r.status, body);
316 break;
317 }
318 const body = await parseBody(r);
319 const runs = Array.isArray(body?.workflow_runs) ? body.workflow_runs : [];
320 // Pick the newest run created at/after the dispatch moment (minus 5s slack).
321 const slack = dispatchedAt - 5_000;
322 const candidate = runs.find((rn: any) => {
323 const t = Date.parse(rn?.created_at || "");
324 return Number.isFinite(t) && t >= slack;
325 }) ?? runs[0];
326 if (candidate?.id) {
327 return {
328 runId: Number(candidate.id),
329 runUrl: candidate.url,
330 htmlUrl: candidate.html_url ||
331 `https://github.com/${owner}/${name}/actions/runs/${candidate.id}`,
332 };
333 }
334 // Wait a beat and retry; the test path injects a synchronous fetchImpl so
335 // this loop completes immediately when the run is registered on first try.
336 if (i < 5) await new Promise((res) => setTimeout(res, 1000));
337 }
338 throw new Error(lastErr || "workflow dispatched but could not locate the run id");
339}
340
341export interface DeployJobStep {
342 name: string;
343 status: string; // queued | in_progress | completed
344 conclusion: string | null;
345 startedAt: string | null;
346 completedAt: string | null;
347}
348
349export interface DeployRunStatus {
350 status: string; // queued | in_progress | completed
351 conclusion: string | null;
352 steps: DeployJobStep[];
353}
354
355export async function fetchRunStatus(
356 args: { repo: string; runId: number; githubToken: string },
357 opts: { fetchImpl?: FetchLike } = {}
358): Promise<DeployRunStatus> {
359 const f: FetchLike = opts.fetchImpl ?? (fetch as unknown as FetchLike);
360 const [owner, name] = args.repo.split("/");
361 const r = await f(
362 `${GITHUB_API}/repos/${owner}/${name}/actions/runs/${args.runId}/jobs`,
363 { method: "GET", headers: ghHeaders(args.githubToken) }
364 );
365 if (!r.ok) {
366 const body = await parseBody(r);
367 throw new Error(friendlyGhError(r.status, body));
368 }
369 const body = await parseBody(r);
370 const jobs = Array.isArray(body?.jobs) ? body.jobs : [];
371 // Flatten across jobs — for the hetzner-deploy workflow there's just one.
372 const steps: DeployJobStep[] = [];
373 let status = "queued";
374 let conclusion: string | null = null;
375 for (const j of jobs) {
376 status = j.status || status;
377 conclusion = j.conclusion ?? conclusion;
378 for (const s of j.steps || []) {
379 steps.push({
380 name: s.name,
381 status: s.status,
382 conclusion: s.conclusion ?? null,
383 startedAt: s.started_at ?? null,
384 completedAt: s.completed_at ?? null,
385 });
386 }
387 }
388 return { status, conclusion, steps };
389}
390
391function fmtClock(ms: number): string {
392 const total = Math.max(0, Math.floor(ms / 1000));
393 const m = Math.floor(total / 60);
394 const s = total % 60;
395 return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
396}
397
398function durationSec(a: string | null, b: string | null): number | null {
399 if (!a || !b) return null;
400 const ta = Date.parse(a);
401 const tb = Date.parse(b);
402 if (!Number.isFinite(ta) || !Number.isFinite(tb)) return null;
403 return Math.max(0, Math.round((tb - ta) / 1000));
404}
405
406export async function watchDeploy(
407 args: { repo: string; runId: number; githubToken: string; startedAt: number },
408 out: (msg: string) => void,
409 opts: {
410 fetchImpl?: FetchLike;
411 pollMs?: number;
412 maxPolls?: number;
413 sleep?: (ms: number) => Promise<void>;
414 now?: () => number;
415 } = {}
416): Promise<{ ok: boolean; conclusion: string | null }> {
417 const pollMs = opts.pollMs ?? 3_000;
418 const maxPolls = opts.maxPolls ?? 240; // ~12min default
419 const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
420 const now = opts.now ?? Date.now;
421 const seen = new Map<string, { startedLogged: boolean; completedLogged: boolean }>();
422
423 for (let i = 0; i < maxPolls; i++) {
424 const st = await fetchRunStatus(args, { fetchImpl: opts.fetchImpl });
425 for (const step of st.steps) {
426 // Skip GitHub's implicit "Set up job"/"Complete job" noise if you like;
427 // for now we surface everything.
428 const key = step.name;
429 const prev = seen.get(key) ?? { startedLogged: false, completedLogged: false };
430 const clock = fmtClock(now() - args.startedAt);
431 if (!prev.startedLogged && (step.status === "in_progress" || step.status === "completed")) {
432 out(` ${clock} ${step.name} (in progress)`);
433 prev.startedLogged = true;
434 }
435 if (!prev.completedLogged && step.status === "completed") {
436 const d = durationSec(step.startedAt, step.completedAt);
437 const tail = d != null ? `completed in ${d}s` : `completed`;
438 out(` ${clock} ${step.name} (${tail})`);
439 prev.completedLogged = true;
440 }
441 seen.set(key, prev);
442 }
443 if (st.status === "completed") {
444 return { ok: st.conclusion === "success", conclusion: st.conclusion };
445 }
446 await sleep(pollMs);
447 }
448 return { ok: false, conclusion: "timeout" };
449}
450
3b585e0Dictation App451// ---------- Command Handlers ----------
452
453async function handleHostCmd(cfg: Config, rest: string[], out: (msg: string) => void): Promise<number> {
454 if (rest[0]) {
455 cfg.host = rest[0];
456 saveConfig(cfg);
457 }
458 out(cfg.host);
459 return 0;
460}
461
462async function handleLoginCmd(cfg: Config, out: (msg: string) => void): Promise<number> {
463 const { default: readline } = await import("node:readline/promises");
464 const rl = readline.createInterface({
465 input: process.stdin,
466 output: process.stdout,
467 });
468 try {
469 const next = await cmdLogin(cfg, (q) => rl.question(q));
470 out(`Logged in as ${next.username || "(unknown)"}`);
471 return 0;
472 } finally {
473 rl.close();
474 }
475}
476
477async function handleRepoCmd(cfg: Config, rest: string[], out: (msg: string) => void): Promise<number> {
478 const sub = rest[0];
479 if (sub === "ls") {
480 return handleRepoLsCmd(cfg, rest, out);
481 }
482 if (sub === "show") {
483 return handleRepoShowCmd(cfg, rest, out);
484 }
485 if (sub === "create") {
486 return handleRepoCreateCmd(cfg, rest, out);
487 }
488 out("usage: gluecron repo (ls|show|create)");
489 return 1;
490}
491
492async function handleRepoLsCmd(cfg: Config, rest: string[], out: (msg: string) => void): Promise<number> {
493 const userFlagIdx = rest.indexOf("--user");
494 const user = userFlagIdx >= 0 && userFlagIdx + 1 < rest.length ? rest[userFlagIdx + 1] : undefined;
495 const repos = await cmdRepoLs(cfg, user);
496 for (const r of repos) {
497 out(` ${r.owner}/${r.name} · ${r.visibility}`);
498 }
499 return 0;
500}
501
502async function handleRepoShowCmd(cfg: Config, rest: string[], out: (msg: string) => void): Promise<number> {
503 const repo = await cmdRepoShow(cfg, rest[1]);
504 if (!repo) {
505 out("(not found)");
506 return 1;
507 }
508 out(JSON.stringify(repo, null, 2));
509 return 0;
510}
511
512async function handleRepoCreateCmd(cfg: Config, rest: string[], out: (msg: string) => void): Promise<number> {
513 const isPrivate = rest.includes("--private");
514 const name = rest.find((x, i) => i > 0 && !x.startsWith("--"));
515 if (!name) {
516 out("usage: gluecron repo create <name> [--private]");
517 return 1;
518 }
519 const r = await cmdRepoCreate(cfg, name, isPrivate);
520 out(JSON.stringify(r, null, 2));
521 return 0;
522}
523
524async function handleIssuesCmd(cfg: Config, rest: string[], out: (msg: string) => void): Promise<number> {
525 if (rest[0] !== "ls" || !rest[1]) {
526 out("usage: gluecron issues ls <owner/name>");
527 return 1;
528 }
529 const issues = await cmdIssuesLs(cfg, rest[1]);
530 for (const i of issues) {
531 out(` #${i.number} ${i.title}`);
532 }
533 return 0;
534}
535
536async function handleGqlCmd(cfg: Config, rest: string[], out: (msg: string) => void): Promise<number> {
537 if (!rest[0]) {
538 out("usage: gluecron gql '<query>'");
539 return 1;
540 }
541 const r = await cmdGql(cfg, rest.join(" "));
542 out(JSON.stringify(r, null, 2));
543 return 0;
544}
545
f764c07Claude546function readFlag(rest: string[], name: string): string | undefined {
547 const i = rest.indexOf(name);
548 if (i >= 0 && i + 1 < rest.length) return rest[i + 1];
549 return undefined;
550}
551
552export async function handleConfigCmd(
553 cfg: Config,
554 rest: string[],
555 out: (msg: string) => void
556): Promise<number> {
557 if (rest[0] !== "set" || !rest[1]) {
558 out("usage: gluecron config set <key> <value>");
559 return 1;
560 }
561 const key = rest[1];
562 const value = rest[2];
563 if (value === undefined) {
564 out("usage: gluecron config set <key> <value>");
565 return 1;
566 }
567 switch (key) {
568 case "github-token":
569 cfg.githubToken = value;
570 break;
571 case "default-repo":
572 cfg.defaultRepo = value;
573 break;
574 case "host":
575 cfg.host = value;
576 break;
577 case "token":
578 cfg.token = value;
579 break;
580 default:
581 out(`unknown config key: ${key}`);
582 return 1;
583 }
584 saveConfig(cfg);
585 out(`ok: ${key} saved`);
586 return 0;
587}
588
589export async function handleDeployCmd(
590 cfg: Config,
591 rest: string[],
592 out: (msg: string) => void,
593 opts: {
594 fetchImpl?: FetchLike;
595 sleep?: (ms: number) => Promise<void>;
596 pollMs?: number;
597 maxPolls?: number;
598 now?: () => number;
599 } = {}
600): Promise<number> {
601 const repo = readFlag(rest, "--repo") || cfg.defaultRepo || "ccantynz/Gluecron.com";
602 const workflow = readFlag(rest, "--workflow") || "hetzner-deploy.yml";
603 const ref = readFlag(rest, "--ref") || "main";
604 const noWatch = rest.includes("--no-watch");
605 const githubToken =
606 readFlag(rest, "--gh-token") ||
607 process.env.GLUECRON_GITHUB_TOKEN ||
608 cfg.githubToken ||
609 "";
610
611 if (!githubToken) {
612 out(
613 "error: no GitHub token — set GLUECRON_GITHUB_TOKEN or run `gluecron config set github-token <token>`"
614 );
615 return 1;
616 }
617
618 const startedAt = (opts.now ?? Date.now)();
619 out(`> Triggering ${workflow} on ${repo}@${ref}`);
620 let result: DeployDispatchResult;
621 try {
622 result = await triggerWorkflowDispatch(
623 { repo, workflow, ref, githubToken },
624 { fetchImpl: opts.fetchImpl, now: opts.now }
625 );
626 } catch (err) {
627 out(`error: ${(err as Error).message}`);
628 return 1;
629 }
630 out(`> Workflow run dispatched: ${result.htmlUrl}`);
631
632 if (noWatch) return 0;
633
634 out("> Watching deploy status...");
635 const watchResult = await watchDeploy(
636 { repo, runId: result.runId, githubToken, startedAt },
637 out,
638 {
639 fetchImpl: opts.fetchImpl,
640 sleep: opts.sleep,
641 pollMs: opts.pollMs,
642 maxPolls: opts.maxPolls,
643 now: opts.now,
644 }
645 );
646 const elapsed = Math.round(((opts.now ?? Date.now)() - startedAt) / 1000);
647 if (watchResult.ok) {
648 out(`> Deploy succeeded in ${elapsed}s.`);
649 return 0;
650 }
651 out(`! Deploy ${watchResult.conclusion || "failed"} after ${elapsed}s.`);
652 return 1;
653}
654
eae38d1Claude655// ---------- Dispatcher ----------
656
657export async function dispatch(argv: string[], out = console.log): Promise<number> {
658 const cfg = loadConfig();
659 const [cmd, ...rest] = argv;
660
661 if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
662 out(HELP);
663 return 0;
664 }
665 if (cmd === "version" || cmd === "--version" || cmd === "-v") {
666 out(VERSION);
667 return 0;
668 }
669
670 try {
671 switch (cmd) {
3b585e0Dictation App672 case "host":
673 return await handleHostCmd(cfg, rest, out);
674 case "login":
675 return await handleLoginCmd(cfg, out);
eae38d1Claude676 case "whoami":
677 out(await cmdWhoami(cfg));
678 return 0;
3b585e0Dictation App679 case "repo":
680 return await handleRepoCmd(cfg, rest, out);
681 case "issues":
682 return await handleIssuesCmd(cfg, rest, out);
683 case "gql":
684 return await handleGqlCmd(cfg, rest, out);
f764c07Claude685 case "deploy":
686 return await handleDeployCmd(cfg, rest, out);
687 case "config":
688 return await handleConfigCmd(cfg, rest, out);
eae38d1Claude689 default:
690 out(`unknown command: ${cmd}\n`);
691 out(HELP);
692 return 1;
693 }
694 } catch (err) {
695 out(`error: ${(err as Error).message}`);
696 return 1;
697 }
698}
699
700// Entry
701if (import.meta.main) {
702 const code = await dispatch(process.argv.slice(2));
703 process.exit(code);
3b585e0Dictation App704}