ikie-cli 0.1.22 → 0.1.24

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
@@ -76,5 +76,5 @@ export declare class Agent {
76
76
  private checkPermission;
77
77
  }
78
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.";
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, 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): 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
@@ -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
  }
@@ -807,7 +810,7 @@ export const PLAN_MODE_ADDENDUM = `
807
810
 
808
811
  ## PLAN MODE (read-only)
809
812
  You are currently in **plan mode**. You have ONLY read-only tools (read_file,
810
- 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
811
814
  files, run shell commands, or change anything — those tools are unavailable and any
812
815
  attempt will be blocked.
813
816
 
@@ -821,7 +824,7 @@ Your job is to investigate and produce a clear, actionable plan:
821
824
 
822
825
  After you present the plan, the user will be asked whether to execute it. On approval,
823
826
  you'll be switched to agent mode and asked to carry out exactly this plan.`;
824
- export function buildSystemPrompt(projectContext, memoryContext) {
827
+ export function buildSystemPrompt(projectContext, memoryContext, skillsCatalog = '') {
825
828
  const parts = [
826
829
  `You are Ikie, an elite agentic software engineer running in the terminal.
827
830
 
@@ -930,11 +933,31 @@ changes they didn't ask for.
930
933
  \`task\` and any needed \`context\`, since it does not see this conversation.
931
934
  - \`switch_mode\`: Request permission to switch between plan and agent mode. Use when the current
932
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
+ **Skill scripts are for your internal use only.** If a skill includes Python/bash scripts,
941
+ run them yourself with the bash tool and use the output. Never paste the raw commands or
942
+ tell the user to run them manually. Apply the skill's knowledge directly to the user's task.
943
+ - \`install_skill\`: Install a skill from a git URL (GitHub, GitLab, Bitbucket) or local path.
944
+ Skills are instruction packs that give the agent specialized expertise. Example:
945
+ \`install_skill(source: "https://github.com/user/repo.git")\`.
946
+ - \`remove_skill\`: Remove a previously installed skill by name. Call without a name to list
947
+ all installed skills.
933
948
  - \`ask_user\`: Ask the user a clarifying question when you need more info to proceed.
934
949
  The user's answer is returned as the tool result. Use sparingly — only when genuinely
935
950
  unsure. Don't ask for confirmation on safe operations.
936
951
  `,
937
952
  ];
953
+ if (skillsCatalog) {
954
+ parts.push(`## Available Skills\n` +
955
+ `These skills are installed and ready. Each is a specialized instruction pack. ` +
956
+ `When the user's task matches a skill's description, call \`use_skill\` with its exact ` +
957
+ `name BEFORE doing the work, then follow the loaded instructions. Only the name and ` +
958
+ `description are shown here — the full instructions load on demand.\n\n` +
959
+ skillsCatalog);
960
+ }
938
961
  if (projectContext)
939
962
  parts.push(`## Project Context\n${projectContext}`);
940
963
  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,349 @@
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
+ 'IMPORTANT: do NOT show the user raw python3 / bash commands or tell them to run scripts manually. ' +
184
+ 'If a skill includes scripts, run them yourself with the bash tool and present the results. ' +
185
+ 'Use the skill\'s knowledge directly to do the work.');
186
+ return parts.join('\n\n');
187
+ }
188
+ // ─── Install / remove ─────────────────────────────────────────────────────────
189
+ /** Where user-installed skills live. */
190
+ export function userSkillsDir() {
191
+ return join(homedir(), '.ikie', 'skills');
192
+ }
193
+ const SKILL_FILE_NAMES = ['SKILL.md', 'Skill.md', 'skill.md'];
194
+ function hasSkillFile(dir) {
195
+ return SKILL_FILE_NAMES.some(n => existsSync(join(dir, n)));
196
+ }
197
+ /** Find every directory containing a SKILL.md within `root`, up to `maxDepth`. */
198
+ function findSkillDirs(root, maxDepth = 3) {
199
+ const found = [];
200
+ const walk = (dir, depth) => {
201
+ if (depth > maxDepth)
202
+ return;
203
+ if (hasSkillFile(dir)) {
204
+ found.push(dir);
205
+ return; // don't descend into a skill's own subfolders
206
+ }
207
+ let entries;
208
+ try {
209
+ entries = readdirSync(dir);
210
+ }
211
+ catch {
212
+ return;
213
+ }
214
+ for (const entry of entries) {
215
+ if (entry === '.git' || entry === 'node_modules')
216
+ continue;
217
+ const abs = join(dir, entry);
218
+ let st;
219
+ try {
220
+ st = statSync(abs);
221
+ }
222
+ catch {
223
+ continue;
224
+ }
225
+ if (st.isDirectory())
226
+ walk(abs, depth + 1);
227
+ }
228
+ };
229
+ walk(root, 0);
230
+ return found;
231
+ }
232
+ /** Read the declared `name:` from a skill dir's SKILL.md, falling back to the folder name. */
233
+ function skillDirName(dir) {
234
+ const file = findSkillFile(dir);
235
+ if (file) {
236
+ try {
237
+ const { meta } = parseFrontmatter(readFileSync(file, 'utf-8'));
238
+ if (meta.name)
239
+ return meta.name.trim();
240
+ }
241
+ catch {
242
+ /* fall through */
243
+ }
244
+ }
245
+ return basename(dir);
246
+ }
247
+ function looksLikeGitUrl(source) {
248
+ return (/^git@/.test(source) ||
249
+ /^https?:\/\/.+\.git$/.test(source) ||
250
+ /^(https?:\/\/)?(www\.)?(github|gitlab|bitbucket)\.com\//.test(source) ||
251
+ /^git\+/.test(source));
252
+ }
253
+ /**
254
+ * Install one or more skills from a local path or a git repository URL into the
255
+ * user skills directory (~/.ikie/skills). A source may be a single skill folder
256
+ * (contains SKILL.md) or a collection that holds several skill folders.
257
+ */
258
+ export async function installSkill(source, opts = {}) {
259
+ const raw = source.trim();
260
+ if (!raw)
261
+ throw new Error('A skill source (local path or git URL) is required.');
262
+ const dest = userSkillsDir();
263
+ mkdirSync(dest, { recursive: true });
264
+ const localPath = resolve(raw);
265
+ const isLocal = existsSync(localPath);
266
+ let scanRoot;
267
+ let tempDir;
268
+ let from;
269
+ if (isLocal) {
270
+ scanRoot = localPath;
271
+ from = 'local';
272
+ }
273
+ else if (looksLikeGitUrl(raw)) {
274
+ const cloneUrl = raw.replace(/^git\+/, '');
275
+ tempDir = join(tmpdir(), `ikie-skill-${Date.now()}`);
276
+ try {
277
+ await execAsync(`git clone --depth 1 ${JSON.stringify(cloneUrl)} ${JSON.stringify(tempDir)}`, {
278
+ timeout: 120000,
279
+ });
280
+ }
281
+ catch (err) {
282
+ const e = err;
283
+ throw new Error(`git clone failed: ${(e.stderr ?? e.message ?? String(err)).trim()}`);
284
+ }
285
+ scanRoot = tempDir;
286
+ from = 'git';
287
+ }
288
+ else {
289
+ throw new Error(`Source not found: "${raw}". Provide an existing local path or a git URL.`);
290
+ }
291
+ try {
292
+ const skillDirs = findSkillDirs(scanRoot);
293
+ if (!skillDirs.length) {
294
+ throw new Error('No SKILL.md found in the source. A skill must contain a SKILL.md file.');
295
+ }
296
+ const installed = [];
297
+ const skipped = [];
298
+ for (const dir of skillDirs) {
299
+ const name = skillDirName(dir);
300
+ const target = join(dest, name);
301
+ if (existsSync(target) && !opts.force) {
302
+ skipped.push({ name, reason: 'already installed (use --force to overwrite)' });
303
+ continue;
304
+ }
305
+ if (existsSync(target))
306
+ rmSync(target, { recursive: true, force: true });
307
+ // Dereference symlinks so bundled files don't point back to a temp clone dir.
308
+ cpSync(dir, target, { recursive: true, dereference: true });
309
+ // Never carry version-control metadata into the skill store.
310
+ rmSync(join(target, '.git'), { recursive: true, force: true });
311
+ installed.push(name);
312
+ }
313
+ return { installed, skipped, from };
314
+ }
315
+ finally {
316
+ if (tempDir)
317
+ rmSync(tempDir, { recursive: true, force: true });
318
+ }
319
+ }
320
+ /** Remove a user-installed skill by name. Returns true if it was removed. */
321
+ export function removeSkill(name) {
322
+ const want = name.trim();
323
+ if (!want)
324
+ return false;
325
+ const dest = userSkillsDir();
326
+ // Match by declared name first, then by folder name.
327
+ let entries;
328
+ try {
329
+ entries = readdirSync(dest);
330
+ }
331
+ catch {
332
+ return false;
333
+ }
334
+ for (const entry of entries) {
335
+ const dir = join(dest, entry);
336
+ try {
337
+ if (!statSync(dir).isDirectory())
338
+ continue;
339
+ }
340
+ catch {
341
+ continue;
342
+ }
343
+ if (entry === want || skillDirName(dir).toLowerCase() === want.toLowerCase()) {
344
+ rmSync(dir, { recursive: true, force: true });
345
+ return true;
346
+ }
347
+ }
348
+ return false;
349
+ }
package/dist/theme.js CHANGED
@@ -363,6 +363,7 @@ function toolMeta(rawName) {
363
363
  case 'git_log': return { verb: 'GitLog', tint: c.info };
364
364
  case 'git_commit': return { verb: 'Commit', tint: c.success };
365
365
  case 'git_branch': return { verb: 'GitBranch', tint: c.info };
366
+ case 'use_skill': return { verb: 'Skill', tint: c.secondary };
366
367
  default: return { verb: base, tint: c.primary };
367
368
  }
368
369
  }
package/dist/tools.js CHANGED
@@ -270,6 +270,49 @@ export const TOOL_DEFS = [
270
270
  },
271
271
  },
272
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
+ },
273
316
  {
274
317
  type: 'function',
275
318
  function: {
@@ -289,11 +332,11 @@ export const TOOL_DEFS = [
289
332
  // ─── Safe tools (auto-approved) ───────────────────────────────────────────────
290
333
  // spawn_agent is "safe" at the dispatch layer — the tools the sub-agent itself
291
334
  // runs go through their own approval inside the sub-agent loop.
292
- 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']);
293
336
  // Tools available in PLAN mode — read-only exploration plus delegation/questions.
294
337
  // Everything that mutates the filesystem or runs commands (write_file, edit_file,
295
338
  // bash, memory_write) is intentionally excluded so plan mode can only research.
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']);
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']);
297
340
  // Paths that may contain secrets, credentials, or system configuration.
298
341
  // Reading these requires explicit user permission even though read_file is normally safe.
299
342
  const RESTRICTED_PATTERNS = [
@@ -371,6 +414,12 @@ export function formatToolArgs(name, input) {
371
414
  const q = String(input.query ?? '');
372
415
  return `"${q.length > 56 ? q.slice(0, 56) + '…' : q}"`;
373
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)';
374
423
  default:
375
424
  return JSON.stringify(input).slice(0, 80);
376
425
  }
@@ -819,6 +868,56 @@ function formatSearchResults(query, results) {
819
868
  });
820
869
  return `Results for "${query}":\n\n${lines.join('\n\n')}`;
821
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
+ }
822
921
  // ─── Dispatcher ───────────────────────────────────────────────────────────────
823
922
  export async function executeTool(name, input) {
824
923
  switch (name) {
@@ -838,6 +937,9 @@ export async function executeTool(name, input) {
838
937
  case 'git_branch': return gitBranch(input);
839
938
  case 'fetch_url': return fetchUrl(input);
840
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);
841
943
  default: return `Unknown tool: ${name}`;
842
944
  }
843
945
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ikie-cli",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "description": "Agentic coding CLI — your terminal AI pair programmer",
5
5
  "type": "module",
6
6
  "bin": {