idea-manager 0.9.7 → 1.0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idea-manager",
3
- "version": "0.9.7",
3
+ "version": "1.0.0",
4
4
  "description": "Turn free-form brainstorming into structured task trees with AI-generated prompts. Built-in MCP Server for autonomous AI agent execution. Local-first with SQLite, cross-PC sync via Git.",
5
5
  "keywords": [
6
6
  "brainstorm",
@@ -4,7 +4,7 @@ import { getTask } from '@/lib/db/queries/tasks';
4
4
  import { getTaskPrompt } from '@/lib/db/queries/task-prompts';
5
5
  import { getBrainstorm } from '@/lib/db/queries/brainstorms';
6
6
  import { getProject } from '@/lib/db/queries/projects';
7
- import { runClaude } from '@/lib/ai/client';
7
+ import { runAgent } from '@/lib/ai/client';
8
8
 
9
9
  export async function GET(
10
10
  _request: NextRequest,
@@ -55,7 +55,8 @@ ${brainstorm?.content ? `\nBrainstorming context:\n${brainstorm.content.slice(0,
55
55
  .join('\n');
56
56
 
57
57
  try {
58
- const aiResponse = await runClaude(`${systemPrompt}\n\nConversation:\n${conversationText}`);
58
+ const agentType = project?.agent_type || 'claude';
59
+ const aiResponse = await runAgent(agentType, `${systemPrompt}\n\nConversation:\n${conversationText}`);
59
60
  const trimmed = aiResponse.trim();
60
61
  if (!trimmed) {
61
62
  const fallbackMsg = addTaskConversation(taskId, 'assistant', '(AI 응답을 생성하지 못했습니다. 다시 시도해주세요.)');
package/src/cli.ts CHANGED
@@ -22,7 +22,7 @@ try {
22
22
  }
23
23
 
24
24
  async function openAsApp(url: string) {
25
- const { execFile: execFileCb } = await import('child_process');
25
+ const { spawn: spawnChild } = await import('child_process');
26
26
  const fs = await import('fs');
27
27
  const platform = process.platform;
28
28
 
@@ -47,23 +47,18 @@ async function openAsApp(url: string) {
47
47
  ];
48
48
 
49
49
  for (const browser of browsers) {
50
- // On macOS, check binary exists before trying
51
50
  if (platform === 'darwin' && !fs.existsSync(browser.bin)) continue;
52
51
 
53
52
  try {
54
- await new Promise<void>((resolve, reject) => {
55
- const child = execFileCb(browser.bin, browser.args, {
56
- shell: platform === 'win32',
57
- detached: true,
58
- stdio: 'ignore',
59
- }, (err) => { if (err) reject(err); });
60
- child.unref();
61
- // Browser launched — resolve after brief delay
62
- setTimeout(resolve, 500);
53
+ const child = spawnChild(browser.bin, browser.args, {
54
+ detached: true,
55
+ stdio: 'ignore',
56
+ shell: platform === 'win32',
63
57
  });
58
+ child.unref();
64
59
  return; // success
65
60
  } catch {
66
- continue; // try next browser
61
+ continue;
67
62
  }
68
63
  }
69
64
 
@@ -19,6 +19,7 @@ interface IProject {
19
19
  project_path: string | null;
20
20
  ai_context: string;
21
21
  watch_enabled: boolean;
22
+ agent_type: string;
22
23
  }
23
24
 
24
25
  export default function WorkspacePanel({
@@ -397,6 +398,23 @@ export default function WorkspacePanel({
397
398
  )}
398
399
  </div>
399
400
  <div className="flex items-center gap-2">
401
+ <select
402
+ value={project.agent_type || 'claude'}
403
+ onChange={async (e) => {
404
+ const res = await fetch(`/api/projects/${id}`, {
405
+ method: 'PUT',
406
+ headers: { 'Content-Type': 'application/json' },
407
+ body: JSON.stringify({ agent_type: e.target.value }),
408
+ });
409
+ if (res.ok) setProject(await res.json());
410
+ }}
411
+ className="px-2 py-1.5 text-xs bg-muted border border-border rounded-md text-foreground cursor-pointer hover:bg-card-hover transition-colors"
412
+ title="AI Agent"
413
+ >
414
+ <option value="claude">Claude</option>
415
+ <option value="gemini">Gemini</option>
416
+ <option value="codex">Codex</option>
417
+ </select>
400
418
  <button onClick={handleToggleWatch}
401
419
  className={`px-3 py-1.5 text-xs border rounded-md transition-colors flex items-center gap-1.5 ${
402
420
  project.watch_enabled
@@ -0,0 +1,102 @@
1
+ import type { AgentType } from '../../types';
2
+
3
+ export interface AgentConfig {
4
+ name: string;
5
+ binary: string;
6
+ buildArgs: (opts: { streaming: boolean }) => string[];
7
+ buildEnv: () => NodeJS.ProcessEnv;
8
+ parseStreamEvent: (parsed: Record<string, unknown>) => { text?: string; final?: string } | null;
9
+ cleanOutput?: (text: string) => string;
10
+ }
11
+
12
+ const claudeConfig: AgentConfig = {
13
+ name: 'Claude',
14
+ binary: 'claude',
15
+ buildArgs: ({ streaming }) => [
16
+ '--dangerously-skip-permissions',
17
+ '--model', 'sonnet',
18
+ ...(streaming
19
+ ? ['--output-format', 'stream-json', '--verbose']
20
+ : ['--output-format', 'text']),
21
+ '--max-turns', '80',
22
+ '-p', '-',
23
+ ],
24
+ buildEnv: () => {
25
+ const env = { ...process.env };
26
+ delete env.CLAUDECODE;
27
+ delete env.CLAUDE_CODE_ENTRYPOINT;
28
+ delete env.CLAUDE_CODE_MAX_OUTPUT_TOKENS;
29
+ for (const key of Object.keys(env)) {
30
+ if (key.startsWith('CLAUDE_CODE_') || key === 'ANTHROPIC_PARENT_SESSION') {
31
+ delete env[key];
32
+ }
33
+ }
34
+ return { ...env, FORCE_COLOR: '0' };
35
+ },
36
+ parseStreamEvent: (parsed) => {
37
+ if (parsed.type === 'content_block_delta' && (parsed.delta as Record<string, unknown>)?.text) {
38
+ return { text: (parsed.delta as Record<string, unknown>).text as string };
39
+ }
40
+ if (parsed.type === 'assistant' && (parsed.message as Record<string, unknown>)?.content) {
41
+ let t = '';
42
+ for (const b of (parsed.message as Record<string, unknown>).content as { type: string; text?: string }[]) {
43
+ if (b.type === 'text') t += b.text;
44
+ }
45
+ return { final: t };
46
+ }
47
+ if (parsed.type === 'result' && parsed.result) {
48
+ return { final: parsed.result as string };
49
+ }
50
+ return null;
51
+ },
52
+ cleanOutput: (text) => text.replace(/Error: Reached max turns \(\d+\)\s*/g, '').trim(),
53
+ };
54
+
55
+ const geminiConfig: AgentConfig = {
56
+ name: 'Gemini',
57
+ binary: 'gemini',
58
+ buildArgs: ({ streaming }) => [
59
+ '--yolo',
60
+ ...(streaming
61
+ ? ['--output-format', 'stream-json']
62
+ : ['--output-format', 'json']),
63
+ '-p', '-',
64
+ ],
65
+ buildEnv: () => ({ ...process.env, FORCE_COLOR: '0' }),
66
+ parseStreamEvent: (parsed) => {
67
+ if (parsed.type === 'content_block_delta' && (parsed.delta as Record<string, unknown>)?.text) {
68
+ return { text: (parsed.delta as Record<string, unknown>).text as string };
69
+ }
70
+ if (parsed.type === 'result') {
71
+ return { final: (parsed.response || parsed.text || parsed.result) as string };
72
+ }
73
+ return null;
74
+ },
75
+ };
76
+
77
+ const codexConfig: AgentConfig = {
78
+ name: 'Codex',
79
+ binary: 'codex',
80
+ buildArgs: ({ streaming }) => [
81
+ 'exec',
82
+ '--full-auto',
83
+ ...(streaming ? ['--json'] : []),
84
+ '-',
85
+ ],
86
+ buildEnv: () => ({ ...process.env, FORCE_COLOR: '0' }),
87
+ parseStreamEvent: (parsed) => {
88
+ if (parsed.type === 'item.completed' && (parsed.item as Record<string, unknown>)?.type === 'agent_message') {
89
+ return { final: (parsed.item as Record<string, unknown>).text as string };
90
+ }
91
+ if (parsed.type === 'item.updated' && (parsed.item as Record<string, unknown>)?.type === 'agent_message') {
92
+ return { text: (parsed.item as Record<string, unknown>).text as string };
93
+ }
94
+ return null;
95
+ },
96
+ };
97
+
98
+ export const AGENTS: Record<AgentType, AgentConfig> = {
99
+ claude: claudeConfig,
100
+ gemini: geminiConfig,
101
+ codex: codexConfig,
102
+ };
@@ -1,48 +1,41 @@
1
1
  import { spawn } from 'node:child_process';
2
-
3
- const CLI_PATH = 'claude';
4
- const DEFAULT_ARGS = ['--dangerously-skip-permissions'];
5
- const MODEL = 'sonnet';
6
- const MAX_TURNS = 80;
2
+ import { AGENTS } from './agents';
3
+ import type { AgentType } from '../../types';
7
4
 
8
5
  export type OnTextChunk = (text: string) => void;
9
6
  export type OnRawEvent = (event: Record<string, unknown>) => void;
10
7
 
11
- export interface RunClaudeOptions {
8
+ export interface RunAgentOptions {
12
9
  cwd?: string;
13
10
  timeoutMs?: number;
14
11
  }
15
12
 
16
13
  /**
17
- * Spawn Claude Code CLI and collect the result text.
14
+ * Spawn an AI CLI agent and collect the result text.
18
15
  * Optional onText callback receives streaming text chunks as they arrive.
19
16
  */
20
- export function runClaude(prompt: string, onText?: OnTextChunk, onRawEvent?: OnRawEvent, options?: RunClaudeOptions): Promise<string> {
17
+ export function runAgent(
18
+ agentType: AgentType,
19
+ prompt: string,
20
+ onText?: OnTextChunk,
21
+ onRawEvent?: OnRawEvent,
22
+ options?: RunAgentOptions,
23
+ ): Promise<string> {
24
+ const config = AGENTS[agentType];
25
+ if (!config) {
26
+ return Promise.reject(new Error(`Unknown agent type: ${agentType}`));
27
+ }
28
+
21
29
  return new Promise((resolve, reject) => {
22
30
  const useStreamJson = !!(onText || onRawEvent);
23
- const args = [
24
- ...DEFAULT_ARGS,
25
- '--model', MODEL,
26
- ...(useStreamJson ? ['--output-format', 'stream-json', '--verbose'] : ['--output-format', 'text']),
27
- '--max-turns', String(MAX_TURNS),
28
- '-p', '-', // read prompt from stdin
29
- ];
30
-
31
- // Strip Claude Code session env vars to avoid nested session detection
32
- const cleanEnv = { ...process.env };
33
- delete cleanEnv.CLAUDECODE;
34
- delete cleanEnv.CLAUDE_CODE_ENTRYPOINT;
35
- delete cleanEnv.CLAUDE_CODE_MAX_OUTPUT_TOKENS;
36
- for (const key of Object.keys(cleanEnv)) {
37
- if (key.startsWith('CLAUDE_CODE_') || key === 'ANTHROPIC_PARENT_SESSION') {
38
- delete cleanEnv[key];
39
- }
40
- }
31
+ const args = config.buildArgs({ streaming: useStreamJson });
32
+ const env = config.buildEnv();
41
33
 
42
- const proc = spawn(CLI_PATH, args, {
34
+ const proc = spawn(config.binary, args, {
43
35
  cwd: options?.cwd || process.cwd(),
44
36
  stdio: ['pipe', 'pipe', 'pipe'],
45
- env: { ...cleanEnv, FORCE_COLOR: '0' },
37
+ shell: process.platform === 'win32',
38
+ env,
46
39
  });
47
40
 
48
41
  // Timeout handling
@@ -62,6 +55,7 @@ export function runClaude(prompt: string, onText?: OnTextChunk, onRawEvent?: OnR
62
55
  let buffer = '';
63
56
  let resultText = '';
64
57
  let stderrText = '';
58
+ let lastEmittedLength = 0;
65
59
 
66
60
  if (useStreamJson) {
67
61
  // stream-json mode: parse NDJSON events
@@ -77,23 +71,21 @@ export function runClaude(prompt: string, onText?: OnTextChunk, onRawEvent?: OnR
77
71
  const parsed = JSON.parse(trimmed);
78
72
  onRawEvent?.(parsed);
79
73
 
80
- if (parsed.type === 'content_block_delta' && parsed.delta?.text) {
81
- resultText += parsed.delta.text;
82
- onText?.(parsed.delta.text);
83
- } else if (parsed.type === 'assistant' && parsed.message?.content) {
84
- let fullText = '';
85
- for (const block of parsed.message.content) {
86
- if (block.type === 'text') fullText += block.text;
74
+ const event = config.parseStreamEvent(parsed);
75
+ if (event) {
76
+ if (event.final) {
77
+ // Some agents emit cumulative text, emit only new portion
78
+ if (event.final.length > lastEmittedLength) {
79
+ const newPart = event.final.slice(lastEmittedLength);
80
+ onText?.(newPart);
81
+ lastEmittedLength = event.final.length;
82
+ }
83
+ resultText = event.final;
84
+ } else if (event.text) {
85
+ resultText += event.text;
86
+ lastEmittedLength = resultText.length;
87
+ onText?.(event.text);
87
88
  }
88
- if (fullText.length > resultText.length) {
89
- onText?.(fullText.slice(resultText.length));
90
- }
91
- resultText = fullText;
92
- } else if (parsed.type === 'result' && parsed.result) {
93
- if (parsed.result.length > resultText.length) {
94
- onText?.(parsed.result.slice(resultText.length));
95
- }
96
- resultText = parsed.result;
97
89
  }
98
90
  } catch { /* ignore non-JSON */ }
99
91
  }
@@ -110,25 +102,29 @@ export function runClaude(prompt: string, onText?: OnTextChunk, onRawEvent?: OnR
110
102
  });
111
103
 
112
104
  proc.on('error', (err) => {
113
- reject(new Error(`Claude CLI error: ${err.message}`));
105
+ reject(new Error(`${config.name} CLI error: ${err.message}`));
114
106
  });
115
107
 
116
108
  proc.on('exit', (code, signal) => {
117
109
  if (timeoutTimer) clearTimeout(timeoutTimer);
118
- // Clean up known CLI noise from text output
119
- if (!useStreamJson) {
120
- resultText = resultText.replace(/Error: Reached max turns \(\d+\)\s*/g, '').trim();
110
+ if (!useStreamJson && config.cleanOutput) {
111
+ resultText = config.cleanOutput(resultText);
121
112
  }
122
113
  if (timedOut) {
123
- reject(new Error(`Claude CLI timed out after ${Math.round((options?.timeoutMs || 0) / 1000)}s`));
114
+ reject(new Error(`${config.name} CLI timed out after ${Math.round((options?.timeoutMs || 0) / 1000)}s`));
124
115
  return;
125
116
  }
126
117
  if (code !== 0 && !resultText) {
127
118
  const detail = stderrText.slice(0, 500) || (signal ? `killed by signal ${signal}` : 'no output');
128
- reject(new Error(`Claude CLI exited with code ${code}: ${detail}`));
119
+ reject(new Error(`${config.name} CLI exited with code ${code}: ${detail}`));
129
120
  return;
130
121
  }
131
122
  resolve(resultText);
132
123
  });
133
124
  });
134
125
  }
126
+
127
+ // Backward compatibility
128
+ export function runClaude(prompt: string, onText?: OnTextChunk, onRawEvent?: OnRawEvent, options?: RunAgentOptions): Promise<string> {
129
+ return runAgent('claude', prompt, onText, onRawEvent, options);
130
+ }
@@ -1,6 +1,6 @@
1
1
  import { getDb } from '../index';
2
2
  import { generateId } from '../../utils/id';
3
- import type { IProject } from '../../../types';
3
+ import type { IProject, AgentType } from '../../../types';
4
4
 
5
5
  interface ProjectRow {
6
6
  id: string;
@@ -9,12 +9,13 @@ interface ProjectRow {
9
9
  project_path: string | null;
10
10
  ai_context: string;
11
11
  watch_enabled: number;
12
+ agent_type: string;
12
13
  created_at: string;
13
14
  updated_at: string;
14
15
  }
15
16
 
16
17
  function rowToProject(row: ProjectRow): IProject {
17
- return { ...row, watch_enabled: row.watch_enabled === 1 };
18
+ return { ...row, watch_enabled: row.watch_enabled === 1, agent_type: (row.agent_type || 'claude') as AgentType };
18
19
  }
19
20
 
20
21
  export function listProjects(): IProject[] {
@@ -47,7 +48,7 @@ export function createProject(name: string, description: string = '', projectPat
47
48
  return getProject(id)!;
48
49
  }
49
50
 
50
- export function updateProject(id: string, data: { name?: string; description?: string; project_path?: string | null; ai_context?: string; watch_enabled?: boolean }): IProject | undefined {
51
+ export function updateProject(id: string, data: { name?: string; description?: string; project_path?: string | null; ai_context?: string; watch_enabled?: boolean; agent_type?: AgentType }): IProject | undefined {
51
52
  const db = getDb();
52
53
  const project = getProject(id);
53
54
  if (!project) return undefined;
@@ -55,13 +56,14 @@ export function updateProject(id: string, data: { name?: string; description?: s
55
56
  const now = new Date().toISOString();
56
57
 
57
58
  db.prepare(
58
- 'UPDATE projects SET name = ?, description = ?, project_path = ?, ai_context = ?, watch_enabled = ?, updated_at = ? WHERE id = ?'
59
+ 'UPDATE projects SET name = ?, description = ?, project_path = ?, ai_context = ?, watch_enabled = ?, agent_type = ?, updated_at = ? WHERE id = ?'
59
60
  ).run(
60
61
  data.name ?? project.name,
61
62
  data.description ?? project.description,
62
63
  data.project_path !== undefined ? data.project_path : project.project_path,
63
64
  data.ai_context !== undefined ? data.ai_context : (project.ai_context ?? ''),
64
65
  data.watch_enabled !== undefined ? (data.watch_enabled ? 1 : 0) : (project.watch_enabled ? 1 : 0),
66
+ data.agent_type ?? project.agent_type ?? 'claude',
65
67
  now,
66
68
  id,
67
69
  );
@@ -33,6 +33,9 @@ export function initSchema(db: Database.Database): void {
33
33
  if (!projCols.some(c => c.name === 'watch_enabled')) {
34
34
  db.exec("ALTER TABLE projects ADD COLUMN watch_enabled INTEGER NOT NULL DEFAULT 0");
35
35
  }
36
+ if (!projCols.some(c => c.name === 'agent_type')) {
37
+ db.exec("ALTER TABLE projects ADD COLUMN agent_type TEXT NOT NULL DEFAULT 'claude'");
38
+ }
36
39
 
37
40
  // v2 tables
38
41
  db.exec(`
@@ -1,5 +1,5 @@
1
1
  import fs from 'fs';
2
- import { runClaude } from './ai/client';
2
+ import { runAgent } from './ai/client';
3
3
  import { listProjects, getProject } from './db/queries/projects';
4
4
  import { getSubProject } from './db/queries/sub-projects';
5
5
  import { getTasksByProject, getTask, updateTask } from './db/queries/tasks';
@@ -121,7 +121,7 @@ async function executeTask(task: ITask, project: IProject, options: WatcherOptio
121
121
  }
122
122
  };
123
123
 
124
- const result = await runClaude(fullPrompt, onText, undefined, {
124
+ const result = await runAgent(project.agent_type || 'claude', fullPrompt, onText, undefined, {
125
125
  cwd,
126
126
  timeoutMs: options.timeoutMs,
127
127
  });
@@ -1,3 +1,5 @@
1
+ export type AgentType = 'claude' | 'gemini' | 'codex';
2
+
1
3
  export interface IProject {
2
4
  id: string;
3
5
  name: string;
@@ -5,6 +7,7 @@ export interface IProject {
5
7
  project_path: string | null;
6
8
  ai_context: string;
7
9
  watch_enabled: boolean;
10
+ agent_type: AgentType;
8
11
  created_at: string;
9
12
  updated_at: string;
10
13
  }