claude-simple 0.2.9 → 0.2.11

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.
@@ -0,0 +1,189 @@
1
+ import { spawn } from 'child_process';
2
+ import { promises as fs } from 'fs';
3
+ import path from 'path';
4
+ import readline from 'readline';
5
+ import crypto from 'crypto';
6
+
7
+ // Cursor Agent owns its native transcript format. Keep a small UI journal so
8
+ // sessions started here remain searchable/readable without relying on that
9
+ // private format.
10
+ function journalPath(cursorHome, sessionId) {
11
+ return path.join(cursorHome, 'claude-simple-ui', 'sessions', `${sessionId}.jsonl`);
12
+ }
13
+
14
+ function journal(cursorHome, sessionId, value) {
15
+ if (!sessionId) return;
16
+ const file = journalPath(cursorHome, sessionId);
17
+ fs.mkdir(path.dirname(file), { recursive: true })
18
+ .then(() => fs.appendFile(file, `${JSON.stringify(value)}\n`, 'utf8'))
19
+ .catch(() => {});
20
+ }
21
+
22
+ function buildArgs(command, options) {
23
+ const args = ['--print', '--output-format', 'stream-json', '--stream-partial-output'];
24
+ if (options.sessionId) args.push('--resume', options.sessionId);
25
+ if (options.model) args.push('--model', options.model);
26
+ if (options.cwd) args.push('--workspace', options.cwd);
27
+ if (options.permissionMode === 'plan') args.push('--mode', 'plan');
28
+ else if (options.permissionMode === 'ask') args.push('--mode', 'ask');
29
+ else if (options.allowDangerouslySkipPermissions || ['bypassPermissions', 'acceptEdits', 'auto'].includes(options.permissionMode)) args.push('--force');
30
+ // The UI has already selected the project directory; do not trigger Cursor's
31
+ // interactive trust prompt in a non-interactive child process.
32
+ args.push('--trust', command || '');
33
+ return args;
34
+ }
35
+
36
+ function textBlocks(message) {
37
+ const content = message?.content;
38
+ if (typeof content === 'string') return content;
39
+ if (!Array.isArray(content)) return '';
40
+ return content.filter(b => b?.type === 'text' && b.text).map(b => b.text).join('\n');
41
+ }
42
+
43
+ function toolBlocks(message) {
44
+ return Array.isArray(message?.content)
45
+ ? message.content.filter(b => b?.type === 'tool_use' || b?.type === 'tool-call')
46
+ : [];
47
+ }
48
+
49
+ /** Run one Cursor Agent turn in headless stream-json mode. */
50
+ export function runCursor(command, options, send, { cliPath, cursorHome, activeSessions }) {
51
+ return new Promise(resolve => {
52
+ const sessionKey = options.sessionId || crypto.randomUUID();
53
+ const home = cursorHome || path.join(process.env.HOME || '', '.cursor');
54
+ const args = buildArgs(command, options);
55
+ let child;
56
+ try {
57
+ child = spawn(cliPath || 'cursor-agent', args, {
58
+ cwd: options.cwd || process.cwd(), env: { ...process.env }, stdio: ['ignore', 'pipe', 'pipe'],
59
+ });
60
+ } catch (error) {
61
+ send({ type: 'error', message: `Failed to start cursor-agent: ${error.message}`, agent: 'cursor' });
62
+ resolve();
63
+ return;
64
+ }
65
+
66
+ const entry = { kind: 'cursor', child, startTime: Date.now(), busy: true, key: sessionKey, sessionId: options.sessionId || null };
67
+ activeSessions.set(sessionKey, entry);
68
+ let sessionId = options.sessionId || null;
69
+ let stderr = '';
70
+ let textOpen = false;
71
+ let resultSeen = false;
72
+ let aborted = false;
73
+ let finalText = '';
74
+ let thoughtOpen = false;
75
+ const emittedTexts = new Set();
76
+
77
+ const closeText = () => {
78
+ if (!textOpen) return;
79
+ textOpen = false;
80
+ send({ type: 'assistant_stream_end', agent: 'cursor' });
81
+ };
82
+ const attachSession = id => {
83
+ if (!id || sessionId === id) return;
84
+ sessionId = id;
85
+ entry.sessionId = id;
86
+ activeSessions.set(id, entry);
87
+ if (!options.sessionId) send({ type: 'session-created', sessionId: id, agent: 'cursor' });
88
+ journal(home, id, { type: 'session_meta', sessionId: id, cwd: options.cwd || null, model: options.model || null, ts: Date.now() });
89
+ journal(home, id, { type: 'user', content: command || '', ts: Date.now() });
90
+ };
91
+ if (options.sessionId) {
92
+ journal(home, options.sessionId, { type: 'session_meta', sessionId: options.sessionId, cwd: options.cwd || null, model: options.model || null, ts: Date.now() });
93
+ journal(home, options.sessionId, { type: 'user', content: command || '', ts: Date.now() });
94
+ }
95
+ const emitDelta = text => {
96
+ if (!text) return;
97
+ if (!textOpen) {
98
+ textOpen = true;
99
+ send({ type: 'assistant_stream_start', agent: 'cursor', ts: Date.now() });
100
+ }
101
+ send({ type: 'assistant_delta', data: text, agent: 'cursor' });
102
+ journal(home, sessionId, { type: 'assistant_delta', data: text, ts: Date.now() });
103
+ };
104
+
105
+ child.stderr.on('data', d => { stderr += String(d); });
106
+ const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
107
+ rl.on('line', line => {
108
+ if (!line.trim()) return;
109
+ let raw;
110
+ try { raw = JSON.parse(line); } catch { return; }
111
+ attachSession(raw.session_id || raw.sessionId);
112
+ if (raw.type === 'thinking' && raw.subtype === 'delta') {
113
+ if (!thoughtOpen) {
114
+ thoughtOpen = true;
115
+ send({ type: 'thought_stream_start', agent: 'cursor', ts: Date.now() });
116
+ }
117
+ send({ type: 'thought_delta', data: raw.text || '', agent: 'cursor' });
118
+ return;
119
+ }
120
+ if (raw.type === 'thinking' && raw.subtype === 'completed' && thoughtOpen) {
121
+ thoughtOpen = false;
122
+ send({ type: 'thought_stream_end', agent: 'cursor' });
123
+ return;
124
+ }
125
+ if (raw.type === 'assistant') {
126
+ const text = textBlocks(raw.message);
127
+ for (const tool of toolBlocks(raw.message)) {
128
+ closeText();
129
+ const input = tool.input ?? tool.args ?? {};
130
+ send({ type: 'assistant', message: { content: [{ type: 'tool_use', name: tool.name || tool.toolName || 'tool', input }] }, agent: 'cursor' });
131
+ journal(home, sessionId, { type: 'tool', name: tool.name || tool.toolName || 'tool', input, ts: Date.now() });
132
+ }
133
+ // Cursor emits a final assistant message twice in stream-json mode.
134
+ // Deltas are authoritative; only emit a complete message if no delta
135
+ // was received for this exact response.
136
+ if (text && !emittedTexts.has(text)) {
137
+ emittedTexts.add(text);
138
+ if (!textOpen) emitDelta(text);
139
+ finalText = text;
140
+ }
141
+ return;
142
+ }
143
+ if (raw.type === 'result') {
144
+ closeText();
145
+ resultSeen = true;
146
+ if (raw.is_error || raw.subtype === 'error') {
147
+ send({ type: 'error', message: raw.result || raw.error || 'Cursor Agent turn failed', agent: 'cursor' });
148
+ } else {
149
+ const u = raw.usage || {};
150
+ const usage = {
151
+ input_tokens: u.inputTokens ?? u.input_tokens ?? 0,
152
+ output_tokens: u.outputTokens ?? u.output_tokens ?? 0,
153
+ cache_read_input_tokens: u.cacheReadTokens ?? u.cache_read_tokens ?? 0,
154
+ cache_write_input_tokens: u.cacheWriteTokens ?? u.cache_write_tokens ?? 0,
155
+ };
156
+ if (raw.result && !finalText) emitDelta(String(raw.result));
157
+ journal(home, sessionId, { type: 'result', usage, ts: Date.now() });
158
+ send({ type: 'result', is_error: false, usage, sessionId: sessionId || sessionKey, agent: 'cursor' });
159
+ }
160
+ } else if (raw.type === 'error') {
161
+ send({ type: 'error', message: raw.error?.message || raw.message || 'Cursor Agent error', agent: 'cursor' });
162
+ }
163
+ });
164
+
165
+ child.on('close', code => {
166
+ rl.close(); closeText();
167
+ if (thoughtOpen) send({ type: 'thought_stream_end', agent: 'cursor' });
168
+ activeSessions.delete(sessionKey);
169
+ if (sessionId) activeSessions.delete(sessionId);
170
+ if (aborted) send({ type: 'complete', exitCode: 130, aborted: true, sessionId: sessionId || sessionKey, agent: 'cursor' });
171
+ else {
172
+ if (code && !resultSeen) send({ type: 'error', message: stderr.trim() || `cursor-agent exited with code ${code}`, agent: 'cursor' });
173
+ send({ type: 'complete', exitCode: code || 0, sessionId: sessionId || sessionKey, agent: 'cursor' });
174
+ }
175
+ resolve();
176
+ });
177
+ child.on('error', error => {
178
+ activeSessions.delete(sessionKey);
179
+ if (sessionId) activeSessions.delete(sessionId);
180
+ send({ type: 'error', message: error.message, agent: 'cursor' });
181
+ resolve();
182
+ });
183
+ entry.abort = () => {
184
+ aborted = true;
185
+ try { child.kill('SIGTERM'); } catch {}
186
+ setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 2000).unref?.();
187
+ };
188
+ });
189
+ }
@@ -0,0 +1,182 @@
1
+ import { spawn } from 'child_process';
2
+ import { promises as fs } from 'fs';
3
+ import path from 'path';
4
+ import readline from 'readline';
5
+ import crypto from 'crypto';
6
+
7
+ // agy (the Antigravity/Gemini CLI) exposes its print mode as NDJSON. Keeping a
8
+ // small, UI-owned journal alongside its native SQLite conversation store gives
9
+ // the web UI reliable history/search support without depending on the CLI's
10
+ // private protobuf schema.
11
+ function journalPath(geminiHome, sessionId) {
12
+ return path.join(geminiHome, 'claude-simple-ui', 'sessions', `${sessionId}.jsonl`);
13
+ }
14
+
15
+ function journal(geminiHome, sessionId, value) {
16
+ if (!sessionId) return;
17
+ const file = journalPath(geminiHome, sessionId);
18
+ fs.mkdir(path.dirname(file), { recursive: true })
19
+ .then(() => fs.appendFile(file, `${JSON.stringify(value)}\n`, 'utf8'))
20
+ .catch(() => {});
21
+ }
22
+
23
+ function isAutoApprove(options) {
24
+ return options.allowDangerouslySkipPermissions
25
+ || ['bypassPermissions', 'acceptEdits', 'auto'].includes(options.permissionMode);
26
+ }
27
+
28
+ function buildArgs(command, options) {
29
+ const args = ['--output-format', 'stream-json'];
30
+ if (options.sessionId) args.push('--conversation', options.sessionId);
31
+ if (options.model) args.push('--model', options.model);
32
+ if (options.effort && ['low', 'medium', 'high'].includes(options.effort)) {
33
+ args.push('--effort', options.effort);
34
+ }
35
+ // In print mode agy otherwise starts command tools in its scratch directory.
36
+ // Adding the requested project preserves normal agent access to the workspace.
37
+ if (options.cwd) args.push('--add-dir', options.cwd);
38
+ if (isAutoApprove(options)) args.push('--dangerously-skip-permissions');
39
+ else if (options.permissionMode === 'plan') args.push('--mode', 'plan');
40
+ else args.push('--mode', 'accept-edits');
41
+ args.push(`--prompt=${command || ''}`);
42
+ return args;
43
+ }
44
+
45
+ function toolInput(update) {
46
+ const info = update.tool_info || update.toolInfo || {};
47
+ return info.parameters || info.input || info.args || {};
48
+ }
49
+
50
+ /** Run a Gemini/Antigravity turn through `agy` print mode. */
51
+ export function runGemini(command, options, send, { cliPath, geminiHome, activeSessions }) {
52
+ return new Promise(resolve => {
53
+ const sessionKey = options.sessionId || crypto.randomUUID();
54
+ const home = geminiHome || path.join(process.env.HOME || '', '.gemini');
55
+ const args = buildArgs(command, options);
56
+ // A resumed native conversation may predate the UI journal. Start recording
57
+ // from this turn so its subsequent history/search/usage is still complete.
58
+ if (options.sessionId) {
59
+ journal(home, options.sessionId, {
60
+ type: 'session_meta', sessionId: options.sessionId, cwd: options.cwd || null,
61
+ model: options.model || null, ts: Date.now(),
62
+ });
63
+ journal(home, options.sessionId, { type: 'user', content: command || '', ts: Date.now() });
64
+ }
65
+ let child;
66
+ try {
67
+ child = spawn(cliPath || 'agy', args, {
68
+ cwd: options.cwd || process.cwd(),
69
+ env: { ...process.env },
70
+ stdio: ['ignore', 'pipe', 'pipe'],
71
+ });
72
+ } catch (error) {
73
+ send({ type: 'error', message: `Failed to start agy: ${error.message}`, agent: 'gemini' });
74
+ resolve();
75
+ return;
76
+ }
77
+
78
+ const entry = {
79
+ kind: 'gemini', child, startTime: Date.now(), busy: true,
80
+ key: sessionKey, sessionId: options.sessionId || null,
81
+ };
82
+ activeSessions.set(sessionKey, entry);
83
+ let sessionId = options.sessionId || null;
84
+ let stderr = '';
85
+ let aborted = false;
86
+ let textOpen = false;
87
+ let resultSeen = false;
88
+
89
+ const closeText = () => {
90
+ if (!textOpen) return;
91
+ textOpen = false;
92
+ send({ type: 'assistant_stream_end', agent: 'gemini' });
93
+ };
94
+ const attachSession = id => {
95
+ if (!id || sessionId === id) return;
96
+ sessionId = id;
97
+ entry.sessionId = id;
98
+ activeSessions.set(id, entry);
99
+ if (!options.sessionId) send({ type: 'session-created', sessionId: id, agent: 'gemini' });
100
+ journal(home, id, {
101
+ type: 'session_meta', sessionId: id, cwd: options.cwd || null,
102
+ model: options.model || null, ts: Date.now(),
103
+ });
104
+ journal(home, id, { type: 'user', content: command || '', ts: Date.now() });
105
+ };
106
+ const usage = raw => ({
107
+ input_tokens: raw?.input_tokens ?? 0,
108
+ output_tokens: raw?.output_tokens ?? 0,
109
+ reasoning_tokens: raw?.thinking_tokens ?? 0,
110
+ cache_read_input_tokens: raw?.cache_read_tokens ?? 0,
111
+ total_tokens: raw?.total_tokens ?? 0,
112
+ });
113
+
114
+ child.stderr.on('data', d => { stderr += String(d); });
115
+ const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
116
+ rl.on('line', line => {
117
+ if (!line.trim()) return;
118
+ let raw;
119
+ try { raw = JSON.parse(line); } catch { return; }
120
+ const id = raw.conversation_id || raw.result?.conversation_id || raw.step_update?.conversation_id;
121
+ attachSession(id);
122
+
123
+ if (raw.event === 'step_update') {
124
+ const update = raw.step_update || {};
125
+ if (update.step_type === 'agent_response' && update.text_delta) {
126
+ if (!textOpen) {
127
+ textOpen = true;
128
+ send({ type: 'assistant_stream_start', agent: 'gemini', ts: Date.now() });
129
+ }
130
+ send({ type: 'assistant_delta', data: update.text_delta, agent: 'gemini' });
131
+ journal(home, sessionId, { type: 'assistant_delta', data: update.text_delta, ts: Date.now() });
132
+ }
133
+ if (update.step_type === 'tool' && update.state === 'ACTIVE') {
134
+ closeText();
135
+ const name = update.tool_name || update.toolName || update.tool_info?.name || 'tool';
136
+ const input = toolInput(update);
137
+ send({ type: 'assistant', message: { content: [{ type: 'tool_use', name, input }] }, agent: 'gemini' });
138
+ journal(home, sessionId, { type: 'tool', name, input, ts: Date.now() });
139
+ }
140
+ return;
141
+ }
142
+ if (raw.event === 'result') {
143
+ closeText();
144
+ resultSeen = true;
145
+ const result = raw.result || {};
146
+ const u = usage(result.usage);
147
+ journal(home, sessionId, { type: 'result', usage: u, ts: Date.now() });
148
+ if (String(result.status || '').toUpperCase() !== 'SUCCESS') {
149
+ send({ type: 'error', message: result.error || result.response || `Gemini turn ${result.status || 'failed'}`, agent: 'gemini' });
150
+ } else {
151
+ send({ type: 'result', is_error: false, usage: u, sessionId: sessionId || sessionKey, agent: 'gemini' });
152
+ }
153
+ } else if (raw.event === 'error') {
154
+ send({ type: 'error', message: raw.error?.message || raw.message || 'Gemini CLI error', agent: 'gemini' });
155
+ }
156
+ });
157
+
158
+ child.on('close', code => {
159
+ rl.close();
160
+ closeText();
161
+ activeSessions.delete(sessionKey);
162
+ if (sessionId) activeSessions.delete(sessionId);
163
+ if (aborted) send({ type: 'complete', exitCode: 130, aborted: true, sessionId: sessionId || sessionKey, agent: 'gemini' });
164
+ else {
165
+ if (code && !resultSeen) send({ type: 'error', message: stderr.trim() || `agy exited with code ${code}`, agent: 'gemini' });
166
+ send({ type: 'complete', exitCode: code || 0, sessionId: sessionId || sessionKey, agent: 'gemini' });
167
+ }
168
+ resolve();
169
+ });
170
+ child.on('error', error => {
171
+ activeSessions.delete(sessionKey);
172
+ if (sessionId) activeSessions.delete(sessionId);
173
+ send({ type: 'error', message: error.message, agent: 'gemini' });
174
+ resolve();
175
+ });
176
+ entry.abort = () => {
177
+ aborted = true;
178
+ try { child.kill('SIGTERM'); } catch {}
179
+ setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 2000).unref?.();
180
+ };
181
+ });
182
+ }
@@ -38,7 +38,7 @@ export const AI_DEFAULTS = {
38
38
  auto_report_days: 1,
39
39
  };
40
40
 
41
- export const DEFAULT_SYSTEM = `你是 Claude Simple UI 的内置工作助手(Meta Agent)。你能访问本机上的 Claude / Codex / Grok 会话、项目笔记、Git 与文件,并生成「我做了啥」的工作报告。
41
+ export const DEFAULT_SYSTEM = `你是 Claude Simple UI 的内置工作助手(Meta Agent)。你能访问本机上的 Claude / Codex / Grok / Gemini 会话、项目笔记、Git 与文件,并生成「我做了啥」的工作报告。
42
42
 
43
43
  规则:
44
44
  1. 需要会话 id / 项目路径时,先 list_projects 或 search_activity / list_sessions,再深入 get_session_brief / get_session_summary。超长会话先读 get_session_brief;只有需要逐事件定位问题时才读 get_session_jsonl。
@@ -73,12 +73,12 @@ const TOOLS = [
73
73
  type: 'function',
74
74
  function: {
75
75
  name: 'list_sessions',
76
- description: '列出最近 agent 会话(Claude/Codex/Grok)元数据',
76
+ description: '列出最近 agent 会话(Claude/Codex/Grok/Gemini)元数据',
77
77
  parameters: {
78
78
  type: 'object',
79
79
  properties: {
80
80
  days: { type: 'integer', description: '只看最近 N 天,0=全部,默认 14' },
81
- agent: { type: 'string', enum: ['claude', 'codex', 'grok'], description: '可选过滤' },
81
+ agent: { type: 'string', enum: ['claude', 'codex', 'grok', 'gemini', 'cursor'], description: '可选过滤' },
82
82
  cwd: { type: 'string', description: '按工作目录过滤' },
83
83
  limit: { type: 'integer', description: '最多返回条数,默认 40' },
84
84
  },
@@ -153,13 +153,13 @@ const TOOLS = [
153
153
  type: 'function',
154
154
  function: {
155
155
  name: 'get_session_jsonl',
156
- description: '读取 Claude/Codex/Grok 指定会话的原始 JSONL 行,用于逐事件、工具调用或失败原因分析;内容可能敏感,只在当前回答中使用',
156
+ description: '读取 Claude/Codex/Grok/Gemini 指定会话的原始 JSONL 行,用于逐事件、工具调用或失败原因分析;内容可能敏感,只在当前回答中使用',
157
157
  parameters: {
158
158
  type: 'object',
159
159
  required: ['sessionId'],
160
160
  properties: {
161
161
  sessionId: { type: 'string' },
162
- agent: { type: 'string', enum: ['claude', 'codex', 'grok'], description: '建议明确指定来源 agent' },
162
+ agent: { type: 'string', enum: ['claude', 'codex', 'grok', 'gemini', 'cursor'], description: '建议明确指定来源 agent' },
163
163
  offset: { type: 'integer', description: '从第几行开始,默认 0' },
164
164
  maxLines: { type: 'integer', description: '每次最多 2000 行,默认 500' },
165
165
  },
@@ -500,6 +500,8 @@ export function createMetaAgent(deps) {
500
500
  claudeConfigDirs,
501
501
  codexHome,
502
502
  grokHome,
503
+ geminiHome,
504
+ cursorHome,
503
505
  } = deps;
504
506
 
505
507
  const configFile = path.join(rootDir, '.ai_config.json');
@@ -1508,6 +1510,8 @@ export function createMetaAgent(deps) {
1508
1510
  claudeConfigDirs: claudeConfigDirs(),
1509
1511
  codexHome: codexHome(),
1510
1512
  grokHome: grokHome(),
1513
+ geminiHome: geminiHome(),
1514
+ cursorHome: cursorHome(),
1511
1515
  };
1512
1516
  }
1513
1517
 
@@ -5,6 +5,7 @@
5
5
  import { promises as fs } from 'fs';
6
6
  import path from 'path';
7
7
  import os from 'os';
8
+ import { execFile } from 'child_process';
8
9
 
9
10
  /** Static fallbacks when local cache/config is missing. */
10
11
  export const FALLBACK_MODELS = {
@@ -37,6 +38,31 @@ export const FALLBACK_MODELS = {
37
38
  { value: 'grok-3-mini', label: 'grok-3-mini' },
38
39
  ],
39
40
  },
41
+ gemini: {
42
+ model: 'gemini-3.7-flash-high',
43
+ models: [
44
+ { value: 'gemini-3.7-flash-high', label: 'Gemini 3.7 Flash (High)' },
45
+ { value: 'gemini-3.7-flash-medium', label: 'Gemini 3.7 Flash (Medium)' },
46
+ { value: 'gemini-3.7-flash-low', label: 'Gemini 3.7 Flash (Low)' },
47
+ { value: 'gemini-3.6-flash-high', label: 'Gemini 3.6 Flash (High)' },
48
+ { value: 'gemini-3.6-flash-medium', label: 'Gemini 3.6 Flash (Medium)' },
49
+ { value: 'gemini-3.6-flash-low', label: 'Gemini 3.6 Flash (Low)' },
50
+ { value: 'gemini-3.5-flash-high', label: 'Gemini 3.5 Flash (High)' },
51
+ { value: 'gemini-3.5-flash-medium', label: 'Gemini 3.5 Flash (Medium)' },
52
+ { value: 'gemini-3.5-flash-low', label: 'Gemini 3.5 Flash (Low)' },
53
+ { value: 'gemini-3.1-pro-high', label: 'Gemini 3.1 Pro (High)' },
54
+ { value: 'gemini-3.1-pro-low', label: 'Gemini 3.1 Pro (Low)' },
55
+ ],
56
+ },
57
+ cursor: {
58
+ model: 'auto',
59
+ models: [
60
+ { value: 'auto', label: 'Auto' },
61
+ { value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex' },
62
+ { value: 'composer-2.5', label: 'Composer 2.5' },
63
+ { value: 'claude-sonnet-5-thinking-high', label: 'Claude Sonnet 5' },
64
+ ],
65
+ },
40
66
  };
41
67
 
42
68
  const DEFAULT_EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
@@ -318,11 +344,89 @@ async function discoverGrok(grokHome) {
318
344
  };
319
345
  }
320
346
 
347
+ let geminiModelsCache = null;
348
+ let geminiModelsCachedAt = 0;
349
+
350
+ async function listAgyModels(cliPath) {
351
+ if (geminiModelsCache && Date.now() - geminiModelsCachedAt < 60_000) return geminiModelsCache;
352
+ // agy renders its model picker through a TTY. `script` supplies a tiny
353
+ // pseudo-terminal while keeping this request non-interactive.
354
+ const command = cliPath || 'agy';
355
+ const scriptArgs = process.platform === 'darwin'
356
+ ? ['-q', '/dev/null', command, 'models']
357
+ : ['-q', '-c', `${command} models`, '/dev/null'];
358
+ const output = await new Promise(resolve => {
359
+ execFile('script', scriptArgs, { timeout: 20_000, maxBuffer: 512 * 1024 }, (error, stdout) => {
360
+ resolve(error ? '' : String(stdout || ''));
361
+ });
362
+ });
363
+ const models = output.split(/\r?\n/)
364
+ .map(line => line.replace(/\x1b\[[0-?]*[ -\/]*[@-~]/g, '').trim())
365
+ .filter(line => /^gemini-/i.test(line))
366
+ .map(line => {
367
+ const [value, ...label] = line.split(/\t+/);
368
+ return { value: value.trim(), label: label.join(' ').trim() || shortLabel(value.trim()) };
369
+ });
370
+ geminiModelsCache = uniqModels(models);
371
+ geminiModelsCachedAt = Date.now();
372
+ return geminiModelsCache;
373
+ }
374
+
375
+ async function discoverGemini(geminiHome, cliPath) {
376
+ const home = geminiHome || path.join(os.homedir(), '.gemini');
377
+ const settings = await readJson(path.join(home, 'antigravity-cli', 'settings.json'))
378
+ || await readJson(path.join(home, 'settings.json'));
379
+ let models = await listAgyModels(cliPath);
380
+ let source = models.length ? 'agy' : 'fallback';
381
+ if (!models.length) models = FALLBACK_MODELS.gemini.models.map(m => ({ ...m }));
382
+ const configured = typeof settings?.model === 'string' ? settings.model.trim() : null;
383
+ const selected = configured && models.find(m => m.value === configured || m.label === configured);
384
+ const defaultModel = selected?.value || models[0]?.value || FALLBACK_MODELS.gemini.model;
385
+ if (configured) source += '+settings';
386
+ return {
387
+ model: defaultModel,
388
+ models: uniqModels(ensureDefaultInList(models, defaultModel)),
389
+ efforts: ['low', 'medium', 'high'],
390
+ effortDefault: defaultModel.match(/-(low|medium|high)$/i)?.[1] || null,
391
+ source,
392
+ };
393
+ }
394
+
395
+ let cursorModelsCache = null;
396
+ let cursorModelsCachedAt = 0;
397
+
398
+ async function listCursorModels(cliPath) {
399
+ if (cursorModelsCache && Date.now() - cursorModelsCachedAt < 60_000) return cursorModelsCache;
400
+ const output = await new Promise(resolve => {
401
+ execFile(cliPath || 'cursor-agent', ['--list-models'], { timeout: 20_000, maxBuffer: 1024 * 1024 }, (error, stdout) => {
402
+ resolve(error ? '' : String(stdout || ''));
403
+ });
404
+ });
405
+ const models = output.split(/\r?\n/)
406
+ .map(line => line.trim())
407
+ .map(line => {
408
+ const m = /^([^\s]+)\s+-\s+(.+)$/.exec(line);
409
+ return m ? { value: m[1], label: m[2] } : null;
410
+ })
411
+ .filter(Boolean);
412
+ cursorModelsCache = uniqModels(models);
413
+ cursorModelsCachedAt = Date.now();
414
+ return cursorModelsCache;
415
+ }
416
+
417
+ async function discoverCursor(cursorHome, cliPath) {
418
+ let models = await listCursorModels(cliPath);
419
+ const source = models.length ? 'cursor-agent' : 'fallback';
420
+ if (!models.length) models = FALLBACK_MODELS.cursor.models.map(m => ({ ...m }));
421
+ const defaultModel = models.find(m => m.value === 'auto')?.value || models[0]?.value || FALLBACK_MODELS.cursor.model;
422
+ return { model: defaultModel, models: uniqModels(ensureDefaultInList(models, defaultModel)), efforts: [], source };
423
+ }
424
+
321
425
  /**
322
- * @param {{ claudeConfigDirs?: string[], codexHome?: string, grokHome?: string, agents?: string[] }} opts
426
+ * @param {{ claudeConfigDirs?: string[], codexHome?: string, grokHome?: string, geminiHome?: string, geminiCliPath?: string, cursorHome?: string, cursorAgentPath?: string, agents?: string[] }} opts
323
427
  */
324
428
  export async function discoverAllAgentModels(opts = {}) {
325
- const agents = opts.agents?.length ? opts.agents : ['claude', 'codex', 'grok'];
429
+ const agents = opts.agents?.length ? opts.agents : ['claude', 'codex', 'grok', 'gemini', 'cursor'];
326
430
  const want = new Set(agents);
327
431
  const defaults = {};
328
432
 
@@ -335,6 +439,12 @@ export async function discoverAllAgentModels(opts = {}) {
335
439
  if (want.has('grok')) {
336
440
  defaults.grok = await discoverGrok(opts.grokHome);
337
441
  }
442
+ if (want.has('gemini')) {
443
+ defaults.gemini = await discoverGemini(opts.geminiHome, opts.geminiCliPath);
444
+ }
445
+ if (want.has('cursor')) {
446
+ defaults.cursor = await discoverCursor(opts.cursorHome, opts.cursorAgentPath);
447
+ }
338
448
 
339
449
  return defaults;
340
450
  }