ikie-cli 0.1.20 → 0.1.22

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/dist/agent.d.ts CHANGED
@@ -68,6 +68,8 @@ export declare class Agent {
68
68
  private callModelStreaming;
69
69
  private callModelNonStreaming;
70
70
  private handleToolCall;
71
+ private handleSwitchMode;
72
+ private requestModeSwitch;
71
73
  private askUser;
72
74
  private runSubagent;
73
75
  getLastAssistantText(): string;
package/dist/agent.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import chalk from 'chalk';
2
2
  import * as readline from 'node:readline';
3
- import { TOOL_DEFS, SAFE_TOOLS, PLAN_TOOLS, formatToolArgs, executeTool } from './tools.js';
3
+ import { TOOL_DEFS, SAFE_TOOLS, PLAN_TOOLS, formatToolArgs, executeTool, isRestrictedPath } from './tools.js';
4
4
  import { renderMarkdown, extractThinkTags } from './renderer.js';
5
5
  import { c, toolLine, permissionPrompt, toolSuccessLine, toolErrorLine, toolOutputBlock, toolDiffBlock, InlineSpinner } from './theme.js';
6
6
  export function estimateTokens(chars) {
@@ -277,7 +277,7 @@ export class Agent {
277
277
  this.activeTurnStats.toolCalls += group.length;
278
278
  const summary = this.formatGroupSummary(group[0].name, inputs);
279
279
  process.stdout.write(`\n${this.indent}${toolLine(`${group[0].name} ×${group.length}`, summary).trimStart()}\n`);
280
- if (!opts.autoApprove && !this.config.autoApprove && !SAFE_TOOLS.has(group[0].name)) {
280
+ if (!opts.autoApprove && !this.config.autoApprove && !SAFE_TOOLS.has(group[0].name) && group[0].name !== 'switch_mode') {
281
281
  const allowed = await this.checkPermission(group[0].name, inputs[0]);
282
282
  if (!allowed) {
283
283
  for (const tc of group) {
@@ -291,26 +291,48 @@ export class Agent {
291
291
  }
292
292
  const t0 = Date.now();
293
293
  let errors = 0;
294
+ // Separate subagents so they can run in parallel; keep other tools sequential
295
+ // to avoid races on file mutations.
296
+ const spawnIndices = [];
297
+ const otherIndices = [];
294
298
  for (let i = 0; i < group.length; i++) {
299
+ if (group[i].name === 'spawn_agent')
300
+ spawnIndices.push(i);
301
+ else
302
+ otherIndices.push(i);
303
+ }
304
+ const results = new Map();
305
+ if (spawnIndices.length > 0) {
306
+ const spawnResults = await Promise.all(spawnIndices.map(i => this.runSubagent(inputs[i], opts)));
307
+ spawnIndices.forEach((idx, i) => results.set(idx, spawnResults[i]));
308
+ }
309
+ for (const i of otherIndices) {
295
310
  if (opts.signal?.aborted)
296
311
  break;
297
312
  const tc = group[i];
298
- if (tc.name === 'spawn_agent') {
299
- const result = await this.runSubagent(inputs[i], opts);
300
- this.conversation.push({ role: 'tool', tool_call_id: tc.id, content: result });
301
- }
302
- else {
303
- try {
304
- const result = await executeTool(tc.name, inputs[i]);
305
- if (result.startsWith('Error'))
306
- errors++;
307
- this.recordChangedFile(tc.name, inputs[i], result);
308
- this.conversation.push({ role: 'tool', tool_call_id: tc.id, content: result });
313
+ try {
314
+ if (tc.name === 'read_file' && isRestrictedPath(String(inputs[i].path ?? ''))) {
315
+ const allowed = await this.checkPermission('read_file', inputs[i]);
316
+ if (!allowed) {
317
+ results.set(i, `Tool execution denied by user: read_file ${inputs[i].path}`);
318
+ continue;
319
+ }
309
320
  }
310
- catch (err) {
321
+ const result = await executeTool(tc.name, inputs[i]);
322
+ if (result.startsWith('Error'))
311
323
  errors++;
312
- this.conversation.push({ role: 'tool', tool_call_id: tc.id, content: `Tool error: ${err}` });
313
- }
324
+ this.recordChangedFile(tc.name, inputs[i], result);
325
+ results.set(i, result);
326
+ }
327
+ catch (err) {
328
+ errors++;
329
+ results.set(i, `Tool error: ${err}`);
330
+ }
331
+ }
332
+ for (let i = 0; i < group.length; i++) {
333
+ const result = results.get(i);
334
+ if (result !== undefined) {
335
+ this.conversation.push({ role: 'tool', tool_call_id: group[i].id, content: result });
314
336
  }
315
337
  }
316
338
  const ms = Date.now() - t0;
@@ -338,12 +360,19 @@ export class Agent {
338
360
  let tools = this.depth >= 1
339
361
  ? TOOL_DEFS.filter(t => t.function.name !== 'spawn_agent')
340
362
  : TOOL_DEFS;
363
+ // Always include switch_mode so the agent can request a mode change.
364
+ const switchModeTool = TOOL_DEFS.find(t => t.function.name === 'switch_mode');
341
365
  // Plan mode: only offer read-only tools, and steer toward proposing a plan.
342
366
  let systemContent = this.systemPrompt;
343
367
  if (this.mode === 'plan') {
344
- tools = tools.filter(t => PLAN_TOOLS.has(t.function.name));
368
+ tools = tools.filter(t => PLAN_TOOLS.has(t.function.name) || t.function.name === 'switch_mode');
369
+ if (switchModeTool && !tools.includes(switchModeTool))
370
+ tools.push(switchModeTool);
345
371
  systemContent += PLAN_MODE_ADDENDUM;
346
372
  }
373
+ else if (switchModeTool && !tools.includes(switchModeTool)) {
374
+ tools.push(switchModeTool);
375
+ }
347
376
  return {
348
377
  model: this.config.model,
349
378
  max_tokens: this.config.maxTokens,
@@ -528,12 +557,26 @@ export class Agent {
528
557
  }
529
558
  // ── Tool execution ────────────────────────────────────────────────────────
530
559
  async handleToolCall(name, id, input, opts) {
560
+ if (name === 'switch_mode') {
561
+ return this.handleSwitchMode(input);
562
+ }
531
563
  if (name === 'spawn_agent') {
532
564
  return this.runSubagent(input, opts);
533
565
  }
534
566
  if (name === 'ask_user') {
535
567
  return this.askUser(input);
536
568
  }
569
+ if (name === 'read_file') {
570
+ const path = String(input.path ?? '');
571
+ if (isRestrictedPath(path) && !opts.autoApprove && !this.config.autoApprove && !this.sessionAllowList.has('read_file')) {
572
+ if (this.sessionDenyList.has('read_file'))
573
+ return `Tool execution denied by user: read_file ${path}`;
574
+ process.stdout.write(`${this.indent}${c.warning('⚠')} ${c.muted('Restricted file')} ${c.white(path)} ${c.muted('— asking for permission')}\n`);
575
+ const allowed = await this.checkPermission('read_file', input);
576
+ if (!allowed)
577
+ return `Tool execution denied by user: read_file ${path}`;
578
+ }
579
+ }
537
580
  if (!opts.autoApprove && !this.config.autoApprove && !SAFE_TOOLS.has(name)) {
538
581
  const allowed = await this.checkPermission(name, input);
539
582
  if (!allowed)
@@ -563,6 +606,63 @@ export class Agent {
563
606
  return msg;
564
607
  }
565
608
  }
609
+ async handleSwitchMode(input) {
610
+ if (this.depth > 0) {
611
+ return 'Error: subagents cannot switch mode. Return your findings and let the main agent decide.';
612
+ }
613
+ const mode = input.mode;
614
+ const reason = (input.reason ?? '').trim();
615
+ if (!mode || (mode !== 'plan' && mode !== 'agent')) {
616
+ return 'Error: switch_mode requires mode "plan" or "agent".';
617
+ }
618
+ if (mode === this.mode) {
619
+ return `Already in ${mode} mode.`;
620
+ }
621
+ const allowed = await this.requestModeSwitch(mode, reason);
622
+ if (!allowed) {
623
+ return `Mode switch to ${mode} denied by user.`;
624
+ }
625
+ this.setMode(mode);
626
+ return `Switched to ${mode} mode.`;
627
+ }
628
+ async requestModeSwitch(mode, reason) {
629
+ process.stdout.write(`\n ${c.primary('◆')} ${c.white.bold('mode switch')} ${c.muted('·')} ${c.white(`to ${mode}`)}\n` +
630
+ ` ${c.muted('reason:')} ${c.dim(reason)}\n` +
631
+ ` ${c.muted('⎿')} ${c.success.bold('y')} ${c.muted('allow')} ${c.error.bold('n')} ${c.muted('deny')}\n` +
632
+ ` ${c.muted('❯')} `);
633
+ return new Promise((resolve) => {
634
+ if (!process.stdin.isTTY) {
635
+ process.stdout.write(chalk.dim('(non-interactive, denying)\n'));
636
+ resolve(false);
637
+ return;
638
+ }
639
+ const wasRaw = process.stdin.isRaw ?? false;
640
+ const savedDataListeners = process.stdin.rawListeners('data').slice();
641
+ const savedKeypressListeners = process.stdin.rawListeners('keypress').slice();
642
+ process.stdin.removeAllListeners('data');
643
+ process.stdin.removeAllListeners('keypress');
644
+ if (process.stdin.isTTY) {
645
+ process.stdin.setRawMode(true);
646
+ process.stdin.resume();
647
+ }
648
+ const onData = (data) => {
649
+ process.stdin.removeListener('data', onData);
650
+ if (process.stdin.isTTY)
651
+ process.stdin.setRawMode(wasRaw);
652
+ restoreStdinListeners(savedDataListeners, savedKeypressListeners);
653
+ const key = data.toString().toLowerCase();
654
+ if (key === 'y' || key === '\r' || key === '\n') {
655
+ process.stdout.write(chalk.green('y\n'));
656
+ resolve(true);
657
+ }
658
+ else {
659
+ process.stdout.write(chalk.red('n\n'));
660
+ resolve(false);
661
+ }
662
+ };
663
+ process.stdin.on('data', onData);
664
+ });
665
+ }
566
666
  async askUser(input) {
567
667
  const question = (input.question ?? '').trim();
568
668
  if (!question)
@@ -823,14 +923,17 @@ changes they didn't ask for.
823
923
  - **Trust fetched page content over snippet summaries** — snippets can be stale; the live page is authoritative.
824
924
  - **Never state a version, date, or fact as definitive if your search results conflict** — say what
825
925
  the most recent source says and link it.
826
- - \`ask_user\`: Ask the user a clarifying question when you need more info to proceed.
827
- The user's answer is returned as the tool result. Use sparingly — only when genuinely
828
- unsure. Don't ask for confirmation on safe operations.
829
926
  - \`spawn_agent\`: Delegate a self-contained subtask to a focused sub-agent. Use this
830
927
  to parallelize or isolate work — e.g. "investigate how auth is implemented and report
831
928
  back", or "write and run tests for module X". The sub-agent has the same tools (except
832
929
  it cannot spawn further sub-agents) and returns a summary. Give it a clear, complete
833
- \`task\` and any needed \`context\`, since it does not see this conversation.`,
930
+ \`task\` and any needed \`context\`, since it does not see this conversation.
931
+ - \`switch_mode\`: Request permission to switch between plan and agent mode. Use when the current
932
+ mode is not sufficient for what you need to do next. The user must approve the switch.
933
+ - \`ask_user\`: Ask the user a clarifying question when you need more info to proceed.
934
+ The user's answer is returned as the tool result. Use sparingly — only when genuinely
935
+ unsure. Don't ask for confirmation on safe operations.
936
+ `,
834
937
  ];
835
938
  if (projectContext)
836
939
  parts.push(`## Project Context\n${projectContext}`);
package/dist/config.d.ts CHANGED
@@ -34,8 +34,9 @@ export declare function loadConfig(): IkieConfig;
34
34
  export declare function saveConfig(patch: Partial<IkieConfig>): void;
35
35
  export declare function getApiKey(cfg: IkieConfig): string | undefined;
36
36
  /**
37
- * True if the user has explicitly set a model in their config file
38
- * (as opposed to falling back to the hardcoded DEFAULT_MODEL).
37
+ * True if the user has explicitly chosen a model (via /model or --model),
38
+ * as opposed to the default that gets saved during first login.
39
+ * Only counts if the model differs from DEFAULT_MODEL.
39
40
  */
40
41
  export declare function hasExplicitModel(): boolean;
41
42
  /** True when signed into a hosted ikie account. */
package/dist/config.js CHANGED
@@ -50,14 +50,15 @@ export function getApiKey(cfg) {
50
50
  return cfg.account?.apiKey;
51
51
  }
52
52
  /**
53
- * True if the user has explicitly set a model in their config file
54
- * (as opposed to falling back to the hardcoded DEFAULT_MODEL).
53
+ * True if the user has explicitly chosen a model (via /model or --model),
54
+ * as opposed to the default that gets saved during first login.
55
+ * Only counts if the model differs from DEFAULT_MODEL.
55
56
  */
56
57
  export function hasExplicitModel() {
57
58
  try {
58
59
  if (existsSync(CONFIG_FILE)) {
59
60
  const raw = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
60
- return typeof raw.model === 'string';
61
+ return typeof raw.model === 'string' && raw.model !== DEFAULT_MODEL;
61
62
  }
62
63
  }
63
64
  catch { }
package/dist/theme.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const VERSION = "0.1.20";
1
+ export declare const VERSION = "0.1.22";
2
2
  export interface Theme {
3
3
  name: string;
4
4
  description: string;
package/dist/theme.js CHANGED
@@ -3,7 +3,7 @@ import os from 'os';
3
3
  import { join as pathJoin, basename } from 'path';
4
4
  import { existsSync, readFileSync } from 'fs';
5
5
  import { loadConfig, saveConfig } from './config.js';
6
- export const VERSION = '0.1.20';
6
+ export const VERSION = '0.1.22';
7
7
  const IKIE_BANNER = [
8
8
  ' ██╗██╗ ██╗██╗███████╗',
9
9
  ' ██║██║ ██╔╝██║██╔════╝',
@@ -354,6 +354,7 @@ function toolMeta(rawName) {
354
354
  case 'search_files': return { verb: 'Search', tint: c.info };
355
355
  case 'memory_write': return { verb: 'Remember', tint: c.secondary };
356
356
  case 'spawn_agent': return { verb: 'Agent', tint: c.secondary };
357
+ case 'switch_mode': return { verb: 'SwitchMode', tint: c.warning };
357
358
  case 'ask_user': return { verb: 'Ask', tint: c.info };
358
359
  case 'fetch_url': return { verb: 'Fetch', tint: c.info };
359
360
  case 'web_search': return { verb: 'WebSearch', tint: c.info };
package/dist/tools.d.ts CHANGED
@@ -2,5 +2,6 @@ import type OpenAI from 'openai';
2
2
  export declare const TOOL_DEFS: OpenAI.Chat.ChatCompletionTool[];
3
3
  export declare const SAFE_TOOLS: Set<string>;
4
4
  export declare const PLAN_TOOLS: Set<string>;
5
+ export declare function isRestrictedPath(path: string): boolean;
5
6
  export declare function formatToolArgs(name: string, input: Record<string, unknown>): string;
6
7
  export declare function executeTool(name: string, input: Record<string, unknown>): Promise<string>;
package/dist/tools.js CHANGED
@@ -156,7 +156,7 @@ export const TOOL_DEFS = [
156
156
  type: 'function',
157
157
  function: {
158
158
  name: 'spawn_agent',
159
- description: 'Delegate a self-contained subtask to a focused, autonomous sub-agent that has the same tools (it cannot spawn further sub-agents). The sub-agent does NOT see the current conversation, so the task and context must be self-contained. It returns a concise summary of what it did. Use for isolating research or parallelizable chunks of work.',
159
+ description: 'Delegate a self-contained subtask to a focused, autonomous sub-agent that has the same tools (it cannot spawn further sub-agents). The sub-agent does NOT see the current conversation, so the task and context must be self-contained. It returns a concise summary of what it did. Use for isolating research or parallelizable chunks of work. Multiple subagents can run in parallel.',
160
160
  parameters: {
161
161
  type: 'object',
162
162
  properties: {
@@ -167,6 +167,21 @@ export const TOOL_DEFS = [
167
167
  },
168
168
  },
169
169
  },
170
+ {
171
+ type: 'function',
172
+ function: {
173
+ name: 'switch_mode',
174
+ description: 'Request permission to switch between plan and agent mode. Use when the current mode is not sufficient — for example, when you are in plan mode and need to make changes, or when you are in agent mode and want to step back to read-only planning. The user must approve the switch.',
175
+ parameters: {
176
+ type: 'object',
177
+ properties: {
178
+ mode: { type: 'string', enum: ['plan', 'agent'], description: 'The mode to switch to.' },
179
+ reason: { type: 'string', description: 'Brief reason why the switch is needed.' },
180
+ },
181
+ required: ['mode', 'reason'],
182
+ },
183
+ },
184
+ },
170
185
  {
171
186
  type: 'function',
172
187
  function: {
@@ -279,6 +294,35 @@ export const SAFE_TOOLS = new Set(['read_file', 'list_dir', 'search_files', 'gre
279
294
  // Everything that mutates the filesystem or runs commands (write_file, edit_file,
280
295
  // bash, memory_write) is intentionally excluded so plan mode can only research.
281
296
  export const PLAN_TOOLS = new Set(['read_file', 'list_dir', 'search_files', 'grep', 'spawn_agent', 'ask_user', 'fetch_url', 'web_search', 'git_status', 'git_diff', 'git_log', 'git_branch']);
297
+ // Paths that may contain secrets, credentials, or system configuration.
298
+ // Reading these requires explicit user permission even though read_file is normally safe.
299
+ const RESTRICTED_PATTERNS = [
300
+ /^\.env(\.|$)/i, // .env, .env.local, .env.production, etc.
301
+ /\/(\.env(\.|$)|\.env$)/i, // any directory's .env files
302
+ /\b\.ssh\b/i, // .ssh directories
303
+ /\b\.aws\b/i, // AWS credentials/config
304
+ /\b\.docker\b/i, // Docker config
305
+ /\b\.npmrc\b/i, // npm auth tokens
306
+ /\b\.pypirc\b/i, // PyPI credentials
307
+ /\b\.git-credentials\b/i, // git credentials
308
+ /\bid_(rsa|ed25519|ecdsa|dsa)\b/i, // SSH private keys
309
+ /\b.*\.pem\b/i, // PEM certs/keys
310
+ /\b.*\.key\b/i, // key files
311
+ /\b.*\.p12\b/i, /\b.*\.pfx\b/i, // PKCS#12 bundles
312
+ /\b\.bashrc\b/i, /\b\.zshrc\b/i, /\b\.profile\b/i, /\b\.bash_profile\b/i,
313
+ /\b\.netrc\b/i, /\b\.pgpass\b/i, /\b\.mylogin\.cnf\b/i,
314
+ /\/etc\b/i, // system config
315
+ /\/proc\b/i, /\/sys\b/i, // Linux system dirs
316
+ /\/var\/(run|spool|lib|db)\b/i, // system state
317
+ /\bshadow\b/i, /\bpasswd\b/i, /\bhtpasswd\b/i, /\bsudoers\b/i,
318
+ ];
319
+ export function isRestrictedPath(path) {
320
+ const normalized = path.replace(/\\/g, '/').toLowerCase();
321
+ const basename = normalized.split('/').pop() ?? '';
322
+ if (RESTRICTED_PATTERNS.some(p => p.test(basename)))
323
+ return true;
324
+ return RESTRICTED_PATTERNS.some(p => p.test(normalized));
325
+ }
282
326
  // ─── Display helpers ──────────────────────────────────────────────────────────
283
327
  export function formatToolArgs(name, input) {
284
328
  const p = (v) => v != null && v !== 'undefined' ? String(v) : '(missing)';
@@ -779,6 +823,7 @@ function formatSearchResults(query, results) {
779
823
  export async function executeTool(name, input) {
780
824
  switch (name) {
781
825
  case 'read_file': return readFile(input);
826
+ case 'switch_mode': return 'Error: switch_mode is handled by the agent, not the tool executor.';
782
827
  case 'write_file': return writeFile(input);
783
828
  case 'edit_file': return editFile(input);
784
829
  case 'bash': return bash(input);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ikie-cli",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "Agentic coding CLI — your terminal AI pair programmer",
5
5
  "type": "module",
6
6
  "bin": {