@cdoing/cli 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 (118) hide show
  1. package/.cdoing/permissions.json +8 -0
  2. package/dist/callbacks.d.ts +17 -0
  3. package/dist/callbacks.d.ts.map +1 -0
  4. package/dist/callbacks.js +265 -0
  5. package/dist/callbacks.js.map +1 -0
  6. package/dist/chat.d.ts +27 -0
  7. package/dist/chat.d.ts.map +1 -0
  8. package/dist/chat.js +57 -0
  9. package/dist/chat.js.map +1 -0
  10. package/dist/commands.d.ts +22 -0
  11. package/dist/commands.d.ts.map +1 -0
  12. package/dist/commands.js +452 -0
  13. package/dist/commands.js.map +1 -0
  14. package/dist/config.d.ts +84 -0
  15. package/dist/config.d.ts.map +1 -0
  16. package/dist/config.js +427 -0
  17. package/dist/config.js.map +1 -0
  18. package/dist/help.d.ts +9 -0
  19. package/dist/help.d.ts.map +1 -0
  20. package/dist/help.js +167 -0
  21. package/dist/help.js.map +1 -0
  22. package/dist/history.d.ts +51 -0
  23. package/dist/history.d.ts.map +1 -0
  24. package/dist/history.js +207 -0
  25. package/dist/history.js.map +1 -0
  26. package/dist/index.d.ts +7 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +220 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/oauth.d.ts +13 -0
  31. package/dist/oauth.d.ts.map +1 -0
  32. package/dist/oauth.js +182 -0
  33. package/dist/oauth.js.map +1 -0
  34. package/dist/review.d.ts +26 -0
  35. package/dist/review.d.ts.map +1 -0
  36. package/dist/review.js +198 -0
  37. package/dist/review.js.map +1 -0
  38. package/dist/serve.d.ts +23 -0
  39. package/dist/serve.d.ts.map +1 -0
  40. package/dist/serve.js +293 -0
  41. package/dist/serve.js.map +1 -0
  42. package/dist/tools.d.ts +14 -0
  43. package/dist/tools.d.ts.map +1 -0
  44. package/dist/tools.js +57 -0
  45. package/dist/tools.js.map +1 -0
  46. package/dist/ui/App.d.ts +24 -0
  47. package/dist/ui/App.d.ts.map +1 -0
  48. package/dist/ui/App.js +321 -0
  49. package/dist/ui/App.js.map +1 -0
  50. package/dist/ui/MessageList.d.ts +14 -0
  51. package/dist/ui/MessageList.d.ts.map +1 -0
  52. package/dist/ui/MessageList.js +147 -0
  53. package/dist/ui/MessageList.js.map +1 -0
  54. package/dist/ui/SessionBrowser.d.ts +18 -0
  55. package/dist/ui/SessionBrowser.d.ts.map +1 -0
  56. package/dist/ui/SessionBrowser.js +149 -0
  57. package/dist/ui/SessionBrowser.js.map +1 -0
  58. package/dist/ui/SetupWizard.d.ts +23 -0
  59. package/dist/ui/SetupWizard.d.ts.map +1 -0
  60. package/dist/ui/SetupWizard.js +402 -0
  61. package/dist/ui/SetupWizard.js.map +1 -0
  62. package/dist/ui/Spinner.d.ts +15 -0
  63. package/dist/ui/Spinner.d.ts.map +1 -0
  64. package/dist/ui/Spinner.js +111 -0
  65. package/dist/ui/Spinner.js.map +1 -0
  66. package/dist/ui/StatusBar.d.ts +16 -0
  67. package/dist/ui/StatusBar.d.ts.map +1 -0
  68. package/dist/ui/StatusBar.js +56 -0
  69. package/dist/ui/StatusBar.js.map +1 -0
  70. package/dist/ui/UserInput.d.ts +13 -0
  71. package/dist/ui/UserInput.d.ts.map +1 -0
  72. package/dist/ui/UserInput.js +872 -0
  73. package/dist/ui/UserInput.js.map +1 -0
  74. package/dist/ui/hooks/helpers.d.ts +55 -0
  75. package/dist/ui/hooks/helpers.d.ts.map +1 -0
  76. package/dist/ui/hooks/helpers.js +304 -0
  77. package/dist/ui/hooks/helpers.js.map +1 -0
  78. package/dist/ui/hooks/useAgent.d.ts +60 -0
  79. package/dist/ui/hooks/useAgent.d.ts.map +1 -0
  80. package/dist/ui/hooks/useAgent.js +213 -0
  81. package/dist/ui/hooks/useAgent.js.map +1 -0
  82. package/dist/ui/hooks/useChat.d.ts +74 -0
  83. package/dist/ui/hooks/useChat.d.ts.map +1 -0
  84. package/dist/ui/hooks/useChat.js +819 -0
  85. package/dist/ui/hooks/useChat.js.map +1 -0
  86. package/dist/ui/theme.d.ts +73 -0
  87. package/dist/ui/theme.d.ts.map +1 -0
  88. package/dist/ui/theme.js +214 -0
  89. package/dist/ui/theme.js.map +1 -0
  90. package/dist/ui/types.d.ts +37 -0
  91. package/dist/ui/types.d.ts.map +1 -0
  92. package/dist/ui/types.js +3 -0
  93. package/dist/ui/types.js.map +1 -0
  94. package/package.json +33 -0
  95. package/src/callbacks.ts +294 -0
  96. package/src/chat.ts +72 -0
  97. package/src/commands.ts +425 -0
  98. package/src/config.ts +462 -0
  99. package/src/help.ts +182 -0
  100. package/src/history.ts +205 -0
  101. package/src/index.ts +248 -0
  102. package/src/oauth.ts +164 -0
  103. package/src/review.ts +233 -0
  104. package/src/serve.ts +290 -0
  105. package/src/tools.ts +104 -0
  106. package/src/ui/App.tsx +426 -0
  107. package/src/ui/MessageList.tsx +222 -0
  108. package/src/ui/SessionBrowser.tsx +161 -0
  109. package/src/ui/SetupWizard.tsx +412 -0
  110. package/src/ui/Spinner.tsx +103 -0
  111. package/src/ui/StatusBar.tsx +106 -0
  112. package/src/ui/UserInput.tsx +954 -0
  113. package/src/ui/hooks/helpers.ts +271 -0
  114. package/src/ui/hooks/useAgent.ts +270 -0
  115. package/src/ui/hooks/useChat.ts +943 -0
  116. package/src/ui/theme.ts +326 -0
  117. package/src/ui/types.ts +41 -0
  118. package/tsconfig.json +18 -0
package/src/history.ts ADDED
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Conversation History Manager
3
+ *
4
+ * Saves conversations to ~/.cdoing/conversations/ as JSON files.
5
+ * Each conversation has an ID, title (from first message), timestamps,
6
+ * and the full message log.
7
+ */
8
+
9
+ import * as fs from "fs";
10
+ import * as path from "path";
11
+ import * as os from "os";
12
+ import chalk from "chalk";
13
+
14
+ const CONV_DIR = path.join(os.homedir(), ".cdoing", "conversations");
15
+
16
+ /** A single message in the conversation log */
17
+ export interface ChatMessage {
18
+ role: "user" | "assistant" | "tool";
19
+ content: string;
20
+ toolName?: string;
21
+ timestamp: number;
22
+ }
23
+
24
+ /** Metadata + messages for a saved conversation */
25
+ export interface Conversation {
26
+ id: string;
27
+ title: string;
28
+ createdAt: number;
29
+ updatedAt: number;
30
+ provider: string;
31
+ model: string;
32
+ messages: ChatMessage[];
33
+ }
34
+
35
+ /** Make sure the conversations directory exists */
36
+ function ensureDir(): void {
37
+ if (!fs.existsSync(CONV_DIR)) fs.mkdirSync(CONV_DIR, { recursive: true });
38
+ }
39
+
40
+ /** Generate a short unique ID */
41
+ function generateId(): string {
42
+ const now = Date.now().toString(36);
43
+ const rand = Math.random().toString(36).slice(2, 6);
44
+ return `${now}-${rand}`;
45
+ }
46
+
47
+ /** Derive a title from the first user message */
48
+ function deriveTitle(message: string): string {
49
+ const clean = message.replace(/\n/g, " ").trim();
50
+ return clean.length > 60 ? clean.substring(0, 57) + "..." : clean;
51
+ }
52
+
53
+ // ── Public API ──────────────────────────────────────────────
54
+
55
+ /** Create a new conversation and return it */
56
+ export function createConversation(provider: string, model: string): Conversation {
57
+ ensureDir();
58
+ return {
59
+ id: generateId(),
60
+ title: "New conversation",
61
+ createdAt: Date.now(),
62
+ updatedAt: Date.now(),
63
+ provider,
64
+ model,
65
+ messages: [],
66
+ };
67
+ }
68
+
69
+ /** Add a message to the conversation and save */
70
+ export function addMessage(
71
+ conv: Conversation,
72
+ role: "user" | "assistant" | "tool",
73
+ content: string,
74
+ toolName?: string
75
+ ): void {
76
+ conv.messages.push({ role, content, timestamp: Date.now(), toolName });
77
+ conv.updatedAt = Date.now();
78
+
79
+ // Set title from first user message
80
+ if (role === "user" && conv.title === "New conversation") {
81
+ conv.title = deriveTitle(content);
82
+ }
83
+
84
+ saveConversation(conv);
85
+ }
86
+
87
+ /** Save conversation to disk */
88
+ export function saveConversation(conv: Conversation): void {
89
+ ensureDir();
90
+ const filePath = path.join(CONV_DIR, `${conv.id}.json`);
91
+ fs.writeFileSync(filePath, JSON.stringify(conv, null, 2), "utf-8");
92
+ }
93
+
94
+ /** Load a conversation by ID */
95
+ export function loadConversation(id: string): Conversation | null {
96
+ const filePath = path.join(CONV_DIR, `${id}.json`);
97
+ if (!fs.existsSync(filePath)) return null;
98
+ try {
99
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ /** List all saved conversations, newest first */
106
+ export function listConversations(): Conversation[] {
107
+ ensureDir();
108
+ const files = fs.readdirSync(CONV_DIR).filter((f) => f.endsWith(".json"));
109
+ const convs: Conversation[] = [];
110
+
111
+ for (const file of files) {
112
+ try {
113
+ const data = JSON.parse(fs.readFileSync(path.join(CONV_DIR, file), "utf-8"));
114
+ convs.push(data);
115
+ } catch {}
116
+ }
117
+
118
+ return convs.sort((a, b) => b.updatedAt - a.updatedAt);
119
+ }
120
+
121
+ /** Load the most recent conversation (for --continue flag) */
122
+ export function loadLastConversation(): Conversation | null {
123
+ const all = listConversations();
124
+ return all.length > 0 ? all[0] : null;
125
+ }
126
+
127
+ /** Delete a conversation by ID */
128
+ export function deleteConversation(id: string): boolean {
129
+ const filePath = path.join(CONV_DIR, `${id}.json`);
130
+ if (fs.existsSync(filePath)) {
131
+ fs.unlinkSync(filePath);
132
+ return true;
133
+ }
134
+ return false;
135
+ }
136
+
137
+ /**
138
+ * Fork a conversation — create an identical copy with a new ID.
139
+ * Returns the forked conversation (already saved to disk).
140
+ */
141
+ export function forkConversation(idOrConv: string | Conversation): Conversation | null {
142
+ const original =
143
+ typeof idOrConv === "string" ? loadConversation(idOrConv) : idOrConv;
144
+ if (!original) return null;
145
+
146
+ ensureDir();
147
+ const forked: Conversation = {
148
+ ...original,
149
+ id: generateId(),
150
+ title: `Fork of: ${original.title}`,
151
+ createdAt: Date.now(),
152
+ updatedAt: Date.now(),
153
+ messages: original.messages.map((m) => ({ ...m })),
154
+ };
155
+ saveConversation(forked);
156
+ return forked;
157
+ }
158
+
159
+ /**
160
+ * Update the title of a conversation in-place.
161
+ * Useful for AI-generated titles after the first response.
162
+ */
163
+ export function updateConversationTitle(id: string, title: string): void {
164
+ const conv = loadConversation(id);
165
+ if (!conv) return;
166
+ conv.title = title.length > 80 ? title.substring(0, 77) + "..." : title;
167
+ conv.updatedAt = Date.now();
168
+ saveConversation(conv);
169
+ }
170
+
171
+ /** Print conversation list to console */
172
+ export function printConversationList(): void {
173
+ const convs = listConversations();
174
+
175
+ if (convs.length === 0) {
176
+ console.log(chalk.dim("\n No saved conversations.\n"));
177
+ return;
178
+ }
179
+
180
+ console.log();
181
+ console.log(chalk.bold(" Conversations:"));
182
+ console.log();
183
+
184
+ const limit = Math.min(convs.length, 20);
185
+ for (let i = 0; i < limit; i++) {
186
+ const c = convs[i];
187
+ const date = new Date(c.updatedAt).toLocaleDateString("en-US", {
188
+ month: "short", day: "numeric", hour: "2-digit", minute: "2-digit",
189
+ });
190
+ const msgCount = c.messages.filter((m) => m.role === "user").length;
191
+ console.log(
192
+ chalk.cyan(` ${c.id}`) +
193
+ chalk.dim(` ${date} (${msgCount} msgs)`) +
194
+ ` ${c.title}`
195
+ );
196
+ }
197
+
198
+ if (convs.length > 20) {
199
+ console.log(chalk.dim(`\n ... and ${convs.length - 20} more`));
200
+ }
201
+
202
+ console.log();
203
+ console.log(chalk.dim(" Use /resume <id> to continue a conversation."));
204
+ console.log(chalk.dim(" Use /delete <id> to remove one.\n"));
205
+ }
package/src/index.ts ADDED
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Cdoing CLI — Entry point.
5
+ * Parses args, resolves API key, launches interactive or one-shot mode.
6
+ */
7
+
8
+ import { Command } from "commander";
9
+ import { AgentRunner } from "@cdoing/ai";
10
+ import { HookManager, loadProjectConfig, MemoryStore, TodoStore } from "@cdoing/core";
11
+ import { ChatInterface } from "./chat";
12
+ import { buildModelConfig, createPermissionManager, resolveApiKey, type CLIOptions } from "./config";
13
+ import { createToolRegistry } from "./tools";
14
+ import { createOneShotCallbacks, createPrintCallbacks, createJsonCallbacks, createStreamJsonCallbacks } from "./callbacks";
15
+ import chalk from "chalk";
16
+ import { oauthLogin, oauthLogout } from "./oauth";
17
+ import { handleConfigCommand, handleInit, handleDoctor, handleCompletions } from "./commands";
18
+ import { loadConversation, loadLastConversation } from "./history";
19
+
20
+ const program = new Command();
21
+
22
+ program
23
+ .name("cdoing")
24
+ .description("AI-powered coding assistant CLI")
25
+ .version("0.1.0")
26
+ .option("-m, --model <model>", "Model to use (e.g., claude-sonnet-4-20250514, gpt-4o)")
27
+ .option("-p, --provider <provider>", "AI provider: anthropic, openai, google, custom", "anthropic")
28
+ .option("--base-url <url>", "Base URL for custom providers")
29
+ .option("--api-key <key>", "API key (overrides env var)")
30
+ .option("--mode <mode>", "Permission mode: ask, auto-edit, auto", "ask")
31
+ .option("-d, --dir <directory>", "Working directory", process.cwd())
32
+ .option("--login", "Login with Claude via OAuth (opens browser)")
33
+ .option("--logout", "Clear stored OAuth tokens")
34
+ // New flags
35
+ .option("--print", "Print output only (non-interactive)")
36
+ .option("-r, --resume <id>", "Resume conversation by ID")
37
+ .option("-c, --continue", "Continue most recent conversation")
38
+ .option("--max-turns <n>", "Maximum agent turns")
39
+ .option("--output-format <format>", "Output format: text, json, stream-json", "text")
40
+ .option("--verbose", "Enable verbose logging")
41
+ .option("--system-prompt <prompt>", "Custom system prompt")
42
+ .option("--allowed-tools <tools>", "Comma-separated list of allowed tools")
43
+ .option("--disallowed-tools <tools>", "Comma-separated list of disallowed tools")
44
+ .argument("[prompt]", "One-shot prompt (skips interactive mode)")
45
+ .action(run);
46
+
47
+ // Subcommands
48
+ program
49
+ .command("config")
50
+ .description("Manage configuration")
51
+ .argument("<action>", "Action: get, set, or list")
52
+ .argument("[key]", "Config key")
53
+ .argument("[value]", "Config value (for set)")
54
+ .action(handleConfigCommand);
55
+
56
+ program
57
+ .command("init")
58
+ .description("Initialize project with .cdoing/config.md")
59
+ .action(handleInit);
60
+
61
+ program
62
+ .command("doctor")
63
+ .description("Diagnose setup and configuration issues")
64
+ .action(handleDoctor);
65
+
66
+ program
67
+ .command("completions")
68
+ .description("Generate shell completion script (zsh, bash)")
69
+ .argument("[shell]", "Shell type: zsh or bash")
70
+ .action(handleCompletions);
71
+
72
+ /** Create a sub-agent factory: spawns a child agent without sub_agent tool */
73
+ function createSubAgentFactory(
74
+ modelConfig: Partial<import("@cdoing/ai").ModelConfig>,
75
+ workingDir: string,
76
+ permissionManager: import("@cdoing/core").PermissionManager,
77
+ hookManager: HookManager,
78
+ options?: { projectConfig?: string; memory?: string },
79
+ ) {
80
+ return async (prompt: string, signal?: AbortSignal): Promise<string> => {
81
+ // Child registry has no sub_agent tool (no recursion)
82
+ const childRegistry = createToolRegistry(workingDir);
83
+ const childAgent = new AgentRunner(modelConfig, childRegistry, permissionManager, hookManager, options);
84
+
85
+ // If an abort signal is provided, cancel the agent when it fires
86
+ if (signal) {
87
+ signal.addEventListener("abort", () => childAgent.cancel(), { once: true });
88
+ }
89
+
90
+ // Silent callbacks — collect result only
91
+ let result = "";
92
+ await childAgent.run(prompt, {
93
+ onToken: (t) => { result += t; },
94
+ onToolCall: () => {},
95
+ onToolResult: () => {},
96
+ onComplete: () => {},
97
+ onError: (e) => { result += `\nError: ${e.message}`; },
98
+ });
99
+ return result;
100
+ };
101
+ }
102
+
103
+ async function run(prompt: string | undefined, options: CLIOptions) {
104
+ // Handle --logout first
105
+ if (options.logout) {
106
+ console.log(chalk.green(`\n ${oauthLogout()}\n`));
107
+ return;
108
+ }
109
+
110
+ // Handle --login: start OAuth flow
111
+ if (options.login) {
112
+ try {
113
+ await oauthLogin();
114
+ console.log(chalk.green("\n Login successful! You can now run cdoing.\n"));
115
+ } catch (err) {
116
+ console.log(chalk.red(`\n Login failed: ${(err as Error).message}\n`));
117
+ process.exit(1);
118
+ }
119
+ return;
120
+ }
121
+
122
+ // Enable verbose logging if requested
123
+ if (options.verbose) {
124
+ process.env.DEBUG = "cdoing:*";
125
+ console.log(chalk.dim("[verbose] Verbose logging enabled"));
126
+ }
127
+
128
+ await resolveApiKey(options);
129
+
130
+ const modelConfig = buildModelConfig(options);
131
+ const permissionManager = createPermissionManager(options);
132
+ const hookManager = new HookManager(options.dir);
133
+ const memoryStore = new MemoryStore();
134
+ const todoStore = new TodoStore();
135
+ const projectConfig = loadProjectConfig(options.dir);
136
+
137
+ const agentOptions: import("@cdoing/ai").AgentRunnerOptions = {
138
+ projectConfig: projectConfig || undefined,
139
+ memory: memoryStore.formatForPrompt() || undefined,
140
+ };
141
+
142
+ // Handle custom system prompt
143
+ if (options.systemPrompt) {
144
+ agentOptions.systemPrompt = options.systemPrompt;
145
+ }
146
+
147
+ // Handle max turns
148
+ if (options.maxTurns) {
149
+ agentOptions.maxTurns = parseInt(options.maxTurns, 10);
150
+ }
151
+
152
+ const subAgentFactory = createSubAgentFactory(
153
+ modelConfig, options.dir, permissionManager, hookManager, agentOptions,
154
+ );
155
+
156
+ let toolRegistry = createToolRegistry(options.dir, { subAgentFactory, todoStore });
157
+
158
+ // Handle tool filtering
159
+ if (options.allowedTools) {
160
+ const allowed = options.allowedTools.split(",").map(t => t.trim());
161
+ toolRegistry = filterTools(toolRegistry, allowed, "allow");
162
+ if (options.verbose) {
163
+ console.log(chalk.dim(`[verbose] Allowed tools: ${allowed.join(", ")}`));
164
+ }
165
+ }
166
+ if (options.disallowedTools) {
167
+ const disallowed = options.disallowedTools.split(",").map(t => t.trim());
168
+ toolRegistry = filterTools(toolRegistry, disallowed, "disallow");
169
+ if (options.verbose) {
170
+ console.log(chalk.dim(`[verbose] Disallowed tools: ${disallowed.join(", ")}`));
171
+ }
172
+ }
173
+
174
+ // Handle --resume or --continue
175
+ let resumedConversation: import("./history").Conversation | null = null;
176
+ if (options.resume) {
177
+ resumedConversation = loadConversation(options.resume);
178
+ if (!resumedConversation) {
179
+ console.log(chalk.red(`\n Conversation not found: ${options.resume}\n`));
180
+ process.exit(1);
181
+ }
182
+ if (options.verbose) {
183
+ console.log(chalk.dim(`[verbose] Resuming conversation: ${options.resume}`));
184
+ }
185
+ } else if (options.continue) {
186
+ resumedConversation = loadLastConversation();
187
+ if (!resumedConversation) {
188
+ console.log(chalk.yellow("\n No previous conversation to continue.\n"));
189
+ } else if (options.verbose) {
190
+ console.log(chalk.dim(`[verbose] Continuing conversation: ${resumedConversation.id}`));
191
+ }
192
+ }
193
+
194
+ if (prompt) {
195
+ const agent = new AgentRunner(modelConfig, toolRegistry, permissionManager, hookManager, agentOptions);
196
+
197
+ // Restore history from resumed conversation
198
+ if (resumedConversation) {
199
+ for (const msg of resumedConversation.messages) {
200
+ if (msg.role === "user") {
201
+ agent.addToHistory("user", msg.content);
202
+ } else if (msg.role === "assistant") {
203
+ agent.addToHistory("assistant", msg.content);
204
+ }
205
+ }
206
+ }
207
+
208
+ // Select callbacks based on output format and print mode
209
+ let callbacks;
210
+ if (options.print) {
211
+ callbacks = createPrintCallbacks();
212
+ } else if (options.outputFormat === "json") {
213
+ callbacks = createJsonCallbacks();
214
+ } else if (options.outputFormat === "stream-json") {
215
+ callbacks = createStreamJsonCallbacks();
216
+ } else {
217
+ callbacks = createOneShotCallbacks();
218
+ }
219
+
220
+ await agent.run(prompt, callbacks);
221
+ } else {
222
+ const chat = new ChatInterface(modelConfig, toolRegistry, permissionManager, hookManager, memoryStore, todoStore);
223
+ await chat.start();
224
+ }
225
+ }
226
+
227
+ /** Filter tools based on allow/disallow list */
228
+ function filterTools(
229
+ registry: import("@cdoing/core").ToolRegistry,
230
+ toolNames: string[],
231
+ mode: "allow" | "disallow"
232
+ ): import("@cdoing/core").ToolRegistry {
233
+ const allTools = registry.getAll();
234
+ const filtered = allTools.filter(tool => {
235
+ const isInList = toolNames.includes(tool.definition.name);
236
+ return mode === "allow" ? isInList : !isInList;
237
+ });
238
+
239
+ // Create a new registry with filtered tools
240
+ const { ToolRegistry } = require("@cdoing/core");
241
+ const newRegistry = new ToolRegistry();
242
+ for (const tool of filtered) {
243
+ newRegistry.register(tool);
244
+ }
245
+ return newRegistry;
246
+ }
247
+
248
+ program.parse();
package/src/oauth.ts ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * CLI OAuth — thin wrapper around @cdoing/core OAuth
3
+ *
4
+ * Only contains CLI-specific UI code (readline prompts, chalk output, browser opener).
5
+ * All credential storage, PKCE, token management lives in @cdoing/core.
6
+ */
7
+
8
+ import chalk from "chalk";
9
+ import * as fs from "fs";
10
+ import * as path from "path";
11
+ import * as os from "os";
12
+
13
+ // Re-export everything from core so existing imports keep working
14
+ export {
15
+ saveOAuthTokens,
16
+ loadOAuthTokens,
17
+ clearOAuthTokens,
18
+ isOAuthExpired,
19
+ refreshAccessToken,
20
+ resolveOAuthToken,
21
+ generateOAuthUrl,
22
+ exchangeOAuthCode,
23
+ getOAuthStatus,
24
+ } from "@cdoing/core";
25
+ export type { OAuthTokens } from "@cdoing/core";
26
+
27
+ import {
28
+ generateOAuthUrl,
29
+ exchangeOAuthCode,
30
+ clearOAuthTokens,
31
+ loadOAuthTokens,
32
+ isOAuthExpired,
33
+ } from "@cdoing/core";
34
+ import type { OAuthTokens } from "@cdoing/core";
35
+
36
+ // ── CLI-specific: Interactive login ──────────────────────
37
+
38
+ export async function oauthLogin(): Promise<OAuthTokens> {
39
+ const readline = await import("readline");
40
+ const { url, codeVerifier } = generateOAuthUrl();
41
+
42
+ console.log();
43
+ console.log(chalk.bold.cyan(" Claude OAuth Login"));
44
+ console.log(chalk.dim(" Opening browser for authentication...\n"));
45
+ console.log(chalk.white(" If the browser doesn't open, visit:"));
46
+ console.log(chalk.dim(` ${url}\n`));
47
+
48
+ openBrowser(url);
49
+
50
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
51
+ const code = await new Promise<string>((resolve) => {
52
+ rl.question(chalk.green(" Paste the authorization code here: "), (a) => {
53
+ rl.close();
54
+ resolve(a.trim());
55
+ });
56
+ });
57
+
58
+ if (!code) throw new Error("No authorization code provided");
59
+ return exchangeOAuthCode(code, codeVerifier);
60
+ }
61
+
62
+ // ── CLI-specific: Browser opener ─────────────────────────
63
+
64
+ function openBrowser(urlToOpen: string): void {
65
+ const { exec } = require("child_process");
66
+ const platform = process.platform;
67
+
68
+ let command: string;
69
+ if (platform === "darwin") {
70
+ command = `open "${urlToOpen}"`;
71
+ } else if (platform === "win32") {
72
+ command = `start "" "${urlToOpen}"`;
73
+ } else {
74
+ command = `xdg-open "${urlToOpen}"`;
75
+ }
76
+
77
+ exec(command, (err: Error | null) => {
78
+ if (err) {
79
+ console.log(chalk.yellow(" Could not open browser automatically."));
80
+ console.log(chalk.white(" Please open the URL above manually.\n"));
81
+ }
82
+ });
83
+ }
84
+
85
+ // ── CLI-specific: Logout with message ────────────────────
86
+
87
+ export function oauthLogout(): string {
88
+ clearOAuthTokens();
89
+ return "Logged out. OAuth tokens cleared.";
90
+ }
91
+
92
+ // ── CLI-specific: Detailed status report ─────────────────
93
+
94
+ export function oauthStatus(): string {
95
+ const tokens = loadOAuthTokens();
96
+
97
+ const configPath = path.join(os.homedir(), ".cdoing", "config.json");
98
+ let apiKeys: Record<string, string> | undefined;
99
+ let apiKeyHelper: string | undefined;
100
+ try {
101
+ if (fs.existsSync(configPath)) {
102
+ const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
103
+ apiKeys = config.apiKeys;
104
+ apiKeyHelper = config.apiKeyHelper;
105
+ }
106
+ } catch {}
107
+
108
+ const lines: string[] = [];
109
+ lines.push("Authentication Status");
110
+ lines.push("");
111
+
112
+ lines.push("OAuth (Claude):");
113
+ if (tokens) {
114
+ const expired = isOAuthExpired(tokens);
115
+ lines.push(` ${expired ? "✗ expired" : "✓ active"}`);
116
+ if (tokens.expires_at) {
117
+ lines.push(` Expires: ${new Date(tokens.expires_at).toLocaleString()}`);
118
+ }
119
+ lines.push(` Refresh token: ${tokens.refresh_token ? "available" : "none"}`);
120
+ } else {
121
+ lines.push(" Not logged in — use /setup to authenticate");
122
+ }
123
+
124
+ lines.push("");
125
+ lines.push("API Key Helper (dynamic):");
126
+ if (apiKeyHelper) {
127
+ lines.push(` ✓ Script: ${apiKeyHelper}`);
128
+ lines.push(` Key is fetched by running this script on every startup`);
129
+ } else {
130
+ lines.push(" Not configured");
131
+ lines.push(" Set with: /config set api-key-helper ~/path/to/script.sh");
132
+ }
133
+
134
+ lines.push("");
135
+ lines.push("Manually stored API keys:");
136
+ if (apiKeys && Object.keys(apiKeys).length > 0) {
137
+ for (const [provider, key] of Object.entries(apiKeys)) {
138
+ const masked = key.slice(0, 8) + "..." + key.slice(-4);
139
+ lines.push(` ✓ ${provider}: ${masked} (saved via /config set api-key)`);
140
+ }
141
+ } else {
142
+ lines.push(" None");
143
+ lines.push(" Set with: /config set api-key <your-key>");
144
+ }
145
+
146
+ lines.push("");
147
+ lines.push("Environment variables:");
148
+ const envVars: [string, string | undefined][] = [
149
+ ["ANTHROPIC_API_KEY", process.env.ANTHROPIC_API_KEY],
150
+ ["OPENAI_API_KEY", process.env.OPENAI_API_KEY],
151
+ ["GOOGLE_API_KEY", process.env.GOOGLE_API_KEY],
152
+ ];
153
+ let hasEnvKey = false;
154
+ for (const [name, value] of envVars) {
155
+ if (value) {
156
+ hasEnvKey = true;
157
+ const masked = value.slice(0, 8) + "..." + value.slice(-4);
158
+ lines.push(` ✓ ${name}: ${masked}`);
159
+ }
160
+ }
161
+ if (!hasEnvKey) lines.push(" None");
162
+
163
+ return lines.join("\n");
164
+ }