ikie-cli 0.1.21 → 0.1.23

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,11 +68,13 @@ 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;
74
76
  private checkPermission;
75
77
  }
76
78
  export declare const SUBAGENT_FRAMING = "You are a focused sub-agent spawned by Ikie to autonomously complete ONE specific task.\nWork independently \u2014 do not ask the user questions. Use your tools to gather what you\nneed, do the work, and verify it. When finished, your FINAL message must be a concise\nsummary of what you did and any key results (paths changed, findings, answers). That\nsummary is the only thing returned to the main agent, so make it self-contained.";
77
- export declare const PLAN_MODE_ADDENDUM = "\n\n## PLAN MODE (read-only)\nYou are currently in **plan mode**. You have ONLY read-only tools (read_file,\nlist_dir, search_files, grep, fetch_url, web_search, spawn_agent, ask_user). You CANNOT write files, edit\nfiles, run shell commands, or change anything \u2014 those tools are unavailable and any\nattempt will be blocked.\n\nYour job is to investigate and produce a clear, actionable plan:\n- Explore the relevant files and understand the current state before proposing anything.\n- Do NOT describe changes as if you already made them. You haven't.\n- End your response with a concise plan: a short **## Plan** heading followed by\n numbered steps. Name the specific files to change and what to change in each. Call\n out risks, assumptions, or open questions.\n- Keep it tight \u2014 enough to execute from, not an essay.\n\nAfter you present the plan, the user will be asked whether to execute it. On approval,\nyou'll be switched to agent mode and asked to carry out exactly this plan.";
78
- export declare function buildSystemPrompt(projectContext: string, memoryContext: string): string;
79
+ export declare const PLAN_MODE_ADDENDUM = "\n\n## PLAN MODE (read-only)\nYou are currently in **plan mode**. You have ONLY read-only tools (read_file,\nlist_dir, search_files, grep, fetch_url, web_search, use_skill, spawn_agent, ask_user). You CANNOT write files, edit\nfiles, run shell commands, or change anything \u2014 those tools are unavailable and any\nattempt will be blocked.\n\nYour job is to investigate and produce a clear, actionable plan:\n- Explore the relevant files and understand the current state before proposing anything.\n- Do NOT describe changes as if you already made them. You haven't.\n- End your response with a concise plan: a short **## Plan** heading followed by\n numbered steps. Name the specific files to change and what to change in each. Call\n out risks, assumptions, or open questions.\n- Keep it tight \u2014 enough to execute from, not an essay.\n\nAfter you present the plan, the user will be asked whether to execute it. On approval,\nyou'll be switched to agent mode and asked to carry out exactly this plan.";
80
+ export declare function buildSystemPrompt(projectContext: string, memoryContext: string, skillsCatalog?: string): 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) {
@@ -68,6 +68,9 @@ function toolPhaseLabel(name) {
68
68
  case 'git_branch': return 'Git branch';
69
69
  case 'fetch_url': return 'Fetching';
70
70
  case 'web_search': return 'Searching web';
71
+ case 'use_skill': return 'Loading skill';
72
+ case 'install_skill': return 'Installing skill';
73
+ case 'remove_skill': return 'Removing skill';
71
74
  default: return `Preparing ${name}`;
72
75
  }
73
76
  }
@@ -277,7 +280,7 @@ export class Agent {
277
280
  this.activeTurnStats.toolCalls += group.length;
278
281
  const summary = this.formatGroupSummary(group[0].name, inputs);
279
282
  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)) {
283
+ if (!opts.autoApprove && !this.config.autoApprove && !SAFE_TOOLS.has(group[0].name) && group[0].name !== 'switch_mode') {
281
284
  const allowed = await this.checkPermission(group[0].name, inputs[0]);
282
285
  if (!allowed) {
283
286
  for (const tc of group) {
@@ -291,26 +294,48 @@ export class Agent {
291
294
  }
292
295
  const t0 = Date.now();
293
296
  let errors = 0;
297
+ // Separate subagents so they can run in parallel; keep other tools sequential
298
+ // to avoid races on file mutations.
299
+ const spawnIndices = [];
300
+ const otherIndices = [];
294
301
  for (let i = 0; i < group.length; i++) {
302
+ if (group[i].name === 'spawn_agent')
303
+ spawnIndices.push(i);
304
+ else
305
+ otherIndices.push(i);
306
+ }
307
+ const results = new Map();
308
+ if (spawnIndices.length > 0) {
309
+ const spawnResults = await Promise.all(spawnIndices.map(i => this.runSubagent(inputs[i], opts)));
310
+ spawnIndices.forEach((idx, i) => results.set(idx, spawnResults[i]));
311
+ }
312
+ for (const i of otherIndices) {
295
313
  if (opts.signal?.aborted)
296
314
  break;
297
315
  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 });
316
+ try {
317
+ if (tc.name === 'read_file' && isRestrictedPath(String(inputs[i].path ?? ''))) {
318
+ const allowed = await this.checkPermission('read_file', inputs[i]);
319
+ if (!allowed) {
320
+ results.set(i, `Tool execution denied by user: read_file ${inputs[i].path}`);
321
+ continue;
322
+ }
309
323
  }
310
- catch (err) {
324
+ const result = await executeTool(tc.name, inputs[i]);
325
+ if (result.startsWith('Error'))
311
326
  errors++;
312
- this.conversation.push({ role: 'tool', tool_call_id: tc.id, content: `Tool error: ${err}` });
313
- }
327
+ this.recordChangedFile(tc.name, inputs[i], result);
328
+ results.set(i, result);
329
+ }
330
+ catch (err) {
331
+ errors++;
332
+ results.set(i, `Tool error: ${err}`);
333
+ }
334
+ }
335
+ for (let i = 0; i < group.length; i++) {
336
+ const result = results.get(i);
337
+ if (result !== undefined) {
338
+ this.conversation.push({ role: 'tool', tool_call_id: group[i].id, content: result });
314
339
  }
315
340
  }
316
341
  const ms = Date.now() - t0;
@@ -338,12 +363,19 @@ export class Agent {
338
363
  let tools = this.depth >= 1
339
364
  ? TOOL_DEFS.filter(t => t.function.name !== 'spawn_agent')
340
365
  : TOOL_DEFS;
366
+ // Always include switch_mode so the agent can request a mode change.
367
+ const switchModeTool = TOOL_DEFS.find(t => t.function.name === 'switch_mode');
341
368
  // Plan mode: only offer read-only tools, and steer toward proposing a plan.
342
369
  let systemContent = this.systemPrompt;
343
370
  if (this.mode === 'plan') {
344
- tools = tools.filter(t => PLAN_TOOLS.has(t.function.name));
371
+ tools = tools.filter(t => PLAN_TOOLS.has(t.function.name) || t.function.name === 'switch_mode');
372
+ if (switchModeTool && !tools.includes(switchModeTool))
373
+ tools.push(switchModeTool);
345
374
  systemContent += PLAN_MODE_ADDENDUM;
346
375
  }
376
+ else if (switchModeTool && !tools.includes(switchModeTool)) {
377
+ tools.push(switchModeTool);
378
+ }
347
379
  return {
348
380
  model: this.config.model,
349
381
  max_tokens: this.config.maxTokens,
@@ -528,12 +560,26 @@ export class Agent {
528
560
  }
529
561
  // ── Tool execution ────────────────────────────────────────────────────────
530
562
  async handleToolCall(name, id, input, opts) {
563
+ if (name === 'switch_mode') {
564
+ return this.handleSwitchMode(input);
565
+ }
531
566
  if (name === 'spawn_agent') {
532
567
  return this.runSubagent(input, opts);
533
568
  }
534
569
  if (name === 'ask_user') {
535
570
  return this.askUser(input);
536
571
  }
572
+ if (name === 'read_file') {
573
+ const path = String(input.path ?? '');
574
+ if (isRestrictedPath(path) && !opts.autoApprove && !this.config.autoApprove && !this.sessionAllowList.has('read_file')) {
575
+ if (this.sessionDenyList.has('read_file'))
576
+ return `Tool execution denied by user: read_file ${path}`;
577
+ process.stdout.write(`${this.indent}${c.warning('⚠')} ${c.muted('Restricted file')} ${c.white(path)} ${c.muted('— asking for permission')}\n`);
578
+ const allowed = await this.checkPermission('read_file', input);
579
+ if (!allowed)
580
+ return `Tool execution denied by user: read_file ${path}`;
581
+ }
582
+ }
537
583
  if (!opts.autoApprove && !this.config.autoApprove && !SAFE_TOOLS.has(name)) {
538
584
  const allowed = await this.checkPermission(name, input);
539
585
  if (!allowed)
@@ -563,6 +609,63 @@ export class Agent {
563
609
  return msg;
564
610
  }
565
611
  }
612
+ async handleSwitchMode(input) {
613
+ if (this.depth > 0) {
614
+ return 'Error: subagents cannot switch mode. Return your findings and let the main agent decide.';
615
+ }
616
+ const mode = input.mode;
617
+ const reason = (input.reason ?? '').trim();
618
+ if (!mode || (mode !== 'plan' && mode !== 'agent')) {
619
+ return 'Error: switch_mode requires mode "plan" or "agent".';
620
+ }
621
+ if (mode === this.mode) {
622
+ return `Already in ${mode} mode.`;
623
+ }
624
+ const allowed = await this.requestModeSwitch(mode, reason);
625
+ if (!allowed) {
626
+ return `Mode switch to ${mode} denied by user.`;
627
+ }
628
+ this.setMode(mode);
629
+ return `Switched to ${mode} mode.`;
630
+ }
631
+ async requestModeSwitch(mode, reason) {
632
+ process.stdout.write(`\n ${c.primary('◆')} ${c.white.bold('mode switch')} ${c.muted('·')} ${c.white(`to ${mode}`)}\n` +
633
+ ` ${c.muted('reason:')} ${c.dim(reason)}\n` +
634
+ ` ${c.muted('⎿')} ${c.success.bold('y')} ${c.muted('allow')} ${c.error.bold('n')} ${c.muted('deny')}\n` +
635
+ ` ${c.muted('❯')} `);
636
+ return new Promise((resolve) => {
637
+ if (!process.stdin.isTTY) {
638
+ process.stdout.write(chalk.dim('(non-interactive, denying)\n'));
639
+ resolve(false);
640
+ return;
641
+ }
642
+ const wasRaw = process.stdin.isRaw ?? false;
643
+ const savedDataListeners = process.stdin.rawListeners('data').slice();
644
+ const savedKeypressListeners = process.stdin.rawListeners('keypress').slice();
645
+ process.stdin.removeAllListeners('data');
646
+ process.stdin.removeAllListeners('keypress');
647
+ if (process.stdin.isTTY) {
648
+ process.stdin.setRawMode(true);
649
+ process.stdin.resume();
650
+ }
651
+ const onData = (data) => {
652
+ process.stdin.removeListener('data', onData);
653
+ if (process.stdin.isTTY)
654
+ process.stdin.setRawMode(wasRaw);
655
+ restoreStdinListeners(savedDataListeners, savedKeypressListeners);
656
+ const key = data.toString().toLowerCase();
657
+ if (key === 'y' || key === '\r' || key === '\n') {
658
+ process.stdout.write(chalk.green('y\n'));
659
+ resolve(true);
660
+ }
661
+ else {
662
+ process.stdout.write(chalk.red('n\n'));
663
+ resolve(false);
664
+ }
665
+ };
666
+ process.stdin.on('data', onData);
667
+ });
668
+ }
566
669
  async askUser(input) {
567
670
  const question = (input.question ?? '').trim();
568
671
  if (!question)
@@ -707,7 +810,7 @@ export const PLAN_MODE_ADDENDUM = `
707
810
 
708
811
  ## PLAN MODE (read-only)
709
812
  You are currently in **plan mode**. You have ONLY read-only tools (read_file,
710
- list_dir, search_files, grep, fetch_url, web_search, spawn_agent, ask_user). You CANNOT write files, edit
813
+ list_dir, search_files, grep, fetch_url, web_search, use_skill, spawn_agent, ask_user). You CANNOT write files, edit
711
814
  files, run shell commands, or change anything — those tools are unavailable and any
712
815
  attempt will be blocked.
713
816
 
@@ -721,7 +824,7 @@ Your job is to investigate and produce a clear, actionable plan:
721
824
 
722
825
  After you present the plan, the user will be asked whether to execute it. On approval,
723
826
  you'll be switched to agent mode and asked to carry out exactly this plan.`;
724
- export function buildSystemPrompt(projectContext, memoryContext) {
827
+ export function buildSystemPrompt(projectContext, memoryContext, skillsCatalog = '') {
725
828
  const parts = [
726
829
  `You are Ikie, an elite agentic software engineer running in the terminal.
727
830
 
@@ -823,15 +926,35 @@ changes they didn't ask for.
823
926
  - **Trust fetched page content over snippet summaries** — snippets can be stale; the live page is authoritative.
824
927
  - **Never state a version, date, or fact as definitive if your search results conflict** — say what
825
928
  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
929
  - \`spawn_agent\`: Delegate a self-contained subtask to a focused sub-agent. Use this
830
930
  to parallelize or isolate work — e.g. "investigate how auth is implemented and report
831
931
  back", or "write and run tests for module X". The sub-agent has the same tools (except
832
932
  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.`,
933
+ \`task\` and any needed \`context\`, since it does not see this conversation.
934
+ - \`switch_mode\`: Request permission to switch between plan and agent mode. Use when the current
935
+ mode is not sufficient for what you need to do next. The user must approve the switch.
936
+ - \`use_skill\`: Load a **skill** — a curated pack of expert instructions (plus optional bundled
937
+ files/scripts) for a specific kind of task. The skills installed on this machine are listed
938
+ under "Available Skills" below by name + description. When a task matches one, call
939
+ \`use_skill\` with its exact name FIRST to pull in the full instructions, then follow them.
940
+ - \`install_skill\`: Install a skill from a git URL (GitHub, GitLab, Bitbucket) or local path.
941
+ Skills are instruction packs that give the agent specialized expertise. Example:
942
+ \`install_skill(source: "https://github.com/user/repo.git")\`.
943
+ - \`remove_skill\`: Remove a previously installed skill by name. Call without a name to list
944
+ all installed skills.
945
+ - \`ask_user\`: Ask the user a clarifying question when you need more info to proceed.
946
+ The user's answer is returned as the tool result. Use sparingly — only when genuinely
947
+ unsure. Don't ask for confirmation on safe operations.
948
+ `,
834
949
  ];
950
+ if (skillsCatalog) {
951
+ parts.push(`## Available Skills\n` +
952
+ `These skills are installed and ready. Each is a specialized instruction pack. ` +
953
+ `When the user's task matches a skill's description, call \`use_skill\` with its exact ` +
954
+ `name BEFORE doing the work, then follow the loaded instructions. Only the name and ` +
955
+ `description are shown here — the full instructions load on demand.\n\n` +
956
+ skillsCatalog);
957
+ }
835
958
  if (projectContext)
836
959
  parts.push(`## Project Context\n${projectContext}`);
837
960
  if (memoryContext)
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import minimist from 'minimist';
5
5
  import { loadConfig, getApiKey, IKIE_API_BASE, IKIE_HOST, DEFAULT_MODEL, hasExplicitModel } from './config.js';
6
6
  import { detectProjectContext, formatContextForPrompt } from './context.js';
7
7
  import { loadAllMemory, formatMemoryForPrompt } from './memory.js';
8
+ import { discoverSkills, formatSkillsForPrompt } from './skills.js';
8
9
  import { buildSystemPrompt, Agent } from './agent.js';
9
10
  import { startREPL } from './repl.js';
10
11
  import { login, logout } from './auth.js';
@@ -23,6 +24,9 @@ ${c.primary('Usage:')}
23
24
  ${c.accent('ikie')} ${c.muted('"<message>"')} One-shot command
24
25
  ${c.accent('ikie login')} Sign in to your ikie account
25
26
  ${c.accent('ikie logout')} Sign out of your ikie account
27
+ ${c.accent('ikie skills')} List installed skills
28
+ ${c.accent('ikie skills install')} ${c.muted('<src>')} Install a skill (git URL or path)
29
+ ${c.accent('ikie skills remove')} ${c.muted('<name>')} Remove an installed skill
26
30
  ${c.accent('ikie')} ${c.muted('--model <id>')} Use specific model
27
31
 
28
32
  ${c.primary('Options:')}
@@ -40,6 +44,63 @@ ${c.primary('In-session commands:')}
40
44
  ${c.muted('Ctrl+V Paste image from clipboard')}
41
45
  `);
42
46
  }
47
+ async function runSkillsCli(args, force) {
48
+ const { discoverSkills, installSkill, removeSkill, listSkillFiles } = await import('./skills.js');
49
+ const sub = (args[0] ?? 'list').toLowerCase();
50
+ if (sub === 'install' || sub === 'add') {
51
+ const src = args.slice(1).filter(a => a !== '--force').join(' ').trim();
52
+ if (!src) {
53
+ console.error(errorLine('Usage: ikie skills install <local-path|git-url> [--force]'));
54
+ return;
55
+ }
56
+ console.log(c.muted(`Installing skill from ${src}…`));
57
+ try {
58
+ const r = await installSkill(src, { force });
59
+ if (r.installed.length) {
60
+ console.log(`${c.success('✓')} Installed: ${c.white(r.installed.join(', '))}`);
61
+ }
62
+ for (const s of r.skipped)
63
+ console.log(`${c.muted('•')} Skipped ${s.name} — ${s.reason}`);
64
+ if (!r.installed.length && !r.skipped.length)
65
+ console.log(c.muted('Nothing installed.'));
66
+ }
67
+ catch (err) {
68
+ console.error(errorLine(err instanceof Error ? err.message : String(err)));
69
+ process.exitCode = 1;
70
+ }
71
+ return;
72
+ }
73
+ if (sub === 'remove' || sub === 'rm' || sub === 'uninstall') {
74
+ const name = args.slice(1).join(' ').trim();
75
+ if (!name) {
76
+ console.error(errorLine('Usage: ikie skills remove <name>'));
77
+ return;
78
+ }
79
+ if (removeSkill(name))
80
+ console.log(`${c.success('✓')} Removed skill "${name}".`);
81
+ else {
82
+ console.error(errorLine(`No user-installed skill named "${name}".`));
83
+ process.exitCode = 1;
84
+ }
85
+ return;
86
+ }
87
+ // list
88
+ const skills = discoverSkills();
89
+ if (!skills.length) {
90
+ console.log('No skills installed.');
91
+ console.log(`\nInstall one with:\n ${c.accent('ikie skills install <git-url|path>')}`);
92
+ console.log(`\nClaude Code skills (.claude/skills) are also picked up automatically.`);
93
+ return;
94
+ }
95
+ console.log(`\n${c.primary.bold('Installed Skills')} ${c.muted(`(${skills.length})`)}\n`);
96
+ for (const s of skills) {
97
+ const nFiles = listSkillFiles(s).length;
98
+ console.log(` ${c.secondary('●')} ${c.white.bold(s.name)} ${c.muted(`[${s.source}·${s.origin}]`)}${nFiles ? c.muted(` · ${nFiles} files`) : ''}`);
99
+ if (s.description)
100
+ console.log(` ${c.dim(s.description)}`);
101
+ }
102
+ console.log();
103
+ }
43
104
  async function main() {
44
105
  if (argv.version) {
45
106
  try {
@@ -68,6 +129,10 @@ async function main() {
68
129
  logout();
69
130
  process.exit(0);
70
131
  }
132
+ if (cmd === 'skills') {
133
+ await runSkillsCli(argv._.slice(1).map(String), Boolean(argv.force));
134
+ process.exit(0);
135
+ }
71
136
  const config = loadConfig();
72
137
  if (argv.model)
73
138
  config.model = argv.model;
@@ -115,7 +180,9 @@ ${errorLine('Not signed in.')}
115
180
  const projectContextStr = formatContextForPrompt(projectCtx);
116
181
  const memory = loadAllMemory();
117
182
  const memoryStr = formatMemoryForPrompt(memory);
118
- const systemPrompt = buildSystemPrompt(projectContextStr, memoryStr);
183
+ const skills = discoverSkills();
184
+ const skillsStr = formatSkillsForPrompt(skills);
185
+ const systemPrompt = buildSystemPrompt(projectContextStr, memoryStr, skillsStr);
119
186
  if (argv.verbose) {
120
187
  console.log(c.muted('─'.repeat(60)));
121
188
  console.log(c.muted('System prompt:'));
package/dist/repl.js CHANGED
@@ -93,6 +93,16 @@ const SLASH_CMDS = [
93
93
  { name: 'rpm', desc: 'Set request limit', args: '[number]' },
94
94
  { name: 'tokens', desc: 'Token estimate' },
95
95
  { name: 'usage', desc: 'Show account usage & credit' },
96
+ {
97
+ name: 'skills',
98
+ desc: 'List/install skills',
99
+ subs: [
100
+ { name: 'list', desc: 'List installed skills' },
101
+ { name: 'show', desc: 'Show a skill\'s instructions' },
102
+ { name: 'install', desc: 'Install a skill from a path or git URL' },
103
+ { name: 'remove', desc: 'Remove an installed skill' },
104
+ ],
105
+ },
96
106
  { name: 'mode', desc: 'Show/set mode (plan|agent)', args: '[plan|agent]' },
97
107
  { name: 'plan', desc: 'Switch to plan mode (read-only)' },
98
108
  { name: 'agent', desc: 'Switch to agent mode (full)' },
@@ -165,6 +175,10 @@ ${c.primary.bold('Ikie Commands')}
165
175
  ${c.warning('/rpm [number]')} Show or set request limit
166
176
  ${c.warning('/tokens')} Show conversation token estimate
167
177
  ${c.warning('/usage')} Show account usage & credit balance
178
+ ${c.warning('/skills')} List installed skills
179
+ ${c.warning('/skills show <name>')} Show a skill's instructions
180
+ ${c.warning('/skills install <src>')} Install a skill (git URL or path)
181
+ ${c.warning('/skills remove <name>')} Remove an installed skill
168
182
  ${c.warning('/plan')} Plan mode — research & propose, no changes
169
183
  ${c.warning('/agent')} Agent mode — full execution (default)
170
184
  ${c.warning('/mode')} Show or set mode ${c.muted('(Shift+Tab toggles)')}
@@ -374,6 +388,87 @@ async function handleSlashCommand(input, agent, config, projectContext, rl, sess
374
388
  await login();
375
389
  return true;
376
390
  }
391
+ case 'skills': {
392
+ const sub = (args[0] ?? 'list').toLowerCase();
393
+ const { discoverSkills, getSkill, listSkillFiles, installSkill, removeSkill } = await import('./skills.js');
394
+ if (sub === 'install' || sub === 'add') {
395
+ const rest = args.slice(1).filter(a => a !== '--force');
396
+ const force = args.includes('--force');
397
+ const src = rest.join(' ').trim();
398
+ if (!src) {
399
+ console.log(errorLine('Usage: /skills install <local-path|git-url> [--force]'));
400
+ return true;
401
+ }
402
+ console.log(c.muted(` Installing skill from ${src}…`));
403
+ try {
404
+ const r = await installSkill(src, { force });
405
+ if (r.installed.length) {
406
+ console.log(successLine(`Installed ${r.installed.length} skill${r.installed.length === 1 ? '' : 's'}: ${r.installed.join(', ')}`));
407
+ console.log(infoLine('Available now — the agent will use them automatically when relevant.'));
408
+ }
409
+ for (const s of r.skipped) {
410
+ console.log(infoLine(`Skipped "${s.name}" — ${s.reason}`));
411
+ }
412
+ if (!r.installed.length && !r.skipped.length) {
413
+ console.log(infoLine('Nothing installed.'));
414
+ }
415
+ }
416
+ catch (err) {
417
+ console.log(errorLine(err instanceof Error ? err.message : String(err)));
418
+ }
419
+ return true;
420
+ }
421
+ if (sub === 'remove' || sub === 'rm' || sub === 'uninstall') {
422
+ const nameArg = args.slice(1).join(' ').trim();
423
+ if (!nameArg) {
424
+ console.log(errorLine('Usage: /skills remove <name>'));
425
+ return true;
426
+ }
427
+ if (removeSkill(nameArg)) {
428
+ console.log(successLine(`Removed skill "${nameArg}".`));
429
+ }
430
+ else {
431
+ console.log(errorLine(`No user-installed skill named "${nameArg}".`));
432
+ }
433
+ return true;
434
+ }
435
+ if (sub === 'show' || sub === 'view') {
436
+ const nameArg = args.slice(1).join(' ').trim();
437
+ if (!nameArg) {
438
+ console.log(errorLine('Usage: /skills show <name>'));
439
+ return true;
440
+ }
441
+ const skill = getSkill(nameArg);
442
+ if (!skill) {
443
+ console.log(errorLine(`No skill named "${nameArg}".`));
444
+ return true;
445
+ }
446
+ console.log(`\n${c.primary.bold(skill.name)} ${c.muted(`(${skill.source} · ${skill.origin})`)}`);
447
+ if (skill.description)
448
+ console.log(c.white(skill.description));
449
+ console.log(c.dim(skill.dir));
450
+ console.log(renderMarkdown(skill.body || '(no instructions)'));
451
+ return true;
452
+ }
453
+ const skills = discoverSkills();
454
+ if (!skills.length) {
455
+ console.log(infoLine('No skills installed.'));
456
+ console.log(`\n ${c.muted('Install one with')} ${c.warning('/skills install <git-url|path>')}`);
457
+ console.log(` ${c.muted('or drop a folder with a')} ${c.warning('SKILL.md')} ${c.muted('into')} ${c.dim('~/.ikie/skills/')}`);
458
+ console.log(`\n ${c.muted('Claude Code skills (.claude/skills) work as-is.')}\n`);
459
+ return true;
460
+ }
461
+ console.log(`\n${c.primary.bold('Installed Skills')} ${c.muted(`(${skills.length})`)}\n`);
462
+ for (const s of skills) {
463
+ const nFiles = listSkillFiles(s).length;
464
+ const extras = nFiles ? c.muted(` · ${nFiles} file${nFiles === 1 ? '' : 's'}`) : '';
465
+ console.log(` ${c.secondary('●')} ${c.white.bold(s.name)} ${c.muted(`[${s.source}·${s.origin}]`)}${extras}`);
466
+ if (s.description)
467
+ console.log(` ${c.dim(s.description)}`);
468
+ }
469
+ console.log(`\n ${c.muted('Used automatically when relevant ·')} ${c.warning('/skills show <name>')} ${c.muted('·')} ${c.warning('/skills install <src>')}\n`);
470
+ return true;
471
+ }
377
472
  case 'logout': {
378
473
  if (!isLoggedIn(config)) {
379
474
  console.log(infoLine('Not signed in.'));
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Skills — progressively-disclosed instruction packs, compatible with the
3
+ * Claude Code "Agent Skills" format (a folder containing a `SKILL.md` with
4
+ * YAML frontmatter `name` + `description`, and an optional body of
5
+ * instructions plus bundled resource files/scripts).
6
+ *
7
+ * The agent only ever sees each skill's name + description up front (cheap).
8
+ * When a skill looks relevant it calls the `use_skill` tool to load the full
9
+ * SKILL.md body on demand, along with the skill's directory so it can read any
10
+ * bundled files or run bundled scripts with its normal tools.
11
+ */
12
+ export interface Skill {
13
+ name: string;
14
+ description: string;
15
+ /** Full markdown body of SKILL.md (everything after the frontmatter). */
16
+ body: string;
17
+ /** Absolute path to the skill's directory. */
18
+ dir: string;
19
+ /** Absolute path to the SKILL.md file. */
20
+ file: string;
21
+ /** Where it came from, for display: "project" or "user". */
22
+ source: 'project' | 'user';
23
+ /** The roots this skill was discovered under, e.g. ".ikie" or ".claude". */
24
+ origin: string;
25
+ }
26
+ /**
27
+ * Discover all available skills across project and user roots.
28
+ * The first occurrence of a given (lowercased) name wins, so a project skill
29
+ * shadows a user skill with the same name.
30
+ */
31
+ export declare function discoverSkills(cwd?: string): Skill[];
32
+ /** Look up a single skill by name (case-insensitive). */
33
+ export declare function getSkill(name: string, cwd?: string): Skill | undefined;
34
+ /** List the bundled files in a skill directory (excluding the SKILL.md itself). */
35
+ export declare function listSkillFiles(skill: Skill): string[];
36
+ /**
37
+ * Render the skill catalog for the system prompt: each skill's name +
38
+ * description, so the model knows what exists and when to reach for one.
39
+ * Returns '' when no skills are installed (so the section is omitted).
40
+ */
41
+ export declare function formatSkillsForPrompt(skills: Skill[]): string;
42
+ /**
43
+ * Build the full text returned to the model when it loads a skill via
44
+ * `use_skill`: the instructions plus a manifest of bundled files and the
45
+ * skill's directory, so the agent can read resources / run scripts itself.
46
+ */
47
+ export declare function renderSkill(skill: Skill): string;
48
+ /** Where user-installed skills live. */
49
+ export declare function userSkillsDir(): string;
50
+ export interface InstallResult {
51
+ installed: string[];
52
+ skipped: {
53
+ name: string;
54
+ reason: string;
55
+ }[];
56
+ from: 'local' | 'git';
57
+ }
58
+ /**
59
+ * Install one or more skills from a local path or a git repository URL into the
60
+ * user skills directory (~/.ikie/skills). A source may be a single skill folder
61
+ * (contains SKILL.md) or a collection that holds several skill folders.
62
+ */
63
+ export declare function installSkill(source: string, opts?: {
64
+ force?: boolean;
65
+ }): Promise<InstallResult>;
66
+ /** Remove a user-installed skill by name. Returns true if it was removed. */
67
+ export declare function removeSkill(name: string): boolean;
package/dist/skills.js ADDED
@@ -0,0 +1,345 @@
1
+ import { homedir, tmpdir } from 'os';
2
+ import { join, resolve, basename } from 'path';
3
+ import { readFileSync, existsSync, readdirSync, statSync, mkdirSync, rmSync, cpSync } from 'fs';
4
+ import { exec } from 'child_process';
5
+ import { promisify } from 'util';
6
+ const execAsync = promisify(exec);
7
+ /** Parse minimal YAML frontmatter (--- delimited) into a flat string map + body. */
8
+ function parseFrontmatter(raw) {
9
+ const text = raw.replace(/^/, '');
10
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
11
+ if (!m)
12
+ return { meta: {}, body: text.trim() };
13
+ const meta = {};
14
+ for (const line of m[1].split(/\r?\n/)) {
15
+ const kv = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line);
16
+ if (!kv)
17
+ continue;
18
+ let value = kv[2].trim();
19
+ // Strip matching surrounding quotes.
20
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
21
+ value = value.slice(1, -1);
22
+ }
23
+ meta[kv[1].toLowerCase()] = value;
24
+ }
25
+ return { meta, body: (m[2] ?? '').trim() };
26
+ }
27
+ function skillRoots(cwd = process.cwd()) {
28
+ const home = homedir();
29
+ return [
30
+ // Project skills win over user skills; .ikie wins over .claude within a tier.
31
+ { dir: join(cwd, '.ikie', 'skills'), source: 'project', origin: '.ikie' },
32
+ { dir: join(cwd, '.claude', 'skills'), source: 'project', origin: '.claude' },
33
+ { dir: join(home, '.ikie', 'skills'), source: 'user', origin: '.ikie' },
34
+ { dir: join(home, '.claude', 'skills'), source: 'user', origin: '.claude' },
35
+ ];
36
+ }
37
+ function findSkillFile(skillDir) {
38
+ // Standard layout: <skillDir>/SKILL.md (case-insensitive on the filename).
39
+ for (const entry of ['SKILL.md', 'Skill.md', 'skill.md']) {
40
+ const p = join(skillDir, entry);
41
+ if (existsSync(p))
42
+ return p;
43
+ }
44
+ return null;
45
+ }
46
+ /**
47
+ * Discover all available skills across project and user roots.
48
+ * The first occurrence of a given (lowercased) name wins, so a project skill
49
+ * shadows a user skill with the same name.
50
+ */
51
+ export function discoverSkills(cwd = process.cwd()) {
52
+ const byName = new Map();
53
+ for (const root of skillRoots(cwd)) {
54
+ if (!existsSync(root.dir))
55
+ continue;
56
+ let entries;
57
+ try {
58
+ entries = readdirSync(root.dir);
59
+ }
60
+ catch {
61
+ continue;
62
+ }
63
+ for (const entry of entries) {
64
+ const skillDir = join(root.dir, entry);
65
+ let isDir = false;
66
+ try {
67
+ isDir = statSync(skillDir).isDirectory();
68
+ }
69
+ catch {
70
+ continue;
71
+ }
72
+ if (!isDir)
73
+ continue;
74
+ const file = findSkillFile(skillDir);
75
+ if (!file)
76
+ continue;
77
+ let raw;
78
+ try {
79
+ raw = readFileSync(file, 'utf-8');
80
+ }
81
+ catch {
82
+ continue;
83
+ }
84
+ const { meta, body } = parseFrontmatter(raw);
85
+ const name = (meta.name || basename(skillDir)).trim();
86
+ const description = (meta.description || '').trim();
87
+ const key = name.toLowerCase();
88
+ if (!name || byName.has(key))
89
+ continue;
90
+ byName.set(key, {
91
+ name,
92
+ description,
93
+ body,
94
+ dir: skillDir,
95
+ file,
96
+ source: root.source,
97
+ origin: root.origin,
98
+ });
99
+ }
100
+ }
101
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
102
+ }
103
+ /** Look up a single skill by name (case-insensitive). */
104
+ export function getSkill(name, cwd = process.cwd()) {
105
+ const want = name.trim().toLowerCase();
106
+ return discoverSkills(cwd).find(s => s.name.toLowerCase() === want);
107
+ }
108
+ /** List the bundled files in a skill directory (excluding the SKILL.md itself). */
109
+ export function listSkillFiles(skill) {
110
+ const out = [];
111
+ const walk = (dir, prefix) => {
112
+ let entries;
113
+ try {
114
+ entries = readdirSync(dir);
115
+ }
116
+ catch {
117
+ return;
118
+ }
119
+ for (const entry of entries) {
120
+ if (entry === 'SKILL.md' && prefix === '')
121
+ continue;
122
+ if (entry === '.git' || entry === 'node_modules')
123
+ continue;
124
+ const abs = join(dir, entry);
125
+ let st;
126
+ try {
127
+ st = statSync(abs);
128
+ }
129
+ catch {
130
+ continue;
131
+ }
132
+ const rel = prefix ? `${prefix}/${entry}` : entry;
133
+ if (st.isDirectory()) {
134
+ walk(abs, rel);
135
+ }
136
+ else {
137
+ out.push(rel);
138
+ }
139
+ }
140
+ };
141
+ walk(skill.dir, '');
142
+ return out.sort();
143
+ }
144
+ /**
145
+ * Render the skill catalog for the system prompt: each skill's name +
146
+ * description, so the model knows what exists and when to reach for one.
147
+ * Returns '' when no skills are installed (so the section is omitted).
148
+ */
149
+ export function formatSkillsForPrompt(skills) {
150
+ if (!skills.length)
151
+ return '';
152
+ const lines = skills.map(s => {
153
+ const desc = s.description || '(no description)';
154
+ return `- **${s.name}** — ${desc}`;
155
+ });
156
+ return lines.join('\n');
157
+ }
158
+ /**
159
+ * Build the full text returned to the model when it loads a skill via
160
+ * `use_skill`: the instructions plus a manifest of bundled files and the
161
+ * skill's directory, so the agent can read resources / run scripts itself.
162
+ */
163
+ export function renderSkill(skill) {
164
+ const files = listSkillFiles(skill);
165
+ const parts = [];
166
+ parts.push(`# Skill: ${skill.name}`);
167
+ if (skill.description)
168
+ parts.push(skill.description);
169
+ parts.push(`Skill directory: ${skill.dir}`);
170
+ if (files.length) {
171
+ const shown = files.slice(0, 40);
172
+ const more = files.length - shown.length;
173
+ parts.push(`Bundled files (read with read_file or run with bash, relative to the skill directory):\n` +
174
+ shown.map(f => ` - ${f}`).join('\n') +
175
+ (more > 0 ? `\n …and ${more} more` : ''));
176
+ }
177
+ parts.push('---');
178
+ parts.push(skill.body || '(This skill has no additional instructions.)');
179
+ parts.push('---');
180
+ parts.push('Follow these instructions for the current task. Use your normal tools to read any ' +
181
+ 'bundled files or run any bundled scripts referenced above (resolve their paths against ' +
182
+ 'the skill directory).');
183
+ return parts.join('\n\n');
184
+ }
185
+ // ─── Install / remove ─────────────────────────────────────────────────────────
186
+ /** Where user-installed skills live. */
187
+ export function userSkillsDir() {
188
+ return join(homedir(), '.ikie', 'skills');
189
+ }
190
+ const SKILL_FILE_NAMES = ['SKILL.md', 'Skill.md', 'skill.md'];
191
+ function hasSkillFile(dir) {
192
+ return SKILL_FILE_NAMES.some(n => existsSync(join(dir, n)));
193
+ }
194
+ /** Find every directory containing a SKILL.md within `root`, up to `maxDepth`. */
195
+ function findSkillDirs(root, maxDepth = 3) {
196
+ const found = [];
197
+ const walk = (dir, depth) => {
198
+ if (depth > maxDepth)
199
+ return;
200
+ if (hasSkillFile(dir)) {
201
+ found.push(dir);
202
+ return; // don't descend into a skill's own subfolders
203
+ }
204
+ let entries;
205
+ try {
206
+ entries = readdirSync(dir);
207
+ }
208
+ catch {
209
+ return;
210
+ }
211
+ for (const entry of entries) {
212
+ if (entry === '.git' || entry === 'node_modules')
213
+ continue;
214
+ const abs = join(dir, entry);
215
+ let st;
216
+ try {
217
+ st = statSync(abs);
218
+ }
219
+ catch {
220
+ continue;
221
+ }
222
+ if (st.isDirectory())
223
+ walk(abs, depth + 1);
224
+ }
225
+ };
226
+ walk(root, 0);
227
+ return found;
228
+ }
229
+ /** Read the declared `name:` from a skill dir's SKILL.md, falling back to the folder name. */
230
+ function skillDirName(dir) {
231
+ const file = findSkillFile(dir);
232
+ if (file) {
233
+ try {
234
+ const { meta } = parseFrontmatter(readFileSync(file, 'utf-8'));
235
+ if (meta.name)
236
+ return meta.name.trim();
237
+ }
238
+ catch {
239
+ /* fall through */
240
+ }
241
+ }
242
+ return basename(dir);
243
+ }
244
+ function looksLikeGitUrl(source) {
245
+ return (/^git@/.test(source) ||
246
+ /^https?:\/\/.+\.git$/.test(source) ||
247
+ /^(https?:\/\/)?(www\.)?(github|gitlab|bitbucket)\.com\//.test(source) ||
248
+ /^git\+/.test(source));
249
+ }
250
+ /**
251
+ * Install one or more skills from a local path or a git repository URL into the
252
+ * user skills directory (~/.ikie/skills). A source may be a single skill folder
253
+ * (contains SKILL.md) or a collection that holds several skill folders.
254
+ */
255
+ export async function installSkill(source, opts = {}) {
256
+ const raw = source.trim();
257
+ if (!raw)
258
+ throw new Error('A skill source (local path or git URL) is required.');
259
+ const dest = userSkillsDir();
260
+ mkdirSync(dest, { recursive: true });
261
+ const localPath = resolve(raw);
262
+ const isLocal = existsSync(localPath);
263
+ let scanRoot;
264
+ let tempDir;
265
+ let from;
266
+ if (isLocal) {
267
+ scanRoot = localPath;
268
+ from = 'local';
269
+ }
270
+ else if (looksLikeGitUrl(raw)) {
271
+ const cloneUrl = raw.replace(/^git\+/, '');
272
+ tempDir = join(tmpdir(), `ikie-skill-${Date.now()}`);
273
+ try {
274
+ await execAsync(`git clone --depth 1 ${JSON.stringify(cloneUrl)} ${JSON.stringify(tempDir)}`, {
275
+ timeout: 120000,
276
+ });
277
+ }
278
+ catch (err) {
279
+ const e = err;
280
+ throw new Error(`git clone failed: ${(e.stderr ?? e.message ?? String(err)).trim()}`);
281
+ }
282
+ scanRoot = tempDir;
283
+ from = 'git';
284
+ }
285
+ else {
286
+ throw new Error(`Source not found: "${raw}". Provide an existing local path or a git URL.`);
287
+ }
288
+ try {
289
+ const skillDirs = findSkillDirs(scanRoot);
290
+ if (!skillDirs.length) {
291
+ throw new Error('No SKILL.md found in the source. A skill must contain a SKILL.md file.');
292
+ }
293
+ const installed = [];
294
+ const skipped = [];
295
+ for (const dir of skillDirs) {
296
+ const name = skillDirName(dir);
297
+ const target = join(dest, name);
298
+ if (existsSync(target) && !opts.force) {
299
+ skipped.push({ name, reason: 'already installed (use --force to overwrite)' });
300
+ continue;
301
+ }
302
+ if (existsSync(target))
303
+ rmSync(target, { recursive: true, force: true });
304
+ cpSync(dir, target, { recursive: true });
305
+ // Never carry version-control metadata into the skill store.
306
+ rmSync(join(target, '.git'), { recursive: true, force: true });
307
+ installed.push(name);
308
+ }
309
+ return { installed, skipped, from };
310
+ }
311
+ finally {
312
+ if (tempDir)
313
+ rmSync(tempDir, { recursive: true, force: true });
314
+ }
315
+ }
316
+ /** Remove a user-installed skill by name. Returns true if it was removed. */
317
+ export function removeSkill(name) {
318
+ const want = name.trim();
319
+ if (!want)
320
+ return false;
321
+ const dest = userSkillsDir();
322
+ // Match by declared name first, then by folder name.
323
+ let entries;
324
+ try {
325
+ entries = readdirSync(dest);
326
+ }
327
+ catch {
328
+ return false;
329
+ }
330
+ for (const entry of entries) {
331
+ const dir = join(dest, entry);
332
+ try {
333
+ if (!statSync(dir).isDirectory())
334
+ continue;
335
+ }
336
+ catch {
337
+ continue;
338
+ }
339
+ if (entry === want || skillDirName(dir).toLowerCase() === want.toLowerCase()) {
340
+ rmSync(dir, { recursive: true, force: true });
341
+ return true;
342
+ }
343
+ }
344
+ return false;
345
+ }
package/dist/theme.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const VERSION = "0.1.21";
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.21';
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 };
@@ -362,6 +363,7 @@ function toolMeta(rawName) {
362
363
  case 'git_log': return { verb: 'GitLog', tint: c.info };
363
364
  case 'git_commit': return { verb: 'Commit', tint: c.success };
364
365
  case 'git_branch': return { verb: 'GitBranch', tint: c.info };
366
+ case 'use_skill': return { verb: 'Skill', tint: c.secondary };
365
367
  default: return { verb: base, tint: c.primary };
366
368
  }
367
369
  }
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: {
@@ -255,6 +270,49 @@ export const TOOL_DEFS = [
255
270
  },
256
271
  },
257
272
  },
273
+ {
274
+ type: 'function',
275
+ function: {
276
+ name: 'use_skill',
277
+ description: 'Load a skill — a curated pack of expert instructions (and optional bundled files/scripts) for a specific kind of task. Skills are listed by name + description in your system prompt under "Available Skills". When a task matches a skill, call this FIRST with that skill\'s exact name to pull in its full instructions, then follow them. Returns the skill body plus the paths of any bundled resources you can read or run.',
278
+ parameters: {
279
+ type: 'object',
280
+ properties: {
281
+ name: { type: 'string', description: 'The exact name of the skill to load (as shown in "Available Skills").' },
282
+ },
283
+ required: ['name'],
284
+ },
285
+ },
286
+ },
287
+ {
288
+ type: 'function',
289
+ function: {
290
+ name: 'install_skill',
291
+ description: 'Install a skill from a local path or git repository URL (GitHub, GitLab, Bitbucket). Skills are instruction packs that give the agent specialized expertise. Provide a git URL (e.g. https://github.com/user/repo.git) or a local path. Use --force to overwrite existing skills.',
292
+ parameters: {
293
+ type: 'object',
294
+ properties: {
295
+ source: { type: 'string', description: 'Local path or git URL to install skill(s) from' },
296
+ force: { type: 'boolean', description: 'Overwrite if already installed (default: false)' },
297
+ },
298
+ required: ['source'],
299
+ },
300
+ },
301
+ },
302
+ {
303
+ type: 'function',
304
+ function: {
305
+ name: 'remove_skill',
306
+ description: 'Remove a previously installed skill by name. Lists installed skills if called without a name.',
307
+ parameters: {
308
+ type: 'object',
309
+ properties: {
310
+ name: { type: 'string', description: 'Name of the skill to remove (omit to list installed skills)' },
311
+ },
312
+ required: [],
313
+ },
314
+ },
315
+ },
258
316
  {
259
317
  type: 'function',
260
318
  function: {
@@ -274,11 +332,40 @@ export const TOOL_DEFS = [
274
332
  // ─── Safe tools (auto-approved) ───────────────────────────────────────────────
275
333
  // spawn_agent is "safe" at the dispatch layer — the tools the sub-agent itself
276
334
  // runs go through their own approval inside the sub-agent loop.
277
- export const SAFE_TOOLS = new Set(['read_file', 'list_dir', 'search_files', 'grep', 'memory_write', 'spawn_agent', 'ask_user', 'fetch_url', 'web_search', 'git_status', 'git_diff', 'git_log', 'git_branch']);
335
+ export const SAFE_TOOLS = new Set(['read_file', 'list_dir', 'search_files', 'grep', 'memory_write', 'spawn_agent', 'ask_user', 'fetch_url', 'web_search', 'git_status', 'git_diff', 'git_log', 'git_branch', 'use_skill', 'install_skill', 'remove_skill']);
278
336
  // Tools available in PLAN mode — read-only exploration plus delegation/questions.
279
337
  // Everything that mutates the filesystem or runs commands (write_file, edit_file,
280
338
  // bash, memory_write) is intentionally excluded so plan mode can only research.
281
- 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']);
339
+ 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', 'use_skill', 'install_skill', 'remove_skill']);
340
+ // Paths that may contain secrets, credentials, or system configuration.
341
+ // Reading these requires explicit user permission even though read_file is normally safe.
342
+ const RESTRICTED_PATTERNS = [
343
+ /^\.env(\.|$)/i, // .env, .env.local, .env.production, etc.
344
+ /\/(\.env(\.|$)|\.env$)/i, // any directory's .env files
345
+ /\b\.ssh\b/i, // .ssh directories
346
+ /\b\.aws\b/i, // AWS credentials/config
347
+ /\b\.docker\b/i, // Docker config
348
+ /\b\.npmrc\b/i, // npm auth tokens
349
+ /\b\.pypirc\b/i, // PyPI credentials
350
+ /\b\.git-credentials\b/i, // git credentials
351
+ /\bid_(rsa|ed25519|ecdsa|dsa)\b/i, // SSH private keys
352
+ /\b.*\.pem\b/i, // PEM certs/keys
353
+ /\b.*\.key\b/i, // key files
354
+ /\b.*\.p12\b/i, /\b.*\.pfx\b/i, // PKCS#12 bundles
355
+ /\b\.bashrc\b/i, /\b\.zshrc\b/i, /\b\.profile\b/i, /\b\.bash_profile\b/i,
356
+ /\b\.netrc\b/i, /\b\.pgpass\b/i, /\b\.mylogin\.cnf\b/i,
357
+ /\/etc\b/i, // system config
358
+ /\/proc\b/i, /\/sys\b/i, // Linux system dirs
359
+ /\/var\/(run|spool|lib|db)\b/i, // system state
360
+ /\bshadow\b/i, /\bpasswd\b/i, /\bhtpasswd\b/i, /\bsudoers\b/i,
361
+ ];
362
+ export function isRestrictedPath(path) {
363
+ const normalized = path.replace(/\\/g, '/').toLowerCase();
364
+ const basename = normalized.split('/').pop() ?? '';
365
+ if (RESTRICTED_PATTERNS.some(p => p.test(basename)))
366
+ return true;
367
+ return RESTRICTED_PATTERNS.some(p => p.test(normalized));
368
+ }
282
369
  // ─── Display helpers ──────────────────────────────────────────────────────────
283
370
  export function formatToolArgs(name, input) {
284
371
  const p = (v) => v != null && v !== 'undefined' ? String(v) : '(missing)';
@@ -327,6 +414,12 @@ export function formatToolArgs(name, input) {
327
414
  const q = String(input.query ?? '');
328
415
  return `"${q.length > 56 ? q.slice(0, 56) + '…' : q}"`;
329
416
  }
417
+ case 'use_skill':
418
+ return `"${p(input.name)}"`;
419
+ case 'install_skill':
420
+ return `"${p(input.source)}"${input.force ? ' --force' : ''}`;
421
+ case 'remove_skill':
422
+ return input.name ? `"${p(input.name)}"` : '(list installed)';
330
423
  default:
331
424
  return JSON.stringify(input).slice(0, 80);
332
425
  }
@@ -775,10 +868,61 @@ function formatSearchResults(query, results) {
775
868
  });
776
869
  return `Results for "${query}":\n\n${lines.join('\n\n')}`;
777
870
  }
871
+ // ─── Skills ─────────────────────────────────────────────────────────────────
872
+ async function useSkill(input) {
873
+ const name = (input.name ?? '').trim();
874
+ if (!name || name === 'undefined')
875
+ return 'Error: name is required for use_skill';
876
+ const { getSkill, discoverSkills, renderSkill } = await import('./skills.js');
877
+ const skill = getSkill(name);
878
+ if (!skill) {
879
+ const available = discoverSkills().map(s => s.name);
880
+ return available.length
881
+ ? `Error: no skill named "${name}". Available skills: ${available.join(', ')}.`
882
+ : `Error: no skill named "${name}", and no skills are installed.`;
883
+ }
884
+ return renderSkill(skill);
885
+ }
886
+ async function installSkill(input) {
887
+ const source = (input.source ?? '').trim();
888
+ if (!source || source === 'undefined')
889
+ return 'Error: source is required for install_skill';
890
+ const { installSkill: doInstall } = await import('./skills.js');
891
+ try {
892
+ const result = await doInstall(source, { force: input.force ?? false });
893
+ const lines = [];
894
+ if (result.installed.length) {
895
+ lines.push(`Installed ${result.installed.length} skill(s): ${result.installed.join(', ')}`);
896
+ }
897
+ if (result.skipped.length) {
898
+ for (const s of result.skipped)
899
+ lines.push(`Skipped "${s.name}": ${s.reason}`);
900
+ }
901
+ return lines.length ? lines.join('\n') : 'Nothing to install.';
902
+ }
903
+ catch (err) {
904
+ return `Error: ${err instanceof Error ? err.message : String(err)}`;
905
+ }
906
+ }
907
+ async function removeSkill(input) {
908
+ const { removeSkill: doRemove, userSkillsDir, discoverSkills } = await import('./skills.js');
909
+ const name = (input.name ?? '').trim();
910
+ if (!name || name === 'undefined') {
911
+ // List installed skills
912
+ const skills = discoverSkills();
913
+ const userSkills = skills.filter(s => s.source === 'user');
914
+ if (!userSkills.length)
915
+ return 'No user-installed skills found.';
916
+ return 'Installed skills:\n' + userSkills.map(s => ` - ${s.name}: ${s.description || '(no description)'}`).join('\n');
917
+ }
918
+ const removed = doRemove(name);
919
+ return removed ? `Removed skill "${name}".` : `No skill named "${name}" found.`;
920
+ }
778
921
  // ─── Dispatcher ───────────────────────────────────────────────────────────────
779
922
  export async function executeTool(name, input) {
780
923
  switch (name) {
781
924
  case 'read_file': return readFile(input);
925
+ case 'switch_mode': return 'Error: switch_mode is handled by the agent, not the tool executor.';
782
926
  case 'write_file': return writeFile(input);
783
927
  case 'edit_file': return editFile(input);
784
928
  case 'bash': return bash(input);
@@ -793,6 +937,9 @@ export async function executeTool(name, input) {
793
937
  case 'git_branch': return gitBranch(input);
794
938
  case 'fetch_url': return fetchUrl(input);
795
939
  case 'web_search': return webSearch(input);
940
+ case 'use_skill': return useSkill(input);
941
+ case 'install_skill': return installSkill(input);
942
+ case 'remove_skill': return removeSkill(input);
796
943
  default: return `Unknown tool: ${name}`;
797
944
  }
798
945
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ikie-cli",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "description": "Agentic coding CLI — your terminal AI pair programmer",
5
5
  "type": "module",
6
6
  "bin": {