jeo-code 0.1.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 (93) hide show
  1. package/README.md +342 -0
  2. package/package.json +57 -0
  3. package/scripts/install.sh +322 -0
  4. package/scripts/uninstall.sh +30 -0
  5. package/src/agent/compaction.ts +75 -0
  6. package/src/agent/config-schema.ts +87 -0
  7. package/src/agent/context-files.ts +51 -0
  8. package/src/agent/engine.ts +208 -0
  9. package/src/agent/json.ts +87 -0
  10. package/src/agent/loop.ts +22 -0
  11. package/src/agent/session.ts +198 -0
  12. package/src/agent/state.ts +199 -0
  13. package/src/agent/subagents.ts +149 -0
  14. package/src/agent/tools.ts +355 -0
  15. package/src/ai/index.ts +11 -0
  16. package/src/ai/model-catalog-compat.ts +119 -0
  17. package/src/ai/model-catalog.ts +97 -0
  18. package/src/ai/model-discovery.ts +148 -0
  19. package/src/ai/model-enrich.ts +75 -0
  20. package/src/ai/model-manager.ts +178 -0
  21. package/src/ai/model-picker.ts +73 -0
  22. package/src/ai/model-registry.ts +83 -0
  23. package/src/ai/provider-status.ts +77 -0
  24. package/src/ai/providers/anthropic.ts +87 -0
  25. package/src/ai/providers/errors.ts +47 -0
  26. package/src/ai/providers/gemini.ts +77 -0
  27. package/src/ai/providers/ollama.ts +54 -0
  28. package/src/ai/providers/openai.ts +67 -0
  29. package/src/ai/sse.ts +46 -0
  30. package/src/ai/types.ts +37 -0
  31. package/src/auth/callback-server.ts +195 -0
  32. package/src/auth/flows/anthropic.ts +114 -0
  33. package/src/auth/flows/google.ts +120 -0
  34. package/src/auth/flows/index.ts +50 -0
  35. package/src/auth/flows/openai.ts +130 -0
  36. package/src/auth/index.ts +23 -0
  37. package/src/auth/oauth.ts +80 -0
  38. package/src/auth/pkce.ts +24 -0
  39. package/src/auth/refresh.ts +60 -0
  40. package/src/auth/storage.ts +113 -0
  41. package/src/auth/types.ts +26 -0
  42. package/src/cli/index.ts +1 -0
  43. package/src/cli/runner.ts +245 -0
  44. package/src/cli.ts +17 -0
  45. package/src/commands/approve.ts +63 -0
  46. package/src/commands/auth.ts +144 -0
  47. package/src/commands/chat.ts +37 -0
  48. package/src/commands/deep-interview.ts +239 -0
  49. package/src/commands/doctor.ts +250 -0
  50. package/src/commands/evolve.ts +191 -0
  51. package/src/commands/launch.ts +745 -0
  52. package/src/commands/mcp.ts +18 -0
  53. package/src/commands/models.ts +104 -0
  54. package/src/commands/ralplan.ts +86 -0
  55. package/src/commands/resume.ts +6 -0
  56. package/src/commands/setup-helpers.ts +93 -0
  57. package/src/commands/setup.ts +190 -0
  58. package/src/commands/skills.ts +38 -0
  59. package/src/commands/team.ts +337 -0
  60. package/src/commands/ultragoal.ts +102 -0
  61. package/src/index.ts +31 -0
  62. package/src/mcp/index.ts +3 -0
  63. package/src/mcp/protocol.ts +45 -0
  64. package/src/mcp/server.ts +97 -0
  65. package/src/mcp/tools.ts +156 -0
  66. package/src/skills/catalog.ts +61 -0
  67. package/src/tui/app.ts +297 -0
  68. package/src/tui/components/ascii-art.ts +340 -0
  69. package/src/tui/components/autocomplete.ts +165 -0
  70. package/src/tui/components/capability.ts +29 -0
  71. package/src/tui/components/code-view.ts +146 -0
  72. package/src/tui/components/color.ts +172 -0
  73. package/src/tui/components/config-panel.ts +193 -0
  74. package/src/tui/components/evolution.ts +305 -0
  75. package/src/tui/components/footer.ts +95 -0
  76. package/src/tui/components/forge.ts +167 -0
  77. package/src/tui/components/index.ts +7 -0
  78. package/src/tui/components/layout.ts +105 -0
  79. package/src/tui/components/meter.ts +61 -0
  80. package/src/tui/components/model-picker.ts +82 -0
  81. package/src/tui/components/provider-picker.ts +42 -0
  82. package/src/tui/components/select-list.ts +199 -0
  83. package/src/tui/components/slash.ts +34 -0
  84. package/src/tui/components/spinner.ts +49 -0
  85. package/src/tui/components/status.ts +45 -0
  86. package/src/tui/components/stream.ts +36 -0
  87. package/src/tui/components/themes.ts +86 -0
  88. package/src/tui/components/tool-list.ts +67 -0
  89. package/src/tui/index.ts +2 -0
  90. package/src/tui/renderer.ts +70 -0
  91. package/src/tui/terminal.ts +78 -0
  92. package/src/util/retry.ts +108 -0
  93. package/tsconfig.json +18 -0
@@ -0,0 +1,80 @@
1
+ import type { AuthProvider } from "./storage";
2
+ import { setOauthToken, clearOauthToken, setOauthCredential } from "./storage";
3
+ import { OAUTH_FLOW_REGISTRY } from "./flows";
4
+ import type { OAuthController } from "./types";
5
+
6
+ export interface OauthFlowDef {
7
+ label: string;
8
+ authorizeUrl: string;
9
+ instructions: string[];
10
+ }
11
+
12
+ /** Metadata kept for help text / manual-paste fallback. */
13
+ export const OAUTH_FLOWS: Record<AuthProvider, OauthFlowDef> = {
14
+ anthropic: {
15
+ label: "Anthropic Console (Claude)",
16
+ authorizeUrl: "https://claude.ai/oauth/authorize",
17
+ instructions: [
18
+ "Real PKCE OAuth: a browser tab opens at claude.ai and a local callback",
19
+ "server on http://localhost:54545/callback receives the code automatically.",
20
+ "If the browser cannot reach this machine, paste the redirect URL / code when prompted.",
21
+ ],
22
+ },
23
+ openai: {
24
+ label: "OpenAI (ChatGPT/Codex)",
25
+ authorizeUrl: "https://auth.openai.com/oauth/authorize",
26
+ instructions: [
27
+ "Real PKCE OAuth via auth.openai.com with a fixed callback on localhost:1455.",
28
+ "Note: the minted token targets the ChatGPT/Codex backend, not the Chat Completions API.",
29
+ ],
30
+ },
31
+ gemini: {
32
+ label: "Google (Gemini CLI)",
33
+ authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
34
+ instructions: [
35
+ "Real Google OAuth (authorization-code) with a callback on localhost:8085.",
36
+ "Note: the minted token targets Cloud Code Assist; the public Gemini API prefers an API key.",
37
+ ],
38
+ },
39
+ };
40
+
41
+ export async function openInBrowser(url: string): Promise<void> {
42
+ try {
43
+ const cmd =
44
+ process.platform === "darwin" ? ["open", url] :
45
+ process.platform === "win32" ? ["cmd", "/c", "start", "", url] :
46
+ ["xdg-open", url];
47
+ const proc = Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore" });
48
+ await proc.exited;
49
+ } catch {
50
+ // ignore — the URL is printed for manual opening
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Run the real interactive OAuth flow for a provider: open the browser, spin up
56
+ * the local callback server, wait for the code (or manual paste), exchange it,
57
+ * and persist the full credential set (access + refresh + expiry).
58
+ */
59
+ export async function interactiveLogin(provider: AuthProvider, ctrl: OAuthController): Promise<{ email?: string }> {
60
+ const flow = OAUTH_FLOW_REGISTRY[provider];
61
+ const creds = await flow.login(ctrl);
62
+ await setOauthCredential(provider, {
63
+ access: creds.access,
64
+ refresh: creds.refresh,
65
+ expires: creds.expires,
66
+ accountId: creds.accountId,
67
+ email: creds.email,
68
+ projectId: creds.projectId,
69
+ });
70
+ return { email: creds.email };
71
+ }
72
+
73
+ /** Non-interactive entry: stash a pre-acquired bearer (no refresh metadata). */
74
+ export async function loginOAuth(provider: AuthProvider, token: string): Promise<void> {
75
+ await setOauthToken(provider, token);
76
+ }
77
+
78
+ export async function logoutOAuth(provider: AuthProvider): Promise<boolean> {
79
+ return clearOauthToken(provider);
80
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Generate a PKCE code verifier + S256 challenge using the Web Crypto API.
3
+ * Mirrors gjc's `packages/ai/src/utils/oauth/pkce.ts`.
4
+ */
5
+ export async function generatePKCE(): Promise<{ verifier: string; challenge: string }> {
6
+ const verifierBytes = new Uint8Array(96);
7
+ crypto.getRandomValues(verifierBytes);
8
+ const verifier = Buffer.from(verifierBytes).toString("base64url");
9
+
10
+ const data = new TextEncoder().encode(verifier);
11
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
12
+ const challenge = Buffer.from(hashBuffer).toString("base64url");
13
+
14
+ return { verifier, challenge };
15
+ }
16
+
17
+ /** Random hex CSRF state token. */
18
+ export function generateState(): string {
19
+ const bytes = new Uint8Array(16);
20
+ crypto.getRandomValues(bytes);
21
+ return Array.from(bytes)
22
+ .map(b => b.toString(16).padStart(2, "0"))
23
+ .join("");
24
+ }
@@ -0,0 +1,60 @@
1
+ import {
2
+ getStoredOAuth,
3
+ setOauthCredential,
4
+ resolveCredential,
5
+ snapshotProvider,
6
+ type AuthProvider,
7
+ type Credential,
8
+ } from "./storage";
9
+ import { OAUTH_FLOW_REGISTRY } from "./flows";
10
+ import type { StoredOAuth } from "../agent/state";
11
+
12
+ export interface RefreshResult {
13
+ refreshed: boolean;
14
+ reason: string;
15
+ credential: Credential;
16
+ }
17
+
18
+ /**
19
+ * Exchange the stored refresh token for a fresh access token via the provider's
20
+ * real OAuth token endpoint, persist it, and return the updated credential.
21
+ * Mirrors gjc's auth-broker refresher semantics (single source of truth).
22
+ */
23
+ export async function refreshOAuthToken(provider: AuthProvider): Promise<RefreshResult> {
24
+ const stored = await getStoredOAuth(provider);
25
+ if (!stored) {
26
+ const snap = await snapshotProvider(provider);
27
+ const reason = snap.oauth ? "manual_token_no_refresh" : "no_oauth_token";
28
+ return { refreshed: false, reason, credential: await resolveCredential(provider) };
29
+ }
30
+ if (!stored.refresh) {
31
+ return {
32
+ refreshed: false,
33
+ reason: "no_refresh_token",
34
+ credential: { kind: "oauth", provider, token: stored.access },
35
+ };
36
+ }
37
+
38
+ const flow = OAUTH_FLOW_REGISTRY[provider];
39
+ const fresh = await flow.refresh(stored.refresh);
40
+ const next: StoredOAuth = {
41
+ access: fresh.access,
42
+ refresh: fresh.refresh || stored.refresh,
43
+ expires: fresh.expires,
44
+ accountId: fresh.accountId ?? stored.accountId,
45
+ email: fresh.email ?? stored.email,
46
+ projectId: fresh.projectId ?? stored.projectId,
47
+ };
48
+ await setOauthCredential(provider, next);
49
+ return {
50
+ refreshed: true,
51
+ reason: "refreshed",
52
+ credential: { kind: "oauth", provider, token: next.access },
53
+ };
54
+ }
55
+
56
+ /** Force-replace the stored OAuth token (used after a manual re-login). */
57
+ export async function rotateOAuthToken(provider: AuthProvider, newToken: string): Promise<void> {
58
+ const { setOauthToken } = await import("./storage");
59
+ await setOauthToken(provider, newToken);
60
+ }
@@ -0,0 +1,113 @@
1
+ import { readGlobalConfig, saveGlobalConfig, type Config, type StoredOAuth } from "../agent/state";
2
+
3
+ export type AuthProvider = "anthropic" | "openai" | "gemini";
4
+
5
+ export type Credential =
6
+ | { kind: "oauth"; provider: AuthProvider; token: string }
7
+ | { kind: "api_key"; provider: AuthProvider; token: string }
8
+ | { kind: "none"; provider: AuthProvider };
9
+
10
+ export interface AuthSnapshot {
11
+ apiKey: string | undefined;
12
+ oauth: string | undefined;
13
+ /** Present only when the stored OAuth is a refreshable {@link StoredOAuth}. */
14
+ oauthExpires?: number;
15
+ oauthHasRefresh?: boolean;
16
+ oauthEmail?: string;
17
+ }
18
+
19
+ const inFlightRefresh = new Map<AuthProvider, Promise<any>>();
20
+
21
+ function accessOf(stored: string | StoredOAuth | undefined): string | undefined {
22
+ if (!stored) return undefined;
23
+ return typeof stored === "string" ? stored : stored.access;
24
+ }
25
+
26
+ /** Single point of resolution: OAuth bearer beats API key when both exist. */
27
+ export async function resolveCredential(provider: AuthProvider): Promise<Credential> {
28
+ const cfg = await readGlobalConfig();
29
+ const stored = cfg.oauth?.[provider];
30
+
31
+ if (stored) {
32
+ // Auto-refresh refreshable credentials that are past their expiry.
33
+ if (typeof stored !== "string" && stored.refresh && stored.expires && stored.expires <= Date.now()) {
34
+ try {
35
+ let refreshPromise = inFlightRefresh.get(provider);
36
+ if (!refreshPromise) {
37
+ refreshPromise = (async () => {
38
+ const { refreshOAuthToken } = await import("./refresh");
39
+ return refreshOAuthToken(provider);
40
+ })();
41
+ inFlightRefresh.set(provider, refreshPromise);
42
+ refreshPromise.finally(() => {
43
+ inFlightRefresh.delete(provider);
44
+ });
45
+ }
46
+ const result = await refreshPromise;
47
+ if (result.refreshed && result.credential.kind === "oauth") {
48
+ return result.credential;
49
+ }
50
+ } catch {
51
+ // Fall through and use the (stale) access token; the provider call will surface a 401.
52
+ }
53
+ }
54
+ const token = accessOf(stored);
55
+ if (token) return { kind: "oauth", provider, token };
56
+ }
57
+
58
+ const apiKey = cfg.providers[provider];
59
+ if (apiKey) return { kind: "api_key", provider, token: apiKey };
60
+ return { kind: "none", provider };
61
+ }
62
+
63
+ export async function snapshotProvider(provider: AuthProvider): Promise<AuthSnapshot> {
64
+ const cfg = await readGlobalConfig();
65
+ const stored = cfg.oauth?.[provider];
66
+ return {
67
+ apiKey: cfg.providers[provider],
68
+ oauth: accessOf(stored),
69
+ oauthExpires: typeof stored === "object" ? stored.expires : undefined,
70
+ oauthHasRefresh: typeof stored === "object" ? !!stored.refresh : false,
71
+ oauthEmail: typeof stored === "object" ? stored.email : undefined,
72
+ };
73
+ }
74
+
75
+ /** Read the full stored OAuth record (object form only). */
76
+ export async function getStoredOAuth(provider: AuthProvider): Promise<StoredOAuth | undefined> {
77
+ const cfg = await readGlobalConfig();
78
+ const stored = cfg.oauth?.[provider];
79
+ return typeof stored === "object" ? stored : undefined;
80
+ }
81
+
82
+ /** Persist a plain bearer token (legacy / manual paste — no refresh metadata). */
83
+ export async function setOauthToken(provider: AuthProvider, token: string): Promise<void> {
84
+ const cfg = await readGlobalConfig();
85
+ const next: Config = JSON.parse(JSON.stringify(cfg));
86
+ next.oauth = next.oauth ?? {};
87
+ next.oauth[provider] = token;
88
+ await saveGlobalConfig(next);
89
+ }
90
+
91
+ /** Persist a full OAuth credential set (access + refresh + expiry). */
92
+ export async function setOauthCredential(provider: AuthProvider, cred: StoredOAuth): Promise<void> {
93
+ const cfg = await readGlobalConfig();
94
+ const next: Config = JSON.parse(JSON.stringify(cfg));
95
+ next.oauth = next.oauth ?? {};
96
+ next.oauth[provider] = cred;
97
+ await saveGlobalConfig(next);
98
+ }
99
+
100
+ export async function clearOauthToken(provider: AuthProvider): Promise<boolean> {
101
+ const cfg = await readGlobalConfig();
102
+ if (!cfg.oauth?.[provider]) return false;
103
+ delete cfg.oauth[provider];
104
+ await saveGlobalConfig(cfg);
105
+ return true;
106
+ }
107
+
108
+ export async function setApiKey(provider: AuthProvider, key: string): Promise<void> {
109
+ const cfg = await readGlobalConfig();
110
+ const next: Config = JSON.parse(JSON.stringify(cfg));
111
+ next.providers[provider] = key;
112
+ await saveGlobalConfig(next);
113
+ }
@@ -0,0 +1,26 @@
1
+ /** OAuth flow types, mirroring gjc's oauth/types.ts (trimmed to joc's surface). */
2
+
3
+ export interface OAuthCredentials {
4
+ access: string;
5
+ refresh: string;
6
+ /** Epoch ms when the access token should be treated as expired (already skew-adjusted). */
7
+ expires: number;
8
+ accountId?: string;
9
+ email?: string;
10
+ projectId?: string;
11
+ }
12
+
13
+ export interface OAuthAuthInfo {
14
+ url: string;
15
+ instructions?: string;
16
+ }
17
+
18
+ export interface OAuthController {
19
+ /** Invoked once the authorization URL is ready (open it / print it). */
20
+ onAuth?(info: OAuthAuthInfo): void;
21
+ /** Progress messages during the flow. */
22
+ onProgress?(message: string): void;
23
+ /** Optional manual paste fallback when the browser cannot reach the callback. */
24
+ onManualCodeInput?(): Promise<string>;
25
+ signal?: AbortSignal;
26
+ }
@@ -0,0 +1 @@
1
+ export * from "./runner";
@@ -0,0 +1,245 @@
1
+ export interface CommandSpec {
2
+ readonly name: string;
3
+ readonly summary: string;
4
+ readonly usage?: string;
5
+ readonly loader: () => Promise<(args: string[]) => Promise<void>>;
6
+ }
7
+
8
+ export const COMMANDS: readonly CommandSpec[] = [
9
+ {
10
+ name: "launch",
11
+ summary: "Interactive coding agent (chat + tools). Default when no subcommand is given.",
12
+ usage: "launch [\"one-shot request\"] [--resume [id]] [--list] [--tmux] [--worktree <path>]",
13
+ loader: async () => {
14
+ const m = await import("../commands/launch");
15
+ return args => m.runLaunchCommand(args);
16
+ },
17
+ },
18
+ {
19
+ name: "setup",
20
+ summary: "Configure LLM providers (API key / OAuth / local) + default model.",
21
+ loader: async () => {
22
+ const m = await import("../commands/setup");
23
+ return async () => m.runSetupCommand();
24
+ },
25
+ },
26
+ {
27
+ name: "auth",
28
+ summary: "Real OAuth (PKCE) login + token storage with auto-refresh.",
29
+ usage: "auth [login|logout|refresh|status] [provider] [--token <bearer>]",
30
+ loader: async () => {
31
+ const m = await import("../commands/auth");
32
+ return args => m.runAuthCommand(args);
33
+ },
34
+ },
35
+ {
36
+ name: "deep-interview",
37
+ summary: "Execute Socratic requirements interview (locks tools while ambiguity > 20%).",
38
+ usage: 'deep-interview "<initial idea>"',
39
+ loader: async () => {
40
+ const m = await import("../commands/deep-interview");
41
+ return args => m.runDeepInterviewCommand(args);
42
+ },
43
+ },
44
+ {
45
+ name: "ralplan",
46
+ summary: "Create planning blueprint (Planner/Architect/Critic).",
47
+ loader: async () => {
48
+ const m = await import("../commands/ralplan");
49
+ return async () => m.runRalplanCommand();
50
+ },
51
+ },
52
+ {
53
+ name: "approve",
54
+ summary: "Approve a planning blueprint.",
55
+ usage: "approve <plan-path>",
56
+ loader: async () => {
57
+ const m = await import("../commands/approve");
58
+ return args => m.runApproveCommand(args);
59
+ },
60
+ },
61
+ {
62
+ name: "team",
63
+ summary: "Execute the planning blueprint (Executor subagent tools).",
64
+ loader: async () => {
65
+ const m = await import("../commands/team");
66
+ return async () => m.runTeamCommand();
67
+ },
68
+ },
69
+ {
70
+ name: "ultragoal",
71
+ summary: "Verify goals and run acceptance checks.",
72
+ loader: async () => {
73
+ const m = await import("../commands/ultragoal");
74
+ return async () => m.runUltragoalCommand();
75
+ },
76
+ },
77
+ {
78
+ name: "doctor",
79
+ summary: "Probe provider connectivity + credentials. Reports if default model is reachable.",
80
+ loader: async () => {
81
+ const m = await import("../commands/doctor");
82
+ return args => m.runDoctorCommand(args);
83
+ },
84
+ },
85
+ {
86
+ name: "mcp",
87
+ summary: "Run joc as an MCP stdio server (subcommand: serve|tools).",
88
+ usage: "mcp [serve|tools]",
89
+ loader: async () => {
90
+ const m = await import("../commands/mcp");
91
+ return args => m.runMcpCommand(args);
92
+ },
93
+ },
94
+ {
95
+ name: "models",
96
+ summary: "List model aliases + probe local/compatible models.",
97
+ loader: async () => {
98
+ const m = await import("../commands/models");
99
+ return args => m.runModelsCommand(args);
100
+ },
101
+ },
102
+ {
103
+ name: "skills",
104
+ summary: "List bundled workflow skills (joc skills <name> for details).",
105
+ usage: "skills [name]",
106
+ loader: async () => {
107
+ const m = await import("../commands/skills");
108
+ return args => m.runSkillsCommand(args);
109
+ },
110
+ },
111
+ {
112
+ name: "resume",
113
+ summary: "Resume the latest interactive session (or 'joc resume <id>').",
114
+ usage: "resume [id]",
115
+ loader: async () => {
116
+ const m = await import("../commands/resume");
117
+ return args => m.runResumeCommand(args);
118
+ },
119
+ },
120
+ {
121
+ name: "chat",
122
+ summary: "Single-shot streaming chat (no tools) — renders the reply token-by-token.",
123
+ usage: "chat \"<message>\"",
124
+ loader: async () => {
125
+ const m = await import("../commands/chat");
126
+ return args => m.runChatCommand(args);
127
+ },
128
+ },
129
+ {
130
+ name: "evolve",
131
+ summary: "Preview the evolution TUI identity (ASCII art + track + meter per stage).",
132
+ usage: "evolve [--step N] [--max M] [--animate] [--no-color]",
133
+ loader: async () => {
134
+ const m = await import("../commands/evolve");
135
+ return args => m.runEvolveCommand(args);
136
+ },
137
+ },
138
+ ];
139
+
140
+ export function findCommand(name: string): CommandSpec | undefined {
141
+ return COMMANDS.find(c => c.name === name);
142
+ }
143
+
144
+ /** Levenshtein edit distance (small inputs; iterative two-row DP). */
145
+ function editDistance(a: string, b: string): number {
146
+ const m = a.length, n = b.length;
147
+ if (m === 0) return n;
148
+ if (n === 0) return m;
149
+ let prev = Array.from({ length: n + 1 }, (_, i) => i);
150
+ let cur = new Array<number>(n + 1);
151
+ for (let i = 1; i <= m; i++) {
152
+ cur[0] = i;
153
+ for (let j = 1; j <= n; j++) {
154
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
155
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
156
+ }
157
+ [prev, cur] = [cur, prev];
158
+ }
159
+ return prev[n];
160
+ }
161
+
162
+ /** Suggest near-miss command names for an unknown input (prefix match or ≤2 edits). */
163
+ export function suggestCommands(name: string): string[] {
164
+ const q = name.toLowerCase();
165
+ if (!q) return [];
166
+ return COMMANDS.map(c => c.name).filter(n => n.startsWith(q) || editDistance(n, q) <= 2);
167
+ }
168
+
169
+ export interface DispatchContext {
170
+ appName: string;
171
+ version: string;
172
+ }
173
+
174
+ export function renderHelp(ctx: DispatchContext): string {
175
+ const lines: string[] = [];
176
+ lines.push("");
177
+ lines.push(`=== @jeo-code CLI (${ctx.appName}) ===`);
178
+ lines.push("Clean, highly optimized AI coding agent using a Socratic spec-first loop.");
179
+ lines.push("");
180
+ lines.push("Usage:");
181
+ lines.push(` ${ctx.appName} <command> [arguments]`);
182
+ lines.push("");
183
+ lines.push("Commands:");
184
+ const width = Math.max(...COMMANDS.map(c => (c.usage ?? c.name).length));
185
+ for (const c of COMMANDS) {
186
+ const label = (c.usage ?? c.name).padEnd(width);
187
+ lines.push(` ${label} ${c.summary}`);
188
+ }
189
+ lines.push("");
190
+ lines.push("Options:");
191
+ lines.push(" -v, --version Show version.");
192
+ lines.push(" -h, --help Show help.");
193
+ lines.push("");
194
+ return lines.join("\n");
195
+ }
196
+
197
+ export function renderCommandHelp(spec: CommandSpec, ctx: DispatchContext): string {
198
+ return [
199
+ "",
200
+ `Usage: ${ctx.appName} ${spec.usage ?? spec.name}`,
201
+ "",
202
+ spec.summary,
203
+ "",
204
+ ].join("\n");
205
+ }
206
+
207
+ export async function dispatch(argv: string[], ctx: DispatchContext): Promise<number> {
208
+ const first = argv[0];
209
+
210
+ if (first === "--version" || first === "-v") {
211
+ console.log(`${ctx.appName} v${ctx.version}`);
212
+ return 0;
213
+ }
214
+ if (first === "--help" || first === "-h") {
215
+ console.log(renderHelp(ctx));
216
+ return 0;
217
+ }
218
+ // Bare invocation or a leading global flag (e.g. `joc`, `joc --tmux`,
219
+ // `joc --tmux --worktree <path>`) routes to the interactive agent — gjc parity.
220
+ if (!first || first.startsWith("-")) {
221
+ const run = await findCommand("launch")!.loader();
222
+ await run(argv);
223
+ return 0;
224
+ }
225
+
226
+ const spec = findCommand(first);
227
+ if (!spec) {
228
+ console.log(`Unknown command: ${first}`);
229
+ const near = suggestCommands(first);
230
+ if (near.length) console.log(`Did you mean: ${near.join(", ")}?`);
231
+ console.log(renderHelp(ctx));
232
+ return 1;
233
+ }
234
+
235
+ // Per-command help: `joc <cmd> --help`.
236
+ const rest = argv.slice(1);
237
+ if (rest.includes("--help") || rest.includes("-h")) {
238
+ console.log(renderCommandHelp(spec, ctx));
239
+ return 0;
240
+ }
241
+
242
+ const run = await spec.loader();
243
+ await run(rest);
244
+ return 0;
245
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env bun
2
+ import { dispatch } from "./cli/runner";
3
+
4
+ const APP_NAME = "joc";
5
+ const VERSION = "0.1.0";
6
+ const MIN_BUN_VERSION = "1.3.14";
7
+
8
+ if (typeof Bun !== "undefined" && Bun.semver?.order(Bun.version, MIN_BUN_VERSION) < 0) {
9
+ process.stderr.write(
10
+ `error: Bun >= ${MIN_BUN_VERSION} required (found v${Bun.version}). Upgrade: bun upgrade\n`,
11
+ );
12
+ process.exit(1);
13
+ }
14
+ process.title = APP_NAME;
15
+
16
+ const code = await dispatch(process.argv.slice(2), { appName: APP_NAME, version: VERSION });
17
+ if (code !== 0) process.exit(code);
@@ -0,0 +1,63 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import {
4
+ readWorkflowState,
5
+ writeWorkflowState,
6
+ } from "../agent/state";
7
+
8
+ export async function runApproveCommand(args: string[] = []): Promise<void> {
9
+ const cwd = process.cwd();
10
+ const planPathInput = args[0];
11
+
12
+ if (!planPathInput) {
13
+ console.log("[ERROR] Plan path argument is required.");
14
+ process.exitCode = 1;
15
+ return;
16
+ }
17
+
18
+ const resolvedInputPath = path.resolve(cwd, planPathInput);
19
+
20
+ // Rejection if the plan file doesn't exist on disk
21
+ try {
22
+ await fs.access(resolvedInputPath);
23
+ } catch {
24
+ console.log(`[ERROR] Plan file not found: ${resolvedInputPath}`);
25
+ process.exitCode = 1;
26
+ return;
27
+ }
28
+
29
+ // Read ralplan state
30
+ const ralplanState = await readWorkflowState("ralplan", cwd);
31
+ if (!ralplanState) {
32
+ console.log(`[ERROR] No ralplan workflow state found. Please run 'joc ralplan' first.`);
33
+ process.exitCode = 1;
34
+ return;
35
+ }
36
+
37
+ if (!ralplanState.plan_path) {
38
+ console.log(`[ERROR] No plan path associated with the current ralplan state.`);
39
+ process.exitCode = 1;
40
+ return;
41
+ }
42
+
43
+ const resolvedStatePath = path.resolve(cwd, ralplanState.plan_path);
44
+ if (resolvedStatePath !== resolvedInputPath) {
45
+ console.log(
46
+ `[ERROR] Provided plan path does not match the active plan in the ralplan state.`
47
+ );
48
+ process.exitCode = 1;
49
+ return;
50
+ }
51
+
52
+ // Idempotency: check if already approved
53
+ if (ralplanState.approved) {
54
+ console.log(`[SUCCESS] Plan is already approved.`);
55
+ return;
56
+ }
57
+
58
+ // Update ralplan-state.json to approved: true
59
+ ralplanState.approved = true;
60
+ await writeWorkflowState("ralplan", ralplanState, cwd);
61
+
62
+ console.log(`[SUCCESS] Plan approved successfully.`);
63
+ }