mini-coder 0.0.3 → 0.0.5

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/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sean Caetano Martin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -53,7 +53,10 @@ I can also connect to **MCP servers** (like Exa for web search), giving you supe
53
53
  - **Multi-provider** — set `OPENCODE_API_KEY` for Zen, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_API_KEY`, or just run Ollama locally. I auto-discover whatever's available.
54
54
  - **Session memory** — conversations are saved in a local SQLite database. Resume where you left off with `-c` or pick a specific session with `-r <id>`.
55
55
  - **Shell integration** — prefix with `!` to run shell commands inline. Use `@` to reference files in your prompt (with Tab completion).
56
- - **Slash commands** — `/model` to switch models, `/plan` for read-only thinking mode, `/ralph` for autonomous looping (agent re-runs your goal with fresh context each iteration until it signals done), `/review` for a code review, `/undo` to roll back a turn, `/new` for a clean session, `/mcp` to manage MCP servers.
56
+ - **Slash commands** — `/model` to switch models, `/plan` for read-only thinking mode, `/ralph` for autonomous looping, `/review` for a code review, `/undo` to roll back a turn, `/new` for a clean session, `/mcp` to manage MCP servers. See all with `/help`.
57
+ - **Custom commands** — drop a `.md` file in `.agents/commands/` and it becomes a `/command`. Supports argument placeholders (`$ARGUMENTS`, `$1`…`$9`) and shell interpolation (`` !`cmd` ``). Global commands live in `~/.agents/commands/`. Custom commands take precedence over built-ins. → [docs/custom-commands.md](docs/custom-commands.md)
58
+ - **Custom agents** — drop a `.md` file in `.agents/agents/` (or `~/.agents/agents/` globally) and reference it with `@agent-name` in your prompt. The agent runs in its own context window with a custom system prompt and optional model override. → [docs/custom-agents.md](docs/custom-agents.md)
59
+ - **Skills** — place a `SKILL.md` in `.agents/skills/<name>/` and inject it into any prompt with `@skill-name`. Skills are *never* auto-loaded — always explicit. → [docs/skills.md](docs/skills.md)
57
60
  - **Post-tool hooks** — drop an executable at `.agents/hooks/post-<tool>` and I'll run it after every matching tool call.
58
61
  - **Beautiful, minimal output** — diffs for edits, formatted trees for file searches, a live status bar with model, git branch, and token counts.
59
62
  - **16 ANSI colors only** — my output inherits *your* terminal theme. Dark mode, light mode, Solarized, Gruvbox — I fit right in.
@@ -64,17 +67,35 @@ I can also connect to **MCP servers** (like Exa for web search), giving you supe
64
67
 
65
68
  - **I eat my own dog food.** I was built *by* a mini-coder agent. It's agents all the way down. 🐢
66
69
  - **I'm tiny but mighty.** The whole runtime is [Bun.js](https://bun.com) — fast startup, native TypeScript, and a built-in SQLite driver.
67
- - **I respect existing conventions.** Hook scripts live in `.agents/hooks/`, context in `AGENTS.md` or `CLAUDE.md` — I follow the ecosystem instead of inventing new standards.
70
+ - **I respect existing conventions.** Hook scripts live in `.agents/hooks/`, context in `AGENTS.md` or `CLAUDE.md`, commands in `.agents/commands/`, agents in `.agents/agents/`, skills in `.agents/skills/` — I follow the ecosystem instead of inventing new standards.
68
71
  - **I spin while I think.** ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ (It's the little things.)
69
72
  - **I can clone myself.** The `subagent` tool lets me spin up parallel instances of myself to tackle independent subtasks simultaneously. Divide and conquer! (Up to 3 levels deep.)
70
73
 
71
74
  ---
72
75
 
76
+ ## 📁 The `.agents` folder
77
+
78
+ mini-coder follows the [`.agents` convention](https://github.com/agentsmd/agents) used across the AI coding tool ecosystem. Drop files in `.agents/` to extend behaviour for the current repo, or `~/.agents/` to apply them globally.
79
+
80
+ | Path | What it does |
81
+ |---|---|
82
+ | `.agents/commands/*.md` | Custom slash commands (`/name`) |
83
+ | `.agents/agents/*.md` | Custom agents (`@name`) |
84
+ | `.agents/skills/<name>/SKILL.md` | Reusable skill instructions (`@name`) |
85
+ | `.agents/hooks/post-<tool>` | Scripts run after a tool call |
86
+ | `AGENTS.md` | Project context injected into every system prompt |
87
+
88
+ Local always overrides global. The same `~/.agents/` folder is shared with Claude Code, Opencode, and other compatible tools — skills and agents you write once work everywhere.
89
+
90
+ ---
91
+
92
+
73
93
  ## 🚀 Getting Started
74
94
 
75
95
  ```bash
76
- # Install globally
77
- bun run build && bun add -g mini-coder@file:$(pwd)
96
+ # Install from npm
97
+ bun add -g mini-coder
98
+ # or: npm install -g mini-coder
78
99
 
79
100
  # Set your provider key (pick one — or run Ollama locally)
80
101
  export OPENCODE_API_KEY=your-zen-key # recommended
@@ -0,0 +1,135 @@
1
+ # Code Quality Issues
2
+
3
+ ## 1. 🔴 Duplicate Code — `parseFrontmatter` tripled across modules
4
+
5
+ `parseFrontmatter` (including its `Frontmatter` interface, `FM_RE` regex and YAML key-parsing loop) is **copy-pasted verbatim** in three files:
6
+
7
+ - `src/cli/agents.ts` (lines 20–45)
8
+ - `src/cli/custom-commands.ts` (lines 20–45)
9
+ - `src/cli/skills.ts` has a partial version `parseSkillMeta` that reads `name` and `description` from the same format.
10
+
11
+ The agents and custom-commands versions are byte-for-byte identical — same regex, same loop, same trim/strip-quotes, same `description`/`model` key handling. The only difference is the struct they populate. Fix: extract into a shared `src/cli/frontmatter.ts` utility and import it in all three.
12
+
13
+ ---
14
+
15
+ ## 2. 🔴 Duplicate Code — `loadFromDir` pattern tripled
16
+
17
+ The `loadFromDir` function in `agents.ts` and `custom-commands.ts` is essentially the same skeleton: `existsSync` check → `readdirSync` → `.endsWith(".md")` filter → `readFileSync` → `parseFrontmatter` → `Map.set`. The `loadSkills` version differs only in that it looks for `SKILL.md` inside subdirectories instead of `.md` flat files, but the rest of the structure is the same. Same for the public `loadXxx(cwd)` function: three identical "merge global + local, local wins" patterns.
18
+
19
+ ---
20
+
21
+ ## 3. 🔴 Duplicate Code — `homedir()`-based `cwdDisplay` computed in two places in `agent.ts`
22
+
23
+ ```ts
24
+ // buildSystemPrompt (line 98-100)
25
+ const cwdDisplay = cwd.startsWith(homedir())
26
+ ? `~${cwd.slice(homedir().length)}`
27
+ : cwd;
28
+
29
+ // renderStatusBarForSession (line 646-648)
30
+ const cwdDisplay = cwd.startsWith(homedir())
31
+ ? `~${cwd.slice(homedir().length)}`
32
+ : cwd;
33
+ ```
34
+
35
+ Also duplicated in `session/manager.ts` (line 53–55) and `cli/output.ts` already has `HOME = homedir()` cached at the top. This pattern should be a small helper function, e.g. `tildePath(p: string): string`.
36
+
37
+ ---
38
+
39
+ ## 4. 🔴 Inlined `import()` calls — violates project rule
40
+
41
+ The rule says **"Do not inline `import` calls"**. There are 12 occurrences of dynamic inline type imports:
42
+
43
+ - `src/agent/agent.ts` lines 197, 224, 673 — `import("../tools/subagent.ts").SubagentOutput`, `SubagentToolEntry`, `CoreMessage`
44
+ - `src/agent/tools.ts` line 104 — `import("../tools/subagent.ts").SubagentOutput`
45
+ - `src/cli/commands.ts` line 44 — `import("../tools/subagent.ts").SubagentOutput`
46
+ - `src/cli/output.ts` lines 332, 342, 512, 579, 597 — `SubagentToolEntry`, `SubagentOutput`, `CoreMessage`
47
+ - `src/llm-api/types.ts` line 81 — `import("../llm-api/turn.ts").CoreMessage`
48
+ - `src/llm-api/turn.ts` line 32 — `import("ai").FlexibleSchema<unknown>`
49
+
50
+ All of these should be top-level `import type` statements.
51
+
52
+ ---
53
+
54
+ ## 5. 🟡 Dead code — `userMessage` in `turn.ts` is never called
55
+
56
+ `src/llm-api/turn.ts` line 185 exports `userMessage(text: string): CoreMessage`. It is never imported or called anywhere in the codebase. It should be removed.
57
+
58
+ ---
59
+
60
+ ## 6. 🟡 Dead code — `availableProviders` imported but never used
61
+
62
+ `src/cli/commands.ts` imports `availableProviders` from `providers.ts` (line 3) but it is never referenced anywhere in that file (only `fetchAvailableModels` is used). This is an unused import.
63
+
64
+ ---
65
+
66
+ ## 7. 🟡 Dead code — `saveMessage` (singular) exported but never used
67
+
68
+ `src/session/db.ts` exports `saveMessage` (single-message variant, line 191). The entire codebase always calls `saveMessages` (plural). `saveMessage` has no callers and should be removed.
69
+
70
+ ---
71
+
72
+ ## 8. 🟡 Dead code — `updateSessionTitle` and `deleteSession` exported but never called
73
+
74
+ `src/session/db.ts` exports `updateSessionTitle` (line 161) and `deleteSession` (line 185). Neither appears in any other file. They may be future API surface, but currently they are dead exports.
75
+
76
+ ---
77
+
78
+ ## 9. 🟡 Dead code — most of `src/llm-api/types.ts` is orphaned
79
+
80
+ `ProviderConfig`, `MessageRole`, `TextContent`, `ToolCallContent`, `ToolResultContent`, `MessageContent`, and `Message` are all defined and exported in `types.ts` but **never imported anywhere**. The codebase uses `CoreMessage` from `turn.ts` directly for all message handling. The only things from `types.ts` that are actually used are `ToolDef` and the `TurnEvent` family. The unused types should be removed.
81
+
82
+ ---
83
+
84
+ ## 10. 🟡 Unused import — `relative` in `session/manager.ts`
85
+
86
+ `src/session/manager.ts` line 2 imports `relative` from `"node:path"` but it is never used anywhere in the file.
87
+
88
+ ---
89
+
90
+ ## 11. 🟡 Unused import — `PREFIX` in `session/manager.ts`
91
+
92
+ `src/session/manager.ts` line 4 imports `PREFIX` from `"../cli/output.ts"` but it is not used anywhere in the file (only `writeln` and `c` from `yoctocolors` are used in `printSessionList`).
93
+
94
+ ---
95
+
96
+ ## 12. 🟡 Bug-prone — `zenGoogle` ignores its parameter and recreates provider on each call
97
+
98
+ `providers.ts` line 78: `zenGoogle(modelId: string)` takes `modelId` but only uses it to pass to `createGoogleGenerativeAI` (which doesn't use it — the model ID is passed to the returned function). Also unlike the other zen providers, `zenGoogle` doesn't memoize — it creates a new `createGoogleGenerativeAI` instance on every call. This is inconsistent and wasteful.
99
+
100
+ ---
101
+
102
+ ## 13. 🟡 Minor — double `homedir()` call per `cwdDisplay` computation
103
+
104
+ In `buildSystemPrompt` and `renderStatusBarForSession` (agent.ts), `homedir()` is called twice inline:
105
+ ```ts
106
+ cwd.startsWith(homedir()) ? `~${cwd.slice(homedir().length)}` : cwd
107
+ ```
108
+ `homedir()` is cheap but its result is constant — it should be captured once (as `output.ts` already does with `const HOME = homedir()`).
109
+
110
+ ---
111
+
112
+ ## 14. 🟢 Style — `eslint-disable` comments in `mcp/client.ts`
113
+
114
+ Lines 81 and 88 in `mcp/client.ts` contain `// eslint-disable-next-line @typescript-eslint/no-explicit-any` comments. The project uses Biome, not ESLint — these comments are dead noise and have no effect. They should be removed.
115
+
116
+ ---
117
+
118
+ ## Summary
119
+
120
+ | # | Severity | File(s) | Issue |
121
+ |---|---|---|---|
122
+ | 1 | 🔴 | `agents.ts`, `custom-commands.ts`, `skills.ts` | `parseFrontmatter` duplicated 3× |
123
+ | 2 | 🔴 | same 3 files | `loadFromDir` + merge pattern duplicated 3× |
124
+ | 3 | 🔴 | `agent.ts` (×2), `manager.ts` | `cwdDisplay` tilde logic duplicated |
125
+ | 4 | 🔴 | 6 files (12 occurrences) | Inlined `import()` type calls — project rule violation |
126
+ | 5 | 🟡 | `turn.ts` | `userMessage` exported but never used |
127
+ | 6 | 🟡 | `commands.ts` | `availableProviders` imported but never used |
128
+ | 7 | 🟡 | `db.ts` | `saveMessage` (singular) exported but never called |
129
+ | 8 | 🟡 | `db.ts` | `updateSessionTitle`, `deleteSession` — dead exports |
130
+ | 9 | 🟡 | `types.ts` | `ProviderConfig`, `Message`, `MessageRole`, etc. — never imported |
131
+ | 10 | 🟡 | `manager.ts` | `relative` imported but not used |
132
+ | 11 | 🟡 | `manager.ts` | `PREFIX` imported but not used |
133
+ | 12 | 🟡 | `providers.ts` | `zenGoogle` doesn't memoize, ignores its parameter |
134
+ | 13 | 🟡 | `agent.ts` | `homedir()` called twice per expression |
135
+ | 14 | 🟢 | `mcp/client.ts` | Dead `eslint-disable` comments (project uses Biome) |
package/dist/mc.js CHANGED
@@ -5,11 +5,81 @@
5
5
  import * as c7 from "yoctocolors";
6
6
 
7
7
  // src/agent/agent.ts
8
- import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
9
- import { homedir as homedir5 } from "os";
10
- import { join as join11 } from "path";
8
+ import { existsSync as existsSync6, readFileSync as readFileSync5 } from "fs";
9
+ import { join as join14 } from "path";
11
10
  import * as c6 from "yoctocolors";
12
11
 
12
+ // src/cli/agents.ts
13
+ import { existsSync, readFileSync, readdirSync } from "fs";
14
+ import { homedir } from "os";
15
+ import { basename, join } from "path";
16
+
17
+ // src/cli/frontmatter.ts
18
+ var FM_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
19
+ function parseFrontmatter(raw) {
20
+ const m = raw.match(FM_RE);
21
+ if (!m)
22
+ return { meta: {}, body: raw };
23
+ const meta = {};
24
+ const yamlBlock = m[1] ?? "";
25
+ for (const line of yamlBlock.split(`
26
+ `)) {
27
+ const colon = line.indexOf(":");
28
+ if (colon === -1)
29
+ continue;
30
+ const key = line.slice(0, colon).trim();
31
+ const val = line.slice(colon + 1).trim().replace(/^["']|["']$/g, "");
32
+ if (key === "name")
33
+ meta.name = val;
34
+ if (key === "description")
35
+ meta.description = val;
36
+ if (key === "model")
37
+ meta.model = val;
38
+ }
39
+ return { meta, body: (m[2] ?? "").trim() };
40
+ }
41
+
42
+ // src/cli/agents.ts
43
+ function loadFromDir(dir, source) {
44
+ const agents = new Map;
45
+ if (!existsSync(dir))
46
+ return agents;
47
+ let entries;
48
+ try {
49
+ entries = readdirSync(dir);
50
+ } catch {
51
+ return agents;
52
+ }
53
+ for (const entry of entries) {
54
+ if (!entry.endsWith(".md"))
55
+ continue;
56
+ const name = basename(entry, ".md");
57
+ const filePath = join(dir, entry);
58
+ let raw;
59
+ try {
60
+ raw = readFileSync(filePath, "utf-8");
61
+ } catch {
62
+ continue;
63
+ }
64
+ const { meta, body } = parseFrontmatter(raw);
65
+ agents.set(name, {
66
+ name,
67
+ description: meta.description ?? name,
68
+ ...meta.model ? { model: meta.model } : {},
69
+ systemPrompt: body,
70
+ source
71
+ });
72
+ }
73
+ return agents;
74
+ }
75
+ function loadAgents(cwd) {
76
+ const globalDir = join(homedir(), ".agents", "agents");
77
+ const localDir = join(cwd, ".agents", "agents");
78
+ const global = loadFromDir(globalDir, "global");
79
+ const local = loadFromDir(localDir, "local");
80
+ return new Map([...global, ...local]);
81
+ }
82
+
13
83
  // src/cli/commands.ts
14
84
  import * as c3 from "yoctocolors";
15
85
 
@@ -48,6 +118,7 @@ var ZEN_GOOGLE_MODELS = new Set([
48
118
  ]);
49
119
  var _zenAnthropic = null;
50
120
  var _zenOpenAI = null;
121
+ var _zenGoogle = null;
51
122
  var _zenCompat = null;
52
123
  function getZenApiKey() {
53
124
  const key = process.env.OPENCODE_API_KEY;
@@ -73,11 +144,14 @@ function zenOpenAI() {
73
144
  }
74
145
  return _zenOpenAI;
75
146
  }
76
- function zenGoogle(modelId) {
77
- return createGoogleGenerativeAI({
78
- apiKey: getZenApiKey(),
79
- baseURL: ZEN_BASE
80
- });
147
+ function zenGoogle() {
148
+ if (!_zenGoogle) {
149
+ _zenGoogle = createGoogleGenerativeAI({
150
+ apiKey: getZenApiKey(),
151
+ baseURL: ZEN_BASE
152
+ });
153
+ }
154
+ return _zenGoogle;
81
155
  }
82
156
  function zenCompat() {
83
157
  if (!_zenCompat) {
@@ -153,7 +227,7 @@ function resolveModel(modelString) {
153
227
  return zenOpenAI()(modelId);
154
228
  }
155
229
  if (ZEN_GOOGLE_MODELS.has(modelId)) {
156
- return zenGoogle(modelId)(modelId);
230
+ return zenGoogle()(modelId);
157
231
  }
158
232
  return zenCompat()(modelId);
159
233
  }
@@ -235,17 +309,17 @@ async function fetchAvailableModels() {
235
309
 
236
310
  // src/session/db.ts
237
311
  import { Database } from "bun:sqlite";
238
- import { existsSync, mkdirSync, unlinkSync } from "fs";
239
- import { homedir } from "os";
240
- import { join } from "path";
312
+ import { existsSync as existsSync2, mkdirSync, unlinkSync } from "fs";
313
+ import { homedir as homedir2 } from "os";
314
+ import { join as join2 } from "path";
241
315
  function getConfigDir() {
242
- return join(homedir(), ".config", "mini-coder");
316
+ return join2(homedir2(), ".config", "mini-coder");
243
317
  }
244
318
  function getDbPath() {
245
319
  const dir = getConfigDir();
246
- if (!existsSync(dir))
320
+ if (!existsSync2(dir))
247
321
  mkdirSync(dir, { recursive: true });
248
- return join(dir, "sessions.db");
322
+ return join2(dir, "sessions.db");
249
323
  }
250
324
  var DB_VERSION = 3;
251
325
  var SCHEMA = `
@@ -321,7 +395,7 @@ function getDb() {
321
395
  db.close();
322
396
  } catch {}
323
397
  for (const path of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
324
- if (existsSync(path))
398
+ if (existsSync2(path))
325
399
  unlinkSync(path);
326
400
  }
327
401
  db = new Database(dbPath, { create: true });
@@ -471,6 +545,88 @@ function generateSessionId() {
471
545
  return `${ts}-${rand}`;
472
546
  }
473
547
 
548
+ // src/cli/custom-commands.ts
549
+ import { existsSync as existsSync3, readFileSync as readFileSync2, readdirSync as readdirSync2 } from "fs";
550
+ import { homedir as homedir3 } from "os";
551
+ import { basename as basename2, join as join3 } from "path";
552
+ function loadFromDir2(dir, source) {
553
+ const commands = new Map;
554
+ if (!existsSync3(dir))
555
+ return commands;
556
+ let entries;
557
+ try {
558
+ entries = readdirSync2(dir);
559
+ } catch {
560
+ return commands;
561
+ }
562
+ for (const entry of entries) {
563
+ if (!entry.endsWith(".md"))
564
+ continue;
565
+ const name = basename2(entry, ".md");
566
+ const filePath = join3(dir, entry);
567
+ let raw;
568
+ try {
569
+ raw = readFileSync2(filePath, "utf-8");
570
+ } catch {
571
+ continue;
572
+ }
573
+ const { meta, body } = parseFrontmatter(raw);
574
+ commands.set(name, {
575
+ name,
576
+ description: meta.description ?? name,
577
+ ...meta.model ? { model: meta.model } : {},
578
+ template: body,
579
+ source
580
+ });
581
+ }
582
+ return commands;
583
+ }
584
+ function loadCustomCommands(cwd) {
585
+ const globalDir = join3(homedir3(), ".agents", "commands");
586
+ const localDir = join3(cwd, ".agents", "commands");
587
+ const global = loadFromDir2(globalDir, "global");
588
+ const local = loadFromDir2(localDir, "local");
589
+ return new Map([...global, ...local]);
590
+ }
591
+ async function expandTemplate(template, args, cwd) {
592
+ const tokens = args.match(/("([^"]*)")|('([^']*)')|(\S+)/g)?.map((t) => t.replace(/^["']|["']$/g, "")) ?? [];
593
+ let result = template;
594
+ for (let i = 9;i >= 1; i--) {
595
+ result = result.replaceAll(`$${i}`, tokens[i - 1] ?? "");
596
+ }
597
+ result = result.replaceAll("$ARGUMENTS", args);
598
+ const SHELL_RE = /!`([^`]+)`/g;
599
+ const shellMatches = [...result.matchAll(SHELL_RE)];
600
+ for (const match of shellMatches) {
601
+ const cmd = match[1] ?? "";
602
+ let output = "";
603
+ try {
604
+ const signal = AbortSignal.timeout(1e4);
605
+ const proc = Bun.spawn(["bash", "-c", cmd], {
606
+ cwd,
607
+ stdout: "pipe",
608
+ stderr: "pipe"
609
+ });
610
+ await Promise.race([
611
+ proc.exited,
612
+ new Promise((_, reject) => signal.addEventListener("abort", () => {
613
+ proc.kill();
614
+ reject(new Error("timeout"));
615
+ }))
616
+ ]);
617
+ const [stdout, stderr] = await Promise.all([
618
+ new Response(proc.stdout).text(),
619
+ new Response(proc.stderr).text()
620
+ ]);
621
+ const exitCode = proc.exitCode ?? 0;
622
+ output = exitCode === 0 ? [stdout, stderr].filter(Boolean).join(`
623
+ `).trim() : stdout.trim();
624
+ } catch {}
625
+ result = result.replaceAll(match[0], output);
626
+ }
627
+ return result;
628
+ }
629
+
474
630
  // src/cli/markdown.ts
475
631
  import * as c from "yoctocolors";
476
632
  function renderInline(text) {
@@ -584,10 +740,13 @@ function renderChunk(text, inFence) {
584
740
  }
585
741
 
586
742
  // src/cli/output.ts
587
- import { homedir as homedir2 } from "os";
743
+ import { homedir as homedir4 } from "os";
588
744
  import * as c2 from "yoctocolors";
589
- var HOME = homedir2();
590
- var PACKAGE_VERSION = "0.0.3";
745
+ var HOME = homedir4();
746
+ var PACKAGE_VERSION = "0.0.4";
747
+ function tildePath(p) {
748
+ return p.startsWith(HOME) ? `~${p.slice(HOME.length)}` : p;
749
+ }
591
750
  function restoreTerminal() {
592
751
  try {
593
752
  process.stderr.write("\x1B[?25h");
@@ -1209,6 +1368,48 @@ var PREFIX = {
1209
1368
  success: G.ok
1210
1369
  };
1211
1370
 
1371
+ // src/cli/skills.ts
1372
+ import { existsSync as existsSync4, readFileSync as readFileSync3, readdirSync as readdirSync3, statSync } from "fs";
1373
+ import { homedir as homedir5 } from "os";
1374
+ import { join as join4 } from "path";
1375
+ function loadFromDir3(dir, source) {
1376
+ const skills = new Map;
1377
+ if (!existsSync4(dir))
1378
+ return skills;
1379
+ let entries;
1380
+ try {
1381
+ entries = readdirSync3(dir);
1382
+ } catch {
1383
+ return skills;
1384
+ }
1385
+ for (const entry of entries) {
1386
+ const skillFile = join4(dir, entry, "SKILL.md");
1387
+ try {
1388
+ if (!statSync(join4(dir, entry)).isDirectory())
1389
+ continue;
1390
+ if (!existsSync4(skillFile))
1391
+ continue;
1392
+ const content = readFileSync3(skillFile, "utf-8");
1393
+ const { meta } = parseFrontmatter(content);
1394
+ const name = meta.name ?? entry;
1395
+ skills.set(name, {
1396
+ name,
1397
+ description: meta.description ?? name,
1398
+ content,
1399
+ source
1400
+ });
1401
+ } catch {}
1402
+ }
1403
+ return skills;
1404
+ }
1405
+ function loadSkills(cwd) {
1406
+ const globalDir = join4(homedir5(), ".agents", "skills");
1407
+ const localDir = join4(cwd, ".agents", "skills");
1408
+ const global = loadFromDir3(globalDir, "global");
1409
+ const local = loadFromDir3(localDir, "local");
1410
+ return new Map([...global, ...local]);
1411
+ }
1412
+
1212
1413
  // src/cli/commands.ts
1213
1414
  async function handleModel(ctx, args) {
1214
1415
  if (args) {
@@ -1426,7 +1627,35 @@ function handleNew(ctx) {
1426
1627
  ctx.startNewSession();
1427
1628
  writeln(`${PREFIX.success} ${c3.dim("new session started \u2014 context cleared")}`);
1428
1629
  }
1429
- function handleHelp() {
1630
+ async function handleCustomCommand(cmd, args, ctx) {
1631
+ const prompt = await expandTemplate(cmd.template, args, ctx.cwd);
1632
+ const label = c3.cyan(cmd.name);
1633
+ const srcPath = cmd.source === "local" ? `.agents/commands/${cmd.name}.md` : `~/.agents/commands/${cmd.name}.md`;
1634
+ const src = c3.dim(`[${srcPath}]`);
1635
+ writeln(`${PREFIX.info} ${label} ${src}`);
1636
+ writeln();
1637
+ try {
1638
+ const output = await ctx.runSubagent(prompt, cmd.model);
1639
+ if (output.activity.length) {
1640
+ renderSubagentActivity(output.activity, " ", 1);
1641
+ writeln();
1642
+ }
1643
+ write(renderMarkdown(output.result));
1644
+ writeln();
1645
+ return {
1646
+ type: "inject-user-message",
1647
+ text: `/${cmd.name} output:
1648
+
1649
+ ${output.result}
1650
+
1651
+ <system-message>Summarize the findings above to the user.</system-message>`
1652
+ };
1653
+ } catch (e) {
1654
+ writeln(`${PREFIX.error} /${cmd.name} failed: ${String(e)}`);
1655
+ return { type: "handled" };
1656
+ }
1657
+ }
1658
+ function handleHelp(ctx, custom) {
1430
1659
  writeln();
1431
1660
  const cmds = [
1432
1661
  ["/model [id]", "list or switch models (fetches live list)"],
@@ -1447,7 +1676,35 @@ function handleHelp() {
1447
1676
  for (const [cmd, desc] of cmds) {
1448
1677
  writeln(` ${c3.cyan(cmd.padEnd(26))} ${c3.dim(desc)}`);
1449
1678
  }
1679
+ if (custom.size > 0) {
1680
+ writeln();
1681
+ writeln(c3.dim(" custom commands:"));
1682
+ for (const cmd of custom.values()) {
1683
+ const tag = cmd.source === "local" ? c3.dim(" (local)") : c3.dim(" (global)");
1684
+ writeln(` ${c3.green(`/${cmd.name}`.padEnd(26))} ${c3.dim(cmd.description)}${tag}`);
1685
+ }
1686
+ }
1687
+ const agents = loadAgents(ctx.cwd);
1688
+ if (agents.size > 0) {
1689
+ writeln();
1690
+ writeln(c3.dim(" agents (~/.agents/agents/ or .agents/agents/):"));
1691
+ for (const agent of agents.values()) {
1692
+ const tag = agent.source === "local" ? c3.dim(" (local)") : c3.dim(" (global)");
1693
+ writeln(` ${c3.magenta(`@${agent.name}`.padEnd(26))} ${c3.dim(agent.description)}${tag}`);
1694
+ }
1695
+ }
1696
+ const skills = loadSkills(ctx.cwd);
1697
+ if (skills.size > 0) {
1698
+ writeln();
1699
+ writeln(c3.dim(" skills (~/.agents/skills/ or .agents/skills/):"));
1700
+ for (const skill of skills.values()) {
1701
+ const tag = skill.source === "local" ? c3.dim(" (local)") : c3.dim(" (global)");
1702
+ writeln(` ${c3.yellow(`@${skill.name}`.padEnd(26))} ${c3.dim(skill.description)}${tag}`);
1703
+ }
1704
+ }
1450
1705
  writeln();
1706
+ writeln(` ${c3.green("@agent".padEnd(26))} ${c3.dim("run prompt through a custom agent (Tab to complete)")}`);
1707
+ writeln(` ${c3.green("@skill".padEnd(26))} ${c3.dim("inject skill instructions into prompt (Tab to complete)")}`);
1451
1708
  writeln(` ${c3.green("@file".padEnd(26))} ${c3.dim("inject file contents into prompt (Tab to complete)")}`);
1452
1709
  writeln(` ${c3.green("!cmd".padEnd(26))} ${c3.dim("run shell command, output added as context")}`);
1453
1710
  writeln();
@@ -1455,6 +1712,11 @@ function handleHelp() {
1455
1712
  writeln();
1456
1713
  }
1457
1714
  async function handleCommand(command, args, ctx) {
1715
+ const custom = loadCustomCommands(ctx.cwd);
1716
+ const customCmd = custom.get(command.toLowerCase());
1717
+ if (customCmd) {
1718
+ return await handleCustomCommand(customCmd, args, ctx);
1719
+ }
1458
1720
  switch (command.toLowerCase()) {
1459
1721
  case "model":
1460
1722
  case "models":
@@ -1479,15 +1741,16 @@ async function handleCommand(command, args, ctx) {
1479
1741
  return await handleReview(ctx, args);
1480
1742
  case "help":
1481
1743
  case "?":
1482
- handleHelp();
1744
+ handleHelp(ctx, custom);
1483
1745
  return { type: "handled" };
1484
1746
  case "exit":
1485
1747
  case "quit":
1486
1748
  case "q":
1487
1749
  return { type: "exit" };
1488
- default:
1750
+ default: {
1489
1751
  writeln(`${PREFIX.error} unknown: /${command} ${c3.dim("\u2014 /help for commands")}`);
1490
1752
  return { type: "unknown", command };
1753
+ }
1491
1754
  }
1492
1755
  }
1493
1756
 
@@ -1527,7 +1790,7 @@ async function loadImageFile(filePath) {
1527
1790
  }
1528
1791
 
1529
1792
  // src/cli/input.ts
1530
- import { join as join2, relative } from "path";
1793
+ import { join as join5, relative } from "path";
1531
1794
  import * as c4 from "yoctocolors";
1532
1795
  var ESC = "\x1B";
1533
1796
  var CSI = `${ESC}[`;
@@ -1556,16 +1819,33 @@ var CTRL_K = "\v";
1556
1819
  var CTRL_L = "\f";
1557
1820
  var CTRL_R = "\x12";
1558
1821
  var TAB = "\t";
1559
- async function getFileCompletions(prefix, cwd) {
1822
+ async function getAtCompletions(prefix, cwd) {
1560
1823
  const query = prefix.startsWith("@") ? prefix.slice(1) : prefix;
1561
- const glob = new Bun.Glob(`**/*${query}*`);
1562
1824
  const results = [];
1563
- for await (const file of glob.scan({ cwd, onlyFiles: true })) {
1564
- if (file.includes("node_modules") || file.includes(".git"))
1565
- continue;
1566
- results.push(`@${relative(cwd, join2(cwd, file))}`);
1567
- if (results.length >= 10)
1825
+ const MAX = 10;
1826
+ const skills = loadSkills(cwd);
1827
+ for (const [name] of skills) {
1828
+ if (results.length >= MAX)
1829
+ break;
1830
+ if (name.includes(query))
1831
+ results.push(`@${name}`);
1832
+ }
1833
+ const agents = loadAgents(cwd);
1834
+ for (const [name] of agents) {
1835
+ if (results.length >= MAX)
1568
1836
  break;
1837
+ if (name.includes(query))
1838
+ results.push(`@${name}`);
1839
+ }
1840
+ if (results.length < MAX) {
1841
+ const glob = new Bun.Glob(`**/*${query}*`);
1842
+ for await (const file of glob.scan({ cwd, onlyFiles: true })) {
1843
+ if (file.includes("node_modules") || file.includes(".git"))
1844
+ continue;
1845
+ results.push(`@${relative(cwd, join5(cwd, file))}`);
1846
+ if (results.length >= MAX)
1847
+ break;
1848
+ }
1569
1849
  }
1570
1850
  return results;
1571
1851
  }
@@ -1584,7 +1864,7 @@ async function tryExtractImageFromPaste(pasted, cwd) {
1584
1864
  }
1585
1865
  }
1586
1866
  if (!trimmed.includes(" ") && isImageFilename(trimmed)) {
1587
- const filePath = trimmed.startsWith("/") ? trimmed : join2(cwd, trimmed);
1867
+ const filePath = trimmed.startsWith("/") ? trimmed : join5(cwd, trimmed);
1588
1868
  const attachment = await loadImageFile(filePath);
1589
1869
  if (attachment) {
1590
1870
  const name = filePath.split("/").pop() ?? trimmed;
@@ -1860,7 +2140,7 @@ async function readline(opts) {
1860
2140
  const beforeCursor = buf.slice(0, cursor);
1861
2141
  const atMatch = beforeCursor.match(/@(\S*)$/);
1862
2142
  if (atMatch) {
1863
- const completions = await getFileCompletions(atMatch[0], cwd);
2143
+ const completions = await getAtCompletions(atMatch[0], cwd);
1864
2144
  if (completions.length === 1 && completions[0]) {
1865
2145
  const replacement = completions[0];
1866
2146
  buf = buf.slice(0, cursor - (atMatch[0] ?? "").length) + replacement + buf.slice(cursor);
@@ -2104,8 +2384,8 @@ async function connectMcpServer(config) {
2104
2384
  }
2105
2385
 
2106
2386
  // src/tools/snapshot.ts
2107
- import { readFileSync, unlinkSync as unlinkSync2 } from "fs";
2108
- import { join as join3 } from "path";
2387
+ import { readFileSync as readFileSync4, unlinkSync as unlinkSync2 } from "fs";
2388
+ import { join as join6 } from "path";
2109
2389
  async function gitBytes(args, cwd) {
2110
2390
  try {
2111
2391
  const proc = Bun.spawn(["git", ...args], {
@@ -2196,7 +2476,7 @@ async function takeSnapshot(cwd, sessionId, turnIndex) {
2196
2476
  return false;
2197
2477
  const files = [];
2198
2478
  for (const entry of entries) {
2199
- const absPath = join3(repoRoot, entry.path);
2479
+ const absPath = join6(repoRoot, entry.path);
2200
2480
  if (!entry.existsOnDisk) {
2201
2481
  const { bytes, code } = await gitBytes(["show", `HEAD:${entry.path}`], repoRoot);
2202
2482
  if (code === 0) {
@@ -2210,7 +2490,7 @@ async function takeSnapshot(cwd, sessionId, turnIndex) {
2210
2490
  }
2211
2491
  if (entry.isNew) {
2212
2492
  try {
2213
- const content = readFileSync(absPath);
2493
+ const content = readFileSync4(absPath);
2214
2494
  files.push({
2215
2495
  path: entry.path,
2216
2496
  content: new Uint8Array(content),
@@ -2220,7 +2500,7 @@ async function takeSnapshot(cwd, sessionId, turnIndex) {
2220
2500
  continue;
2221
2501
  }
2222
2502
  try {
2223
- const content = readFileSync(absPath);
2503
+ const content = readFileSync4(absPath);
2224
2504
  files.push({
2225
2505
  path: entry.path,
2226
2506
  content: new Uint8Array(content),
@@ -2245,7 +2525,7 @@ async function restoreSnapshot(cwd, sessionId, turnIndex) {
2245
2525
  const root = repoRoot ?? cwd;
2246
2526
  let anyFailed = false;
2247
2527
  for (const file of files) {
2248
- const absPath = join3(root, file.path);
2528
+ const absPath = join6(root, file.path);
2249
2529
  if (!file.existed) {
2250
2530
  try {
2251
2531
  if (await Bun.file(absPath).exists()) {
@@ -2274,7 +2554,6 @@ async function restoreSnapshot(cwd, sessionId, turnIndex) {
2274
2554
  }
2275
2555
 
2276
2556
  // src/session/manager.ts
2277
- import { homedir as homedir3 } from "os";
2278
2557
  import * as c5 from "yoctocolors";
2279
2558
  function newSession(model, cwd) {
2280
2559
  const id = generateSessionId();
@@ -2301,7 +2580,7 @@ function printSessionList() {
2301
2580
  ${c5.bold("Recent sessions:")}`);
2302
2581
  for (const s of sessions) {
2303
2582
  const date = new Date(s.updated_at).toLocaleString();
2304
- const cwd = s.cwd.startsWith(homedir3()) ? `~${s.cwd.slice(homedir3().length)}` : s.cwd;
2583
+ const cwd = tildePath(s.cwd);
2305
2584
  const title = s.title || c5.dim("(untitled)");
2306
2585
  writeln(` ${c5.dim(s.id.padEnd(14))} ${title.padEnd(30)} ${c5.cyan(s.model.split("/").pop() ?? s.model).padEnd(20)} ${c5.dim(cwd)} ${c5.dim(date)}`);
2307
2586
  }
@@ -2314,8 +2593,8 @@ function getMostRecentSession() {
2314
2593
  }
2315
2594
 
2316
2595
  // src/tools/create.ts
2317
- import { existsSync as existsSync2, mkdirSync as mkdirSync2 } from "fs";
2318
- import { dirname, join as join4, relative as relative2 } from "path";
2596
+ import { existsSync as existsSync5, mkdirSync as mkdirSync2 } from "fs";
2597
+ import { dirname, join as join7, relative as relative2 } from "path";
2319
2598
  import { z as z2 } from "zod";
2320
2599
 
2321
2600
  // src/tools/diff.ts
@@ -2455,10 +2734,10 @@ var createTool = {
2455
2734
  schema: CreateSchema,
2456
2735
  execute: async (input) => {
2457
2736
  const cwd = input.cwd ?? process.cwd();
2458
- const filePath = input.path.startsWith("/") ? input.path : join4(cwd, input.path);
2737
+ const filePath = input.path.startsWith("/") ? input.path : join7(cwd, input.path);
2459
2738
  const relPath = relative2(cwd, filePath);
2460
2739
  const dir = dirname(filePath);
2461
- if (!existsSync2(dir))
2740
+ if (!existsSync5(dir))
2462
2741
  mkdirSync2(dir, { recursive: true });
2463
2742
  const file = Bun.file(filePath);
2464
2743
  const created = !await file.exists();
@@ -2470,7 +2749,7 @@ var createTool = {
2470
2749
  };
2471
2750
 
2472
2751
  // src/tools/glob.ts
2473
- import { join as join5, relative as relative3 } from "path";
2752
+ import { join as join8, relative as relative3 } from "path";
2474
2753
  import { z as z3 } from "zod";
2475
2754
  var GlobSchema = z3.object({
2476
2755
  pattern: z3.string().describe("Glob pattern to match files against, e.g. '**/*.ts'"),
@@ -2502,7 +2781,7 @@ var globTool = {
2502
2781
  if (ignored)
2503
2782
  continue;
2504
2783
  try {
2505
- const fullPath = join5(cwd, file);
2784
+ const fullPath = join8(cwd, file);
2506
2785
  const stat = await Bun.file(fullPath).stat?.() ?? null;
2507
2786
  matches.push({ path: file, mtime: stat?.mtime?.getTime() ?? 0 });
2508
2787
  } catch {
@@ -2515,13 +2794,13 @@ var globTool = {
2515
2794
  if (truncated)
2516
2795
  matches.pop();
2517
2796
  matches.sort((a, b) => b.mtime - a.mtime);
2518
- const files = matches.map((m) => relative3(cwd, join5(cwd, m.path)));
2797
+ const files = matches.map((m) => relative3(cwd, join8(cwd, m.path)));
2519
2798
  return { files, count: files.length, truncated };
2520
2799
  }
2521
2800
  };
2522
2801
 
2523
2802
  // src/tools/grep.ts
2524
- import { join as join6 } from "path";
2803
+ import { join as join9 } from "path";
2525
2804
  import { z as z4 } from "zod";
2526
2805
 
2527
2806
  // src/tools/hashline.ts
@@ -2607,7 +2886,7 @@ var grepTool = {
2607
2886
  if (ignoreGlob.some((g) => g.match(relPath) || g.match(relPath.split("/")[0] ?? ""))) {
2608
2887
  continue;
2609
2888
  }
2610
- const fullPath = join6(cwd, relPath);
2889
+ const fullPath = join9(cwd, relPath);
2611
2890
  let text;
2612
2891
  try {
2613
2892
  text = await Bun.file(fullPath).text();
@@ -2658,8 +2937,8 @@ var grepTool = {
2658
2937
 
2659
2938
  // src/tools/hooks.ts
2660
2939
  import { constants, accessSync } from "fs";
2661
- import { homedir as homedir4 } from "os";
2662
- import { join as join7 } from "path";
2940
+ import { homedir as homedir6 } from "os";
2941
+ import { join as join10 } from "path";
2663
2942
  function isExecutable(filePath) {
2664
2943
  try {
2665
2944
  accessSync(filePath, constants.X_OK);
@@ -2671,8 +2950,8 @@ function isExecutable(filePath) {
2671
2950
  function findHook(toolName, cwd) {
2672
2951
  const scriptName = `post-${toolName}`;
2673
2952
  const candidates = [
2674
- join7(cwd, ".agents", "hooks", scriptName),
2675
- join7(homedir4(), ".agents", "hooks", scriptName)
2953
+ join10(cwd, ".agents", "hooks", scriptName),
2954
+ join10(homedir6(), ".agents", "hooks", scriptName)
2676
2955
  ];
2677
2956
  for (const p of candidates) {
2678
2957
  if (isExecutable(p))
@@ -2761,7 +3040,7 @@ function hookEnvForRead(input, cwd) {
2761
3040
  }
2762
3041
 
2763
3042
  // src/tools/insert.ts
2764
- import { join as join8, relative as relative4 } from "path";
3043
+ import { join as join11, relative as relative4 } from "path";
2765
3044
  import { z as z5 } from "zod";
2766
3045
  var InsertSchema = z5.object({
2767
3046
  path: z5.string().describe("File path to edit (absolute or relative to cwd)"),
@@ -2776,7 +3055,7 @@ var insertTool = {
2776
3055
  schema: InsertSchema,
2777
3056
  execute: async (input) => {
2778
3057
  const cwd = input.cwd ?? process.cwd();
2779
- const filePath = input.path.startsWith("/") ? input.path : join8(cwd, input.path);
3058
+ const filePath = input.path.startsWith("/") ? input.path : join11(cwd, input.path);
2780
3059
  const relPath = relative4(cwd, filePath);
2781
3060
  const file = Bun.file(filePath);
2782
3061
  if (!await file.exists()) {
@@ -2821,7 +3100,7 @@ function parseAnchor(value) {
2821
3100
  }
2822
3101
 
2823
3102
  // src/tools/read.ts
2824
- import { join as join9, relative as relative5 } from "path";
3103
+ import { join as join12, relative as relative5 } from "path";
2825
3104
  import { z as z6 } from "zod";
2826
3105
  var ReadSchema = z6.object({
2827
3106
  path: z6.string().describe("File path to read (absolute or relative to cwd)"),
@@ -2836,7 +3115,7 @@ var readTool = {
2836
3115
  schema: ReadSchema,
2837
3116
  execute: async (input) => {
2838
3117
  const cwd = input.cwd ?? process.cwd();
2839
- const filePath = input.path.startsWith("/") ? input.path : join9(cwd, input.path);
3118
+ const filePath = input.path.startsWith("/") ? input.path : join12(cwd, input.path);
2840
3119
  const file = Bun.file(filePath);
2841
3120
  const exists = await file.exists();
2842
3121
  if (!exists) {
@@ -2869,7 +3148,7 @@ var readTool = {
2869
3148
  };
2870
3149
 
2871
3150
  // src/tools/replace.ts
2872
- import { join as join10, relative as relative6 } from "path";
3151
+ import { join as join13, relative as relative6 } from "path";
2873
3152
  import { z as z7 } from "zod";
2874
3153
  var ReplaceSchema = z7.object({
2875
3154
  path: z7.string().describe("File path to edit (absolute or relative to cwd)"),
@@ -2884,7 +3163,7 @@ var replaceTool = {
2884
3163
  schema: ReplaceSchema,
2885
3164
  execute: async (input) => {
2886
3165
  const cwd = input.cwd ?? process.cwd();
2887
- const filePath = input.path.startsWith("/") ? input.path : join10(cwd, input.path);
3166
+ const filePath = input.path.startsWith("/") ? input.path : join13(cwd, input.path);
2888
3167
  const relPath = relative6(cwd, filePath);
2889
3168
  const file = Bun.file(filePath);
2890
3169
  if (!await file.exists()) {
@@ -3025,15 +3304,19 @@ var shellTool = {
3025
3304
  // src/tools/subagent.ts
3026
3305
  import { z as z9 } from "zod";
3027
3306
  var SubagentInput = z9.object({
3028
- prompt: z9.string().describe("The task or question to give the subagent")
3307
+ prompt: z9.string().describe("The task or question to give the subagent"),
3308
+ agentName: z9.string().optional().describe("Name of a custom agent to use (from .agents/agents/). Omit to use a generic subagent.")
3029
3309
  });
3030
- function createSubagentTool(runSubagent) {
3310
+ function createSubagentTool(runSubagent, availableAgents) {
3311
+ const agentSection = availableAgents.size > 0 ? `
3312
+
3313
+ When the user's message contains @<agent-name>, delegate to that agent by setting agentName to the exact agent name. Available custom agents: ${[...availableAgents.entries()].map(([name, cfg]) => `"${name}" (${cfg.description})`).join(", ")}.` : "";
3031
3314
  return {
3032
3315
  name: "subagent",
3033
- description: "Spawn a sub-agent to handle a focused subtask. " + "Use this for parallel exploration, specialised analysis, or tasks that benefit from " + "a fresh context window. The subagent has access to all the same tools.",
3316
+ description: `Spawn a sub-agent to handle a focused subtask. Use this for parallel exploration, specialised analysis, or tasks that benefit from a fresh context window. The subagent has access to all the same tools.${agentSection}`,
3034
3317
  schema: SubagentInput,
3035
3318
  execute: async (input) => {
3036
- return runSubagent(input.prompt);
3319
+ return runSubagent(input.prompt, input.agentName);
3037
3320
  }
3038
3321
  };
3039
3322
  }
@@ -3090,12 +3373,12 @@ function buildToolSet(opts) {
3090
3373
  withHooks(withCwdDefault(replaceTool, cwd), lookupHook, cwd, (result) => hookEnvForReplace(result, cwd), onHook),
3091
3374
  withHooks(withCwdDefault(insertTool, cwd), lookupHook, cwd, (result) => hookEnvForInsert(result, cwd), onHook),
3092
3375
  withHooks(withCwdDefault(shellTool, cwd), lookupHook, cwd, (result, input) => hookEnvForShell(result, input, cwd), onHook),
3093
- createSubagentTool(async (prompt) => {
3376
+ createSubagentTool(async (prompt, agentName) => {
3094
3377
  if (depth >= MAX_SUBAGENT_DEPTH) {
3095
3378
  throw new Error(`Subagent depth limit reached (max ${MAX_SUBAGENT_DEPTH}). ` + `Cannot spawn another subagent from depth ${depth}.`);
3096
3379
  }
3097
- return opts.runSubagent(prompt, depth + 1);
3098
- })
3380
+ return opts.runSubagent(prompt, depth + 1, agentName);
3381
+ }, opts.availableAgents)
3099
3382
  ];
3100
3383
  }
3101
3384
  function buildReadOnlyToolSet(opts) {
@@ -3126,14 +3409,14 @@ async function getGitBranch(cwd) {
3126
3409
  }
3127
3410
  function loadContextFile(cwd) {
3128
3411
  const candidates = [
3129
- join11(cwd, "AGENTS.md"),
3130
- join11(cwd, "CLAUDE.md"),
3131
- join11(getConfigDir(), "AGENTS.md")
3412
+ join14(cwd, "AGENTS.md"),
3413
+ join14(cwd, "CLAUDE.md"),
3414
+ join14(getConfigDir(), "AGENTS.md")
3132
3415
  ];
3133
3416
  for (const p of candidates) {
3134
- if (existsSync3(p)) {
3417
+ if (existsSync6(p)) {
3135
3418
  try {
3136
- return readFileSync2(p, "utf-8");
3419
+ return readFileSync5(p, "utf-8");
3137
3420
  } catch {}
3138
3421
  }
3139
3422
  }
@@ -3141,7 +3424,7 @@ function loadContextFile(cwd) {
3141
3424
  }
3142
3425
  function buildSystemPrompt(cwd) {
3143
3426
  const contextFile = loadContextFile(cwd);
3144
- const cwdDisplay = cwd.startsWith(homedir5()) ? `~${cwd.slice(homedir5().length)}` : cwd;
3427
+ const cwdDisplay = tildePath(cwd);
3145
3428
  const now = new Date().toLocaleString(undefined, { hour12: false });
3146
3429
  let prompt = `You are mini-coder, a small and fast CLI coding agent.
3147
3430
  You have access to tools to read files, search code, make edits, run shell commands, and spawn subagents.
@@ -3205,16 +3488,23 @@ async function runAgent(opts) {
3205
3488
  }
3206
3489
  let turnIndex = getMaxTurnIndex(session.id) + 1;
3207
3490
  const coreHistory = [...session.messages];
3208
- const runSubagent = async (prompt, depth = 0) => {
3491
+ const runSubagent = async (prompt, depth = 0, agentName, modelOverride) => {
3492
+ const allAgents = loadAgents(cwd);
3493
+ const agentConfig = agentName ? allAgents.get(agentName) : undefined;
3494
+ if (agentName && !agentConfig) {
3495
+ throw new Error(`Unknown agent "${agentName}". Available agents: ${[...allAgents.keys()].join(", ") || "(none)"}`);
3496
+ }
3497
+ const model = modelOverride ?? agentConfig?.model ?? currentModel;
3498
+ const systemPrompt = agentConfig?.systemPrompt ?? buildSystemPrompt(cwd);
3209
3499
  const subMessages = [{ role: "user", content: prompt }];
3210
3500
  const subTools = buildToolSet({
3211
3501
  cwd,
3212
3502
  depth,
3213
3503
  runSubagent,
3214
- onHook: renderHook
3504
+ onHook: renderHook,
3505
+ availableAgents: allAgents
3215
3506
  });
3216
- const subLlm = resolveModel(currentModel);
3217
- const systemPrompt = buildSystemPrompt(cwd);
3507
+ const subLlm = resolveModel(model);
3218
3508
  let result = "";
3219
3509
  let inputTokens = 0;
3220
3510
  let outputTokens = 0;
@@ -3254,11 +3544,13 @@ async function runAgent(opts) {
3254
3544
  }
3255
3545
  return { result, inputTokens, outputTokens, activity };
3256
3546
  };
3547
+ const agents = loadAgents(cwd);
3257
3548
  const tools = buildToolSet({
3258
3549
  cwd,
3259
3550
  depth: 0,
3260
3551
  runSubagent,
3261
- onHook: renderHook
3552
+ onHook: renderHook,
3553
+ availableAgents: agents
3262
3554
  });
3263
3555
  const mcpTools = [];
3264
3556
  async function connectAndAddMcp(name) {
@@ -3310,7 +3602,7 @@ async function runAgent(opts) {
3310
3602
  planMode = v;
3311
3603
  },
3312
3604
  cwd,
3313
- runSubagent: (prompt) => runSubagent(prompt),
3605
+ runSubagent: (prompt, model) => runSubagent(prompt, 0, undefined, model),
3314
3606
  undoLastTurn: async () => {
3315
3607
  if (session.messages.length === 0)
3316
3608
  return false;
@@ -3526,7 +3818,7 @@ ${out}
3526
3818
  const branch = await getGitBranch(cwd);
3527
3819
  const provider = currentModel.split("/")[0] ?? "";
3528
3820
  const modelShort = currentModel.split("/").slice(1).join("/");
3529
- const cwdDisplay = cwd.startsWith(homedir5()) ? `~${cwd.slice(homedir5().length)}` : cwd;
3821
+ const cwdDisplay = tildePath(cwd);
3530
3822
  renderStatusBar({
3531
3823
  model: modelShort,
3532
3824
  provider,
@@ -3567,11 +3859,20 @@ async function resolveFileRefs(text, cwd) {
3567
3859
  let result = text;
3568
3860
  const matches = [...text.matchAll(atPattern)];
3569
3861
  const images = [];
3570
- for (const match of matches.reverse()) {
3862
+ const skills = loadSkills(cwd);
3863
+ for (const match of [...matches].reverse()) {
3571
3864
  const ref = match[1];
3572
3865
  if (!ref)
3573
3866
  continue;
3574
- const filePath = ref.startsWith("/") ? ref : join11(cwd, ref);
3867
+ const skill = skills.get(ref);
3868
+ if (skill) {
3869
+ const replacement = `<skill name="${skill.name}">
3870
+ ${skill.content}
3871
+ </skill>`;
3872
+ result = result.slice(0, match.index) + replacement + result.slice((match.index ?? 0) + match[0].length);
3873
+ continue;
3874
+ }
3875
+ const filePath = ref.startsWith("/") ? ref : join14(cwd, ref);
3575
3876
  if (isImageFilename(ref)) {
3576
3877
  const attachment = await loadImageFile(filePath);
3577
3878
  if (attachment) {
@@ -0,0 +1,70 @@
1
+ # Custom Agents
2
+
3
+ An agent is a subagent with a custom system prompt and optional model override.
4
+ Use `@agent-name` anywhere in your prompt to route the message through it.
5
+
6
+ ## Where to put them
7
+
8
+ | Location | Scope |
9
+ |---|---|
10
+ | `.agents/agents/*.md` | Current repo only |
11
+ | `~/.agents/agents/*.md` | All projects (global) |
12
+
13
+ Local agents override global ones with the same name.
14
+
15
+ ## Create an agent
16
+
17
+ The filename becomes the agent name.
18
+
19
+ `~/.agents/agents/reviewer.md`:
20
+
21
+ ```md
22
+ ---
23
+ description: Strict code reviewer focused on bugs and structure
24
+ model: zen/claude-sonnet-4-6
25
+ ---
26
+
27
+ You are a senior engineer doing a code review. Be direct and specific.
28
+ Cite file and line number for every finding. Flag bugs first, then
29
+ structure issues, then style — only if they violate project conventions.
30
+ No flattery. End with a one-line verdict.
31
+ ```
32
+
33
+ Then in the REPL:
34
+
35
+ ```
36
+ @reviewer review the auth module for race conditions
37
+ ```
38
+
39
+ The rest of the message (everything except the `@reviewer` token) becomes
40
+ the prompt. The agent runs in its own context window and returns its output
41
+ into the conversation.
42
+
43
+ ## Frontmatter fields
44
+
45
+ | Field | Required | Description |
46
+ |---|---|---|
47
+ | `description` | No | Shown in `/help`. Defaults to filename. |
48
+ | `model` | No | Override the active model for this agent. |
49
+
50
+ The markdown body (after frontmatter) is the agent's system prompt.
51
+
52
+ ## Combining with files
53
+
54
+ `@file` references are resolved before the agent fires:
55
+
56
+ ```
57
+ @reviewer @src/auth/session.ts check this file for issues
58
+ ```
59
+
60
+ ## Tab completion
61
+
62
+ Type `@` and press `Tab` to autocomplete agent names alongside files.
63
+
64
+ ## Listing agents
65
+
66
+ ```
67
+ /help
68
+ ```
69
+
70
+ Agents are listed in magenta, tagged `(local)` or `(global)`.
@@ -0,0 +1,133 @@
1
+ # Custom Commands
2
+
3
+ Custom commands let you define reusable prompts that run as `/command` in the mini-coder REPL.
4
+
5
+ ## Where to put them
6
+
7
+ | Location | Scope |
8
+ |---|---|
9
+ | `.agents/commands/*.md` | Current repo only |
10
+ | `~/.agents/commands/*.md` | All projects (global) |
11
+
12
+ Local commands override global ones with the same name.
13
+
14
+ ## Create a command
15
+
16
+ Create a markdown file. The filename becomes the command name.
17
+
18
+ `.agents/commands/standup.md`:
19
+
20
+ ```md
21
+ ---
22
+ description: Summarise what changed since yesterday
23
+ model: zen/claude-3-5-haiku
24
+ ---
25
+
26
+ Run `!`git log --oneline --since=yesterday`` and summarise the changes
27
+ as a short standup update. Group by theme, skip merge commits.
28
+ ```
29
+
30
+ Then in the REPL:
31
+
32
+ ```
33
+ /standup
34
+ ```
35
+
36
+ ## Frontmatter fields
37
+
38
+ | Field | Required | Description |
39
+ |---|---|---|
40
+ | `description` | No | Shown in `/help`. Defaults to the command name. |
41
+ | `model` | No | Override the active model for this command. |
42
+
43
+ ## Arguments
44
+
45
+ Use `$ARGUMENTS` for the full argument string, or `$1`, `$2`, … `$9` for individual tokens.
46
+
47
+ `.agents/commands/search.md`:
48
+
49
+ ```md
50
+ ---
51
+ description: Search the codebase for a topic
52
+ model: zen/claude-3-5-haiku
53
+ ---
54
+
55
+ Search the codebase for: $ARGUMENTS
56
+
57
+ Use glob, grep, and read tools to explore thoroughly. Report all
58
+ relevant files, key code snippets with line numbers, and a short summary.
59
+ Be exhaustive but concise. No edits — read only.
60
+ ```
61
+
62
+ ```
63
+ /search session management
64
+ /search error handling in providers
65
+ ```
66
+
67
+ Positional tokens:
68
+
69
+ ```md
70
+ ---
71
+ description: Create a new component
72
+ ---
73
+
74
+ Create a React component named $1 in the $2 directory.
75
+ Use TypeScript, include prop types and a default export.
76
+ ```
77
+
78
+ ```
79
+ /component Button src/ui
80
+ ```
81
+
82
+ ## Shell interpolation
83
+
84
+ Use `` !`cmd` `` to inject shell output into the prompt at expansion time.
85
+ Commands time out after 10 seconds.
86
+
87
+ ```md
88
+ ---
89
+ description: Review failing tests
90
+ ---
91
+
92
+ The following tests are currently failing:
93
+
94
+ !`bun test 2>&1 | grep "fail\|✗" | head -20`
95
+
96
+ Investigate the failures and suggest fixes. Read the relevant source
97
+ files before drawing conclusions.
98
+ ```
99
+
100
+ ```
101
+ /fix-tests
102
+ ```
103
+
104
+ ## Model override
105
+
106
+ Specify a model in frontmatter to use a faster or cheaper model for
107
+ lightweight tasks regardless of what the session is currently set to.
108
+
109
+ ```md
110
+ ---
111
+ description: Quick grep for a symbol
112
+ model: zen/claude-3-5-haiku
113
+ ---
114
+
115
+ Find all usages of $ARGUMENTS across the codebase using grep and glob.
116
+ List each occurrence with file path and line number. No explanations needed.
117
+ ```
118
+
119
+ Large models for deep analysis, small models for search and lookup.
120
+
121
+ ## Precedence
122
+
123
+ Custom commands shadow built-ins. If you create `.agents/commands/review.md`
124
+ it will replace the built-in `/review` for that project.
125
+
126
+ ## Listing commands
127
+
128
+ ```
129
+ /help
130
+ ```
131
+
132
+ Custom commands are listed at the bottom under **custom commands**, tagged
133
+ with `(local)` or `(global)`.
package/docs/skills.md ADDED
@@ -0,0 +1,71 @@
1
+ # Skills
2
+
3
+ A skill is a reusable instruction file injected inline into your prompt.
4
+ Use `@skill-name` to load it — the content is inserted into the message
5
+ before it's sent to the LLM.
6
+
7
+ > **Skills are never auto-loaded.** They must be explicitly referenced
8
+ > with `@skill-name` in your prompt. Nothing is injected automatically.
9
+
10
+ ## Where to put them
11
+
12
+ Each skill is a folder containing a `SKILL.md`:
13
+
14
+ | Location | Scope |
15
+ |---|---|
16
+ | `.agents/skills/<name>/SKILL.md` | Current repo only |
17
+ | `~/.agents/skills/<name>/SKILL.md` | All projects (global) |
18
+
19
+ Local skills override global ones with the same name.
20
+
21
+ ## Create a skill
22
+
23
+ The folder name becomes the skill name (unless overridden by `name:` in frontmatter).
24
+
25
+ `.agents/skills/conventional-commits/SKILL.md`:
26
+
27
+ ```md
28
+ ---
29
+ name: conventional-commits
30
+ description: Conventional commit message format rules
31
+ ---
32
+
33
+ # Conventional Commits
34
+
35
+ All commit messages must follow this format:
36
+
37
+ <type>(<scope>): <short summary>
38
+
39
+ Types: feat, fix, docs, refactor, test, chore
40
+ - Summary is lowercase, no period at the end
41
+ - Breaking changes: add `!` after type, e.g. `feat!:`
42
+ - Body is optional, wrapped at 72 chars
43
+ ```
44
+
45
+ Then in the REPL:
46
+
47
+ ```
48
+ @conventional-commits write a commit message for my staged changes
49
+ ```
50
+
51
+ The skill content is wrapped in `<skill name="…">…</skill>` tags and
52
+ included in the message sent to the LLM.
53
+
54
+ ## Frontmatter fields
55
+
56
+ | Field | Required | Description |
57
+ |---|---|---|
58
+ | `name` | No | Skill name for `@` reference. Defaults to folder name. |
59
+ | `description` | No | Shown in `/help`. Defaults to name. |
60
+
61
+ ## Tab completion
62
+
63
+ Type `@` and press `Tab` to autocomplete skill names alongside files.
64
+
65
+ ## Listing skills
66
+
67
+ ```
68
+ /help
69
+ ```
70
+
71
+ Skills are listed in yellow, tagged `(local)` or `(global)`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mini-coder",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "description": "A small, fast CLI coding agent",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -30,5 +30,6 @@
30
30
  "@biomejs/biome": "^1.9.4",
31
31
  "@types/bun": "latest",
32
32
  "typescript": "^5.8.3"
33
- }
33
+ },
34
+ "license": "MIT"
34
35
  }