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,144 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { readGlobalConfig } from "../agent/state";
3
+ import {
4
+ OAUTH_FLOWS,
5
+ OAUTH_FLOW_REGISTRY,
6
+ openInBrowser,
7
+ interactiveLogin,
8
+ loginOAuth,
9
+ logoutOAuth,
10
+ refreshOAuthToken,
11
+ snapshotProvider,
12
+ type AuthProvider,
13
+ type OAuthController,
14
+ } from "../auth";
15
+
16
+ export async function runAuthCommand(args: string[]): Promise<void> {
17
+ const sub = args[0];
18
+ if (!sub || sub === "status") return runAuthStatus();
19
+ if (sub === "login") return runAuthLogin(args.slice(1));
20
+ if (sub === "logout") return runAuthLogout(args[1] as AuthProvider | undefined);
21
+ if (sub === "refresh") return runAuthRefresh(args[1] as AuthProvider | undefined);
22
+ console.log(`Unknown auth subcommand: ${sub}\nUsage: joc auth [login|logout|refresh|status] [provider] [--token <bearer>]`);
23
+ process.exitCode = 1;
24
+ }
25
+
26
+ function fmtExpiry(expires?: number): string {
27
+ if (!expires) return "";
28
+ const ms = expires - Date.now();
29
+ if (ms <= 0) return " (expired — will auto-refresh)";
30
+ const mins = Math.round(ms / 60000);
31
+ if (mins < 60) return ` (expires in ${mins}m)`;
32
+ return ` (expires in ${Math.round(mins / 60)}h)`;
33
+ }
34
+
35
+ async function runAuthStatus(): Promise<void> {
36
+ const cfg = await readGlobalConfig();
37
+ console.log("\n=== joc auth status ===");
38
+ console.log("Provider API key OAuth");
39
+ for (const p of ["anthropic", "openai", "gemini"] as AuthProvider[]) {
40
+ const snap = await snapshotProvider(p);
41
+ const key = snap.apiKey ? "set" : "—";
42
+ let oauth = "—";
43
+ if (snap.oauth) {
44
+ oauth = snap.oauthHasRefresh ? "set (refreshable)" : "set (manual)";
45
+ oauth += fmtExpiry(snap.oauthExpires);
46
+ if (snap.oauthEmail) oauth += ` <${snap.oauthEmail}>`;
47
+ }
48
+ console.log(` ${p.padEnd(11)} ${key.padEnd(9)} ${oauth}`);
49
+ }
50
+ console.log(`\nDefault model: ${cfg.defaultModel}`);
51
+ console.log(`Ollama base: ${cfg.ollamaBaseUrl ?? "—"}`);
52
+ console.log(`OpenAI base: ${cfg.openaiBaseUrl ?? "(api.openai.com/v1)"}`);
53
+ }
54
+
55
+ async function runAuthLogin(rest: string[]): Promise<void> {
56
+ const tokenIdx = rest.indexOf("--token");
57
+ const manualToken = tokenIdx >= 0 ? rest[tokenIdx + 1] : undefined;
58
+ const provider = rest.find((a, i) => a !== "--token" && rest[i - 1] !== "--token") as AuthProvider | undefined;
59
+
60
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
61
+ const chosen = provider ?? (await selectProvider(rl));
62
+ if (!chosen) {
63
+ rl.close();
64
+ return;
65
+ }
66
+
67
+ // Non-interactive paste path (`--token`): store as a manual bearer.
68
+ if (manualToken) {
69
+ rl.close();
70
+ await loginOAuth(chosen, manualToken.trim());
71
+ console.log(`[SUCCESS] Stored manual OAuth bearer for ${chosen} (no auto-refresh).`);
72
+ return;
73
+ }
74
+
75
+ const flow = OAUTH_FLOW_REGISTRY[chosen];
76
+ console.log(`\n=== joc auth login — ${flow.label} ===`);
77
+ if (!flow.verifiedEndToEnd && flow.note) console.log(`Note: ${flow.note}`);
78
+ for (const line of OAUTH_FLOWS[chosen].instructions) console.log(" " + line);
79
+ console.log("");
80
+
81
+ const ctrl: OAuthController = {
82
+ onAuth: ({ url, instructions }) => {
83
+ console.log(`Opening browser:\n ${url}\n`);
84
+ if (instructions) console.log(instructions + "\n");
85
+ void openInBrowser(url);
86
+ },
87
+ onProgress: msg => console.log(` … ${msg}`),
88
+ onManualCodeInput: async () =>
89
+ (await rl.question("Paste redirect URL or code (or wait for the browser callback): ")).trim(),
90
+ };
91
+
92
+ try {
93
+ const { email } = await interactiveLogin(chosen, ctrl);
94
+ console.log(`\n[SUCCESS] OAuth login complete for ${chosen}${email ? ` (${email})` : ""}.`);
95
+ console.log("Stored access + refresh tokens in ~/.joc/config.json; joc will auto-refresh on expiry.");
96
+ } catch (err) {
97
+ console.log(`\n[FAILED] ${(err as Error).message}`);
98
+ console.log("Tip: paste the redirect URL when prompted, or use 'joc auth login <provider> --token <bearer>'.");
99
+ process.exitCode = 1;
100
+ } finally {
101
+ rl.close();
102
+ }
103
+ }
104
+
105
+ async function runAuthLogout(provider?: AuthProvider): Promise<void> {
106
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
107
+ const chosen = provider ?? (await selectProvider(rl, "logout"));
108
+ rl.close();
109
+ if (!chosen) return;
110
+ const removed = await logoutOAuth(chosen);
111
+ console.log(removed ? `[SUCCESS] Removed OAuth token for ${chosen}.` : `No OAuth token stored for ${chosen}.`);
112
+ }
113
+
114
+ async function runAuthRefresh(provider?: AuthProvider): Promise<void> {
115
+ if (!provider) {
116
+ console.log("Usage: joc auth refresh <provider>");
117
+ process.exitCode = 1;
118
+ return;
119
+ }
120
+ const result = await refreshOAuthToken(provider);
121
+ console.log(
122
+ result.refreshed
123
+ ? `[SUCCESS] Refreshed ${provider} OAuth token.`
124
+ : `[SKIP] ${provider}: ${result.reason}.`
125
+ );
126
+ }
127
+
128
+ async function selectProvider(
129
+ rl: ReturnType<typeof createInterface>,
130
+ action = "login"
131
+ ): Promise<AuthProvider | null> {
132
+ console.log(`\nWhich provider do you want to ${action}?`);
133
+ console.log(" 1) anthropic (real PKCE OAuth, verified end-to-end)");
134
+ console.log(" 2) openai (real PKCE OAuth, Codex backend)");
135
+ console.log(" 3) gemini (real Google OAuth, Cloud Code Assist)");
136
+ const ans = (await rl.question("Choose [1-3]: ")).trim();
137
+ const map: Record<string, AuthProvider> = { "1": "anthropic", "2": "openai", "3": "gemini" };
138
+ const p = map[ans];
139
+ if (!p) {
140
+ console.log("Invalid choice.");
141
+ return null;
142
+ }
143
+ return p;
144
+ }
@@ -0,0 +1,37 @@
1
+ import { createModelManager } from "../ai/model-manager";
2
+
3
+ /**
4
+ * `joc chat "<message>"` — a quick, single-shot streaming chat (no tools).
5
+ * Renders the reply token-by-token via the provider streaming path
6
+ * (`ModelManager.stream`), complementary to the tool-loop `joc launch`.
7
+ */
8
+ export async function runChatCommand(args: string[] = []): Promise<void> {
9
+ let message = args.join(" ").trim();
10
+ if (!message && !process.stdin.isTTY) message = (await Bun.stdin.text()).trim();
11
+ if (!message) {
12
+ console.log('Usage: joc chat "<message>" (streams the reply token-by-token)');
13
+ process.exitCode = 1;
14
+ return;
15
+ }
16
+
17
+ const manager = createModelManager();
18
+ process.stdout.write("joc> ");
19
+ let any = false;
20
+ let usage: { inputTokens?: number; outputTokens?: number; durationMs?: number } | undefined;
21
+ try {
22
+ for await (const chunk of manager.stream([{ role: "user", content: message }], { onUsage: u => { usage = u; } })) {
23
+ process.stdout.write(chunk);
24
+ any = true;
25
+ }
26
+ } catch (err) {
27
+ process.stdout.write("\n");
28
+ console.log(`! ${(err as Error).message}`);
29
+ process.exitCode = 1;
30
+ return;
31
+ }
32
+ process.stdout.write(any ? "\n" : "(no output)\n");
33
+ if (usage && (usage.inputTokens != null || usage.outputTokens != null)) {
34
+ const tps = usage.outputTokens && usage.durationMs ? ` · ${Math.round((usage.outputTokens / usage.durationMs) * 1000)} tok/s` : "";
35
+ console.log(`(${usage.inputTokens ?? "?"} in / ${usage.outputTokens ?? "?"} out tokens${tps})`);
36
+ }
37
+ }
@@ -0,0 +1,239 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { createInterface } from "node:readline/promises";
4
+ import { callLlm, type Message } from "../agent/loop";
5
+ import { extractJsonObject } from "../agent/json";
6
+ import { meter } from "../tui/components/meter";
7
+ import {
8
+ readWorkflowState,
9
+ writeWorkflowState,
10
+ clearWorkflowState,
11
+ type WorkflowState,
12
+ getLocalJocDir,
13
+ } from "../agent/state";
14
+
15
+ interface SocraticResponse {
16
+ ambiguityScore: number;
17
+ assessment: string;
18
+ nextQuestion: string;
19
+ goal?: string;
20
+ constraints?: string[];
21
+ acceptance_criteria?: string[];
22
+ }
23
+
24
+ export async function runDeepInterviewCommand(args: string[]): Promise<void> {
25
+ const auto = args.includes("--auto") || !process.stdin.isTTY;
26
+ const filteredArgs = args.filter(arg => arg !== "--auto");
27
+ const cwd = process.cwd();
28
+ const rl = createInterface({
29
+ input: process.stdin,
30
+ output: process.stdout,
31
+ });
32
+
33
+ // Check for active state
34
+ let state = await readWorkflowState("deep-interview", cwd);
35
+ if (state && state.active && state.current_phase !== "complete") {
36
+ if (auto) {
37
+ await clearWorkflowState("deep-interview", cwd);
38
+ state = null;
39
+ console.log("Cleared previous state. Starting fresh.");
40
+ } else {
41
+ const resume = await rl.question(
42
+ `\n[ALERT] An active requirements gathering session is already in progress (Ambiguity: ${((state.current_ambiguity ?? 1) * 100).toFixed(0)}%).\n` +
43
+ `Would you like to resume it? [Y/n]: `
44
+ );
45
+ if (resume.trim().toLowerCase() === "n") {
46
+ await clearWorkflowState("deep-interview", cwd);
47
+ state = null;
48
+ console.log("Cleared previous state. Starting fresh.");
49
+ } else {
50
+ console.log("Resuming active Socratic interview session...");
51
+ }
52
+ }
53
+ }
54
+
55
+ // Determine initial idea
56
+ let initialIdea = "";
57
+ if (state) {
58
+ initialIdea = state.initial_idea ?? "";
59
+ } else {
60
+ // If we have CLI args, use them. Otherwise, prompt the user.
61
+ initialIdea = filteredArgs.join(" ");
62
+ if (!initialIdea.trim()) {
63
+ if (auto) {
64
+ console.log("Error: Initial project idea cannot be empty.");
65
+ rl.close();
66
+ return;
67
+ } else {
68
+ initialIdea = await rl.question("\nEnter your initial project idea: ");
69
+ }
70
+ }
71
+ }
72
+
73
+ if (!initialIdea.trim()) {
74
+ console.log("Error: Initial project idea cannot be empty.");
75
+ rl.close();
76
+ return;
77
+ }
78
+
79
+ const slug = state?.slug || initialIdea
80
+ .toLowerCase()
81
+ .replace(/[^a-z0-9\s-]/g, "")
82
+ .trim()
83
+ .split(/\s+/)
84
+ .slice(0, 5)
85
+ .join("-");
86
+
87
+ const interviewId = state?.interview_id || crypto.randomUUID();
88
+
89
+ if (!state) {
90
+ state = {
91
+ active: true,
92
+ current_phase: "interviewing",
93
+ skill: "deep-interview",
94
+ interview_id: interviewId,
95
+ slug,
96
+ initial_idea: initialIdea,
97
+ current_ambiguity: 1.0,
98
+ threshold: 0.2,
99
+ };
100
+ await writeWorkflowState("deep-interview", state, cwd);
101
+ }
102
+
103
+ // Setup initial message history
104
+ const history: Message[] = [
105
+ {
106
+ role: "system",
107
+ content:
108
+ `You are the Socratic Interviewer, a veteran requirements engineer who helps software engineers refine their ideas before writing code.\n` +
109
+ `Your absolute goal is to assess ambiguity across three key dimensions:\n` +
110
+ `1. Goal Clarity\n` +
111
+ `2. Constraint Completeness\n` +
112
+ `3. Success/Acceptance Criteria Definition\n\n` +
113
+ `Provide an output strictly in JSON format. Do not write any text outside of the JSON block.\n` +
114
+ `Structure your output EXACTLY as follows:\n` +
115
+ `{\n` +
116
+ ` "ambiguityScore": 0.0 to 1.0,\n` +
117
+ ` "assessment": "Assessment details here",\n` +
118
+ ` "nextQuestion": "Your Socratic question here to target the weakest dimension",\n` +
119
+ ` "goal": "Optional: qualitative goal definition once ambiguity is <= 0.2",\n` +
120
+ ` "constraints": ["Optional list of constraints once ambiguity is <= 0.2"],\n` +
121
+ ` "acceptance_criteria": ["Optional list of acceptance criteria once ambiguity is <= 0.2"]\n` +
122
+ `}\n` +
123
+ `Ensure ambiguityScore drops dynamically as more detail is gathered. When details are sufficient, set ambiguityScore to <= 0.2.`
124
+ },
125
+ {
126
+ role: "user",
127
+ content: `Here is my initial idea: "${initialIdea}"`
128
+ }
129
+ ];
130
+
131
+ console.log(`\n=== Starting Socratic Interview: ${slug} ===`);
132
+ console.log(`Initial Idea: "${initialIdea}"`);
133
+ console.log(`Ambiguity Threshold: 20% (interview finishes once ambiguity <= 20%)\n`);
134
+
135
+ let round = 1;
136
+ let ambiguity = 1.0;
137
+ let lastParsed: SocraticResponse | undefined;
138
+
139
+ const freezeSeed = async (parsed?: SocraticResponse): Promise<void> => {
140
+ const seedDir = path.join(getLocalJocDir(cwd), "seeds");
141
+ await fs.mkdir(seedDir, { recursive: true });
142
+ const seedPath = path.join(seedDir, `seed-${slug}.yaml`);
143
+ const constraints = parsed?.constraints?.length
144
+ ? parsed.constraints.map(c => ` - "${c}"`).join("\n")
145
+ : ` - "TypeScript / Bun runtime"`;
146
+ const criteria = parsed?.acceptance_criteria?.length
147
+ ? parsed.acceptance_criteria.map(a => ` - "${a}"`).join("\n")
148
+ : ` - "Runs successfully in the terminal"`;
149
+ const seedContent =
150
+ `# Frozen Specification Seed\n` +
151
+ `slug: ${slug}\n` +
152
+ `interview_id: ${interviewId}\n` +
153
+ `goal: "${parsed?.goal || initialIdea}"\n` +
154
+ `constraints:\n${constraints}\n\n` +
155
+ `acceptance_criteria:\n${criteria}\n`;
156
+ await fs.writeFile(seedPath, seedContent, "utf-8");
157
+ state!.current_phase = "complete";
158
+ state!.seed_path = seedPath;
159
+ await writeWorkflowState("deep-interview", state!, cwd);
160
+ console.log(`Saved frozen requirements spec seed to: ${seedPath}`);
161
+ };
162
+
163
+ while (ambiguity > 0.2 && round <= 10) {
164
+ console.log(`\n[Round ${round}] Analyzing requirements...`);
165
+
166
+ try {
167
+ const responseText = await callLlm(history, { jsonMode: true });
168
+ const parsed = extractJsonObject<SocraticResponse>(responseText);
169
+ lastParsed = parsed;
170
+
171
+ ambiguity = parsed.ambiguityScore;
172
+ state.current_ambiguity = ambiguity;
173
+ await writeWorkflowState("deep-interview", state, cwd);
174
+
175
+ console.log(`Ambiguity ${meter(ambiguity)} (Assessment: ${parsed.assessment})`);
176
+
177
+ if (ambiguity <= 0.2) {
178
+ console.log(`\n[SUCCESS] Ambiguity is <= 20%! Concluding requirements gather.`);
179
+ await freezeSeed(parsed);
180
+ console.log("\n[Handoff Ready] Requirement is crystallized. Next, run 'joc ralplan' to build a plan.");
181
+ break;
182
+ }
183
+
184
+ console.log(`\nQuestion: ${parsed.nextQuestion}`);
185
+ let answer = "";
186
+ if (auto) {
187
+ answer = "Use sensible, conventional defaults and proceed. Optimize for a minimal correct implementation.";
188
+ } else {
189
+ answer = await rl.question("\nYour Answer: ");
190
+ }
191
+
192
+ history.push({ role: "assistant", content: responseText });
193
+ history.push({ role: "user", content: answer });
194
+ round++;
195
+
196
+ } catch (error: any) {
197
+ console.log(`\n[Error calling LLM]: ${error.message}`);
198
+ break;
199
+ }
200
+ }
201
+
202
+ // --auto must always yield a seed: if the gate wasn't reached within the round
203
+ // cap, freeze a best-effort seed from the last assessment so the pipeline proceeds.
204
+ if (state.current_phase !== "complete" && auto) {
205
+ const currentAmbiguity = state.current_ambiguity ?? 1.0;
206
+ const threshold = state.threshold ?? 0.2;
207
+ if (currentAmbiguity > threshold) {
208
+ console.log(`\n[AUTO] Ambiguity gate not reached in ${round - 1} rounds (${(currentAmbiguity * 100).toFixed(0)}% > ${(threshold * 100).toFixed(0)}%); saving draft seed.`);
209
+ const seedDir = path.join(getLocalJocDir(cwd), "seeds");
210
+ await fs.mkdir(seedDir, { recursive: true });
211
+ const seedPath = path.join(seedDir, `seed-${slug}.draft.yaml`);
212
+ const constraints = lastParsed?.constraints?.length
213
+ ? lastParsed.constraints.map(c => ` - "${c}"`).join("\n")
214
+ : ` - "TypeScript / Bun runtime"`;
215
+ const criteria = lastParsed?.acceptance_criteria?.length
216
+ ? lastParsed.acceptance_criteria.map(a => ` - "${a}"`).join("\n")
217
+ : ` - "Runs successfully in the terminal"`;
218
+ const seedContent =
219
+ `# Draft Specification Seed\n` +
220
+ `slug: ${slug}\n` +
221
+ `interview_id: ${interviewId}\n` +
222
+ `goal: "${lastParsed?.goal || initialIdea}"\n` +
223
+ `constraints:\n${constraints}\n\n` +
224
+ `acceptance_criteria:\n${criteria}\n`;
225
+ await fs.writeFile(seedPath, seedContent, "utf-8");
226
+
227
+ state.current_phase = "interviewing";
228
+ state.seed_path = seedPath;
229
+ await writeWorkflowState("deep-interview", state, cwd);
230
+ console.log(`Saved draft requirements spec seed to: ${seedPath}`);
231
+ } else {
232
+ console.log(`\n[AUTO] Ambiguity gate reached in ${round - 1} rounds; freezing a best-effort seed.`);
233
+ await freezeSeed(lastParsed);
234
+ console.log("[Handoff Ready] Best-effort seed frozen. Next, run 'joc ralplan'.");
235
+ }
236
+ }
237
+
238
+ rl.close();
239
+ }
@@ -0,0 +1,250 @@
1
+ import { readGlobalConfig } from "../agent/state";
2
+ import { resolveCredential, snapshotProvider, type AuthProvider, type Credential } from "../auth";
3
+ import { resolveProvider } from "../ai";
4
+ import { resolveModelId } from "../ai/model-registry";
5
+ import { meter } from "../tui/components/meter";
6
+ import { size } from "../tui/terminal";
7
+ import chalk from "chalk";
8
+
9
+ interface ProbeResult {
10
+ status: "ok" | "fail" | "skipped";
11
+ detail: string;
12
+ latencyMs?: number;
13
+ }
14
+
15
+ const APP_NAME = "joc";
16
+ const PROBE_TIMEOUT_MS = 4000;
17
+
18
+ async function timedFetch(url: string, init: RequestInit): Promise<{ res: Response; latencyMs: number }> {
19
+ const start = performance.now();
20
+ const ctrl = new AbortController();
21
+ const timer = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT_MS);
22
+ try {
23
+ const res = await fetch(url, { ...init, signal: ctrl.signal });
24
+ return { res, latencyMs: Math.round(performance.now() - start) };
25
+ } finally {
26
+ clearTimeout(timer);
27
+ }
28
+ }
29
+
30
+ async function probeOpenAi(credential: Credential, baseUrl: string | undefined): Promise<ProbeResult> {
31
+ const base = (baseUrl ?? "https://api.openai.com/v1").replace(/\/$/, "");
32
+ const token = credential.kind === "oauth" || credential.kind === "api_key" ? credential.token : "no-key";
33
+ try {
34
+ const { res, latencyMs } = await timedFetch(`${base}/models`, {
35
+ headers: { Authorization: `Bearer ${token}` },
36
+ });
37
+ if (res.ok) return { status: "ok", detail: `GET ${base}/models 200`, latencyMs };
38
+ return { status: "fail", detail: `GET ${base}/models ${res.status}`, latencyMs };
39
+ } catch (err) {
40
+ return { status: "fail", detail: `network error: ${(err as Error).message}` };
41
+ }
42
+ }
43
+
44
+ async function probeGemini(credential: Credential): Promise<ProbeResult> {
45
+ if (credential.kind === "none") {
46
+ return { status: "skipped", detail: "no credential (run 'joc setup' or 'joc auth login gemini')" };
47
+ }
48
+ const key = credential.kind === "api_key" ? credential.token : "";
49
+ const url = credential.kind === "oauth"
50
+ ? "https://generativelanguage.googleapis.com/v1beta/models"
51
+ : `https://generativelanguage.googleapis.com/v1beta/models?key=${key}`;
52
+ try {
53
+ const { res, latencyMs } = await timedFetch(url, {
54
+ headers: credential.kind === "oauth"
55
+ ? { authorization: `Bearer ${credential.token}` }
56
+ : {},
57
+ });
58
+ if (res.ok) return { status: "ok", detail: "GET /v1beta/models 200", latencyMs };
59
+ return { status: "fail", detail: `GET /v1beta/models ${res.status}`, latencyMs };
60
+ } catch (err) {
61
+ return { status: "fail", detail: `network error: ${(err as Error).message}` };
62
+ }
63
+ }
64
+
65
+ async function probeAnthropic(credential: Credential): Promise<ProbeResult> {
66
+ if (credential.kind === "none") {
67
+ return { status: "skipped", detail: "no credential (run 'joc setup' or 'joc auth login anthropic')" };
68
+ }
69
+ // Anthropic has no free model-listing endpoint; verify auth by issuing a
70
+ // 1-token request that returns quickly without burning meaningful credit.
71
+ const headers: Record<string, string> = {
72
+ "content-type": "application/json",
73
+ "anthropic-version": "2023-06-01",
74
+ };
75
+ if (credential.kind === "oauth") {
76
+ headers.authorization = `Bearer ${credential.token}`;
77
+ headers["anthropic-beta"] = "oauth-2025-04-20";
78
+ } else {
79
+ headers["x-api-key"] = credential.token;
80
+ }
81
+ try {
82
+ const { res, latencyMs } = await timedFetch("https://api.anthropic.com/v1/messages", {
83
+ method: "POST",
84
+ headers,
85
+ body: JSON.stringify({
86
+ model: "claude-3-5-sonnet-20241022",
87
+ max_tokens: 1,
88
+ messages: [{ role: "user", content: "ping" }],
89
+ }),
90
+ });
91
+ if (res.ok) return { status: "ok", detail: "POST /v1/messages 200 (1-token probe)", latencyMs };
92
+ if (res.status === 401 || res.status === 403) {
93
+ return { status: "fail", detail: `auth rejected (${res.status})`, latencyMs };
94
+ }
95
+ return { status: "fail", detail: `POST /v1/messages ${res.status}`, latencyMs };
96
+ } catch (err) {
97
+ return { status: "fail", detail: `network error: ${(err as Error).message}` };
98
+ }
99
+ }
100
+
101
+ async function probeOllama(baseUrl: string): Promise<ProbeResult> {
102
+ const base = baseUrl.replace(/\/$/, "");
103
+ try {
104
+ const { res, latencyMs } = await timedFetch(`${base}/api/tags`, { method: "GET" });
105
+ if (res.ok) return { status: "ok", detail: `GET ${base}/api/tags 200`, latencyMs };
106
+ return { status: "fail", detail: `GET ${base}/api/tags ${res.status}`, latencyMs };
107
+ } catch (err) {
108
+ return { status: "fail", detail: `network error: ${(err as Error).message}` };
109
+ }
110
+ }
111
+
112
+ function colorStatus(status: ProbeResult["status"], label: string): string {
113
+ if (status === "ok") return chalk.green(label);
114
+ if (status === "skipped") return chalk.yellow(label);
115
+ return chalk.red(label);
116
+ }
117
+
118
+ function formatRow(provider: string, credKind: string, result: ProbeResult): string {
119
+ const status =
120
+ result.status === "ok" ? " OK " :
121
+ result.status === "skipped" ? " SKIP " :
122
+ " FAIL ";
123
+ const latency = result.latencyMs !== undefined ? `${result.latencyMs}ms` : "—";
124
+ // Visual latency bar (relative to a 2s baseline) for OK probes.
125
+ const bar = result.status === "ok" && result.latencyMs !== undefined ? ` ${meter(result.latencyMs, 2000, 12)}` : "";
126
+ return ` ${provider.padEnd(10)} ${credKind.padEnd(16)} [${colorStatus(result.status, status)}] ${latency.padEnd(7)} ${result.detail}${bar}`;
127
+ }
128
+
129
+ export async function runDoctorCommand(args: string[] = []): Promise<void> {
130
+ const strict = args.includes("--strict");
131
+ const json = args.includes("--json");
132
+ const config = await readGlobalConfig();
133
+ const resolvedModel = await resolveModelId(config.defaultModel);
134
+ const defaultProvider = resolveProvider(resolvedModel);
135
+ const ollamaBase = config.ollamaBaseUrl ?? "http://localhost:11434";
136
+
137
+ // --- Gather (probes run concurrently → ~1× the slowest timeout, not N×) ---
138
+ const probes: { name: string; credKind: string; result: ProbeResult }[] = [];
139
+ const cloud = ["anthropic", "openai", "gemini"] as AuthProvider[];
140
+ const cloudProbes = await Promise.all(
141
+ cloud.map(async provider => {
142
+ const credential = await resolveCredential(provider);
143
+ let result: ProbeResult;
144
+ if (provider === "openai") result = await probeOpenAi(credential, config.openaiBaseUrl);
145
+ else if (provider === "gemini") result = await probeGemini(credential);
146
+ else result = await probeAnthropic(credential);
147
+ return { name: provider, credKind: credential.kind, result };
148
+ })
149
+ );
150
+ const ollamaResult = await probeOllama(ollamaBase);
151
+ for (const p of cloudProbes) probes.push(p);
152
+ probes.push({ name: "ollama", credKind: "none (local)", result: ollamaResult });
153
+
154
+ const oauthHealth: { provider: string; refreshable: boolean; expiresInMin?: number; email?: string }[] = [];
155
+ for (const p of ["anthropic", "openai", "gemini"] as AuthProvider[]) {
156
+ const snap = await snapshotProvider(p);
157
+ if (!snap.oauth) continue;
158
+ oauthHealth.push({
159
+ provider: p,
160
+ refreshable: !!snap.oauthHasRefresh,
161
+ expiresInMin: snap.oauthExpires ? Math.round((snap.oauthExpires - Date.now()) / 60000) : undefined,
162
+ email: snap.oauthEmail,
163
+ });
164
+ }
165
+
166
+ const defaultProbe = probes.find(p => p.name === defaultProvider);
167
+ const ready = defaultProbe?.result.status === "ok";
168
+
169
+ // --- JSON output mode (CI / scripting) ---
170
+ if (json) {
171
+ const report = {
172
+ app: APP_NAME,
173
+ bunVersion: Bun.version,
174
+ defaultModel: { configured: config.defaultModel, resolved: resolvedModel, provider: defaultProvider },
175
+ ollamaBaseUrl: ollamaBase,
176
+ openaiBaseUrl: config.openaiBaseUrl ?? null,
177
+ terminal: {
178
+ cols: size().cols,
179
+ rows: size().rows,
180
+ colorLevel: chalk.level
181
+ },
182
+ providers: probes.map(p => ({
183
+ name: p.name,
184
+ credential: p.credKind,
185
+ status: p.result.status,
186
+ latencyMs: p.result.latencyMs ?? null,
187
+ detail: p.result.detail,
188
+ })),
189
+ oauth: oauthHealth,
190
+ ready,
191
+ };
192
+ console.log(JSON.stringify(report, null, 2));
193
+ if (strict && !ready) process.exit(1);
194
+ return;
195
+ }
196
+
197
+ // --- Human output ---
198
+ console.log("");
199
+ console.log(`=== ${APP_NAME} doctor ===`);
200
+ console.log("");
201
+ console.log(`Bun runtime: v${Bun.version}`);
202
+ console.log(`Default model: ${config.defaultModel}${resolvedModel !== config.defaultModel ? ` → ${resolvedModel}` : ""} → ${defaultProvider}`);
203
+ console.log(`Config: ${process.env.HOME}/.joc/config.json`);
204
+ if (config.openaiBaseUrl) console.log(`OpenAI base: ${config.openaiBaseUrl}`);
205
+ console.log(`Ollama base: ${ollamaBase}`);
206
+
207
+ const termSize = size();
208
+ const tuiVerdict = termSize.cols < 40
209
+ ? `${termSize.cols}x${termSize.rows} ${chalk.red("(too narrow for ASCII art)")}`
210
+ : `${termSize.cols}x${termSize.rows} ${chalk.green("(ASCII art enabled)")}`;
211
+ console.log(`Terminal size: ${tuiVerdict}`);
212
+ console.log(`Color support: Level ${chalk.level} (${chalk.level > 0 ? chalk.green("enabled") : "disabled"})`);
213
+ console.log("");
214
+
215
+ console.log("Provider connectivity:");
216
+ console.log(` ${"Provider".padEnd(10)} ${"Credential".padEnd(16)} ${"Status".padEnd(8)} ${"Latency".padEnd(7)} Detail`);
217
+ console.log(` ${"-".repeat(75)}`);
218
+ for (const p of probes) console.log(formatRow(p.name, p.credKind, p.result));
219
+
220
+ console.log("");
221
+ const oauthLines = oauthHealth.map(o => {
222
+ let detail = o.refreshable ? "refreshable" : "manual (no refresh)";
223
+ if (o.expiresInMin !== undefined) detail += o.expiresInMin <= 0 ? ", expired (auto-refresh on next call)" : `, expires in ${o.expiresInMin}m`;
224
+ if (o.email) detail += `, ${o.email}`;
225
+ return ` ${o.provider.padEnd(10)} ${detail}`;
226
+ });
227
+ if (oauthLines.length) {
228
+ console.log("OAuth tokens:");
229
+ for (const line of oauthLines) console.log(line);
230
+ console.log("");
231
+ }
232
+
233
+ // Final verdict
234
+ if (ready) {
235
+ console.log(`${chalk.green("[READY]")} Default model '${config.defaultModel}' is reachable.`);
236
+ } else if (defaultProbe?.result.status === "skipped") {
237
+ console.log(
238
+ `${chalk.red("[NOT READY]")} Default model '${config.defaultModel}' resolves to '${defaultProvider}', ` +
239
+ `but no credential is configured. Run 'joc setup' or 'joc auth login ${defaultProvider}'.`
240
+ );
241
+ } else {
242
+ console.log(
243
+ `${chalk.red("[NOT READY]")} Default model '${config.defaultModel}' probe failed: ${defaultProbe?.result.detail ?? "unknown"}.`
244
+ );
245
+ }
246
+
247
+ if (strict && !ready) {
248
+ process.exit(1);
249
+ }
250
+ }