bots-garden/mini-mepublic Fork
⑂ c26f9e8
Commits
⬇ Clone ▾
git clone https://git.rickub.com/bots-garden/mini-me.git
git clone ssh://git@rickub.com/bots-garden/mini-me.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

Merge feature/zed-skill-selector into main

rickub committed 2026-09-21T20:29:07Z Browse files
c26f9e8 parents: ec8e46a 37a5a39
modified .tickets/issues/0037-add-a-file-selector-to-the-tui-mode.yaml +2 -2
@@ -1,12 +1,12 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 37
33 title: Add a file selector to the tui mode
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-09-21T18:55:37.512Z
9-updatedAt: 2026-09-21T19:03:09.902Z
9+updatedAt: 2026-09-21T20:27:52.705Z
1010 labels: []
1111 milestone: v0.0.3
1212 body: ""
@@ -1,12 +1,12 @@
1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2 id: 372 id: 37
3 title: Add a file selector to the tui mode3 title: Add a file selector to the tui mode
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-09-21T18:55:37.512Z8 createdAt: 2026-09-21T18:55:37.512Z
9-updatedAt: 2026-09-21T19:03:09.902Z9+updatedAt: 2026-09-21T20:27:52.705Z
10 labels: []10 labels: []
11 milestone: v0.0.311 milestone: v0.0.3
12 body: ""12 body: ""
modified .tickets/issues/0038-add-a-skill-selector.yaml +2 -2
@@ -1,12 +1,12 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 38
33 title: add a skill selector
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-09-21T19:00:12.508Z
9-updatedAt: 2026-09-21T19:02:47.785Z
9+updatedAt: 2026-09-21T20:28:01.189Z
1010 labels: []
1111 milestone: v0.0.3
1212 body: ""
@@ -1,12 +1,12 @@
1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2 id: 382 id: 38
3 title: add a skill selector3 title: add a skill selector
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-09-21T19:00:12.508Z8 createdAt: 2026-09-21T19:00:12.508Z
9-updatedAt: 2026-09-21T19:02:47.785Z9+updatedAt: 2026-09-21T20:28:01.189Z
10 labels: []10 labels: []
11 milestone: v0.0.311 milestone: v0.0.3
12 body: ""12 body: ""
modified demo/agent.dmr.yaml +1 -1
@@ -57,7 +57,7 @@ lsp:
5757
5858 # Where the `read_skill` tool looks for its markdown procedures.
5959 # The path is relative to the CURRENT DIRECTORY, so run the agent from the directory that holds skills.
60-skillsDir: .mm/skills
60+skillsDir: skills
6161
6262 # Persistent sessions, per project.
6363 # When enabled, every conversation is saved as one JSON file under `dir`
@@ -57,7 +57,7 @@ lsp:
57 57
58 # Where the `read_skill` tool looks for its markdown procedures. 58 # Where the `read_skill` tool looks for its markdown procedures.
59 # The path is relative to the CURRENT DIRECTORY, so run the agent from the directory that holds skills.59 # The path is relative to the CURRENT DIRECTORY, so run the agent from the directory that holds skills.
60-skillsDir: .mm/skills60+skillsDir: skills
61 61
62 # Persistent sessions, per project. 62 # Persistent sessions, per project.
63 # When enabled, every conversation is saved as one JSON file under `dir` 63 # When enabled, every conversation is saved as one JSON file under `dir`
added demo/skills/README.md +2 -0
new file mode 100644
@@ -0,0 +1,2 @@
1+# Demo skills
2+> for test
new file mode 100644
@@ -0,0 +1,2 @@
1+# Demo skills
2+> for test
added demo/skills/bg-jobs/SKILL.md +82 -0
new file mode 100644
@@ -0,0 +1,82 @@
1+---
2+name: bg-jobs
3+description: Start, wait on, inspect and stop a long-running background job — a server, a watcher, a build that does not end — without blocking the turn.
4+keywords: background, job, server, serve, start, daemon, nohup, watch, tail, log, pid, kill, stop, port, running
5+---
6+
7+# Background jobs
8+
9+## When to use it
10+
11+Any command that **serves, watches, or runs until stopped**: an HTTP server, `npm run dev`, `go run` on a web app, a file watcher, a queue worker. Read this before you start one, and before you wait on, inspect or stop one.
12+
13+A command that never returns will hold the turn for its whole budget and then be killed. The answer the user asked for never arrives.
14+
15+## Start it
16+
17+Both streams redirected, the pid kept:
18+
19+```bash
20+nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid
21+```
22+
23+Three parts, and all three matter:
24+
25+- **`> /tmp/<job>.log 2>&1`** — both streams. Redirecting only stdout is the classic mistake: the job keeps the tool's own error pipe open, and the call cannot finish until the job does. You get the 30-second budget spent on nothing.
26+- **`&`** — the shell returns at once.
27+- **`echo $! > /tmp/<job>.pid`** — without the pid you cannot stop it later except by guessing at `pkill` patterns, which also kills things you did not start.
28+
29+Use one name per job, and reuse it for the log and the pid file.
30+
31+## Wait for it to be ready
32+
33+**Do not `sleep` and hope.** Poll for the thing you actually need, with a bound:
34+
35+```bash
36+for i in $(seq 1 30); do
37+ curl -fsS localhost:8080/ >/dev/null 2>&1 && echo ready && break
38+ sleep 0.5
39+done
40+```
41+
42+For something that is not an HTTP server, poll the log for the line it prints when it is up:
43+
44+```bash
45+for i in $(seq 1 30); do
46+ grep -q "listening on" /tmp/<job>.log && echo ready && break
47+ sleep 0.5
48+done
49+```
50+
51+If the loop ends without `ready`, the job did not start — go read the log.
52+
53+## Inspect it
54+
55+```bash
56+tail -n 40 /tmp/<job>.log # what it said
57+kill -0 "$(cat /tmp/<job>.pid)" 2>/dev/null && echo alive || echo dead
58+```
59+
60+`kill -0` sends no signal; it only asks whether the process is there. A job that is `dead` right after starting almost always says why in the last lines of its log — read them before changing anything.
61+
62+## Stop it
63+
64+```bash
65+kill "$(cat /tmp/<job>.pid)" 2>/dev/null
66+sleep 0.5
67+kill -0 "$(cat /tmp/<job>.pid)" 2>/dev/null && kill -9 "$(cat /tmp/<job>.pid)"
68+rm -f /tmp/<job>.pid
69+```
70+
71+Ask politely first (`kill` sends TERM, which lets a server close its listener), then insist. Removing the pid file afterwards stops a later command from acting on a pid the system has since reused for something else.
72+
73+## Before you finish
74+
75+**Stop every job you started**, and **say which ones you left running** and why. A server still holding port 8080 after the turn is a surprise the user will hit later, when nothing on screen explains it.
76+
77+## Traps
78+
79+- **One shell per call.** Each `bash` call is a new shell in the same directory: a job started in one call is still running in the next, but shell variables are gone. That is exactly why the pid goes to a file and not to `$PID`.
80+- **The port is already in use.** A previous run left something behind. `lsof -i :8080` or `ss -lptn 'sport = :8080'` names the holder; stop that before starting again, rather than moving to another port and forgetting.
81+- **Nothing is interactive.** There is no stdin. A command that asks a question will hang until it is killed; pass the answer as a flag, or do not run it.
82+- **`nohup` writes `nohup.out`** in the working directory when you forget to redirect — if you see that file appear, a redirect is missing.
new file mode 100644
@@ -0,0 +1,82 @@
1+---
2+name: bg-jobs
3+description: Start, wait on, inspect and stop a long-running background job — a server, a watcher, a build that does not end — without blocking the turn.
4+keywords: background, job, server, serve, start, daemon, nohup, watch, tail, log, pid, kill, stop, port, running
5+---
6+
7+# Background jobs
8+
9+## When to use it
10+
11+Any command that **serves, watches, or runs until stopped**: an HTTP server, `npm run dev`, `go run` on a web app, a file watcher, a queue worker. Read this before you start one, and before you wait on, inspect or stop one.
12+
13+A command that never returns will hold the turn for its whole budget and then be killed. The answer the user asked for never arrives.
14+
15+## Start it
16+
17+Both streams redirected, the pid kept:
18+
19+```bash
20+nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid
21+```
22+
23+Three parts, and all three matter:
24+
25+- **`> /tmp/<job>.log 2>&1`** — both streams. Redirecting only stdout is the classic mistake: the job keeps the tool's own error pipe open, and the call cannot finish until the job does. You get the 30-second budget spent on nothing.
26+- **`&`** — the shell returns at once.
27+- **`echo $! > /tmp/<job>.pid`** — without the pid you cannot stop it later except by guessing at `pkill` patterns, which also kills things you did not start.
28+
29+Use one name per job, and reuse it for the log and the pid file.
30+
31+## Wait for it to be ready
32+
33+**Do not `sleep` and hope.** Poll for the thing you actually need, with a bound:
34+
35+```bash
36+for i in $(seq 1 30); do
37+ curl -fsS localhost:8080/ >/dev/null 2>&1 && echo ready && break
38+ sleep 0.5
39+done
40+```
41+
42+For something that is not an HTTP server, poll the log for the line it prints when it is up:
43+
44+```bash
45+for i in $(seq 1 30); do
46+ grep -q "listening on" /tmp/<job>.log && echo ready && break
47+ sleep 0.5
48+done
49+```
50+
51+If the loop ends without `ready`, the job did not start — go read the log.
52+
53+## Inspect it
54+
55+```bash
56+tail -n 40 /tmp/<job>.log # what it said
57+kill -0 "$(cat /tmp/<job>.pid)" 2>/dev/null && echo alive || echo dead
58+```
59+
60+`kill -0` sends no signal; it only asks whether the process is there. A job that is `dead` right after starting almost always says why in the last lines of its log — read them before changing anything.
61+
62+## Stop it
63+
64+```bash
65+kill "$(cat /tmp/<job>.pid)" 2>/dev/null
66+sleep 0.5
67+kill -0 "$(cat /tmp/<job>.pid)" 2>/dev/null && kill -9 "$(cat /tmp/<job>.pid)"
68+rm -f /tmp/<job>.pid
69+```
70+
71+Ask politely first (`kill` sends TERM, which lets a server close its listener), then insist. Removing the pid file afterwards stops a later command from acting on a pid the system has since reused for something else.
72+
73+## Before you finish
74+
75+**Stop every job you started**, and **say which ones you left running** and why. A server still holding port 8080 after the turn is a surprise the user will hit later, when nothing on screen explains it.
76+
77+## Traps
78+
79+- **One shell per call.** Each `bash` call is a new shell in the same directory: a job started in one call is still running in the next, but shell variables are gone. That is exactly why the pid goes to a file and not to `$PID`.
80+- **The port is already in use.** A previous run left something behind. `lsof -i :8080` or `ss -lptn 'sport = :8080'` names the holder; stop that before starting again, rather than moving to another port and forgetting.
81+- **Nothing is interactive.** There is no stdin. A command that asks a question will hang until it is killed; pass the answer as a flag, or do not run it.
82+- **`nohup` writes `nohup.out`** in the working directory when you forget to redirect — if you see that file appear, a redirect is missing.
added demo/skills/greetings/SKILL.md +19 -0
new file mode 100644
@@ -0,0 +1,19 @@
1+---
2+name: greetings
3+description: Greet somebody with a festive message.
4+keywords: greet, greetings, hello, hi, welcome, bonjour, coucou, saluer
5+---
6+
7+# Greetings
8+
9+## What to do
10+
11+The command below is for YOU to run with your `bash` tool, in this same turn.
12+Do not show it to the user, and do not answer from memory: the answer is the
13+command's output, nothing else.
14+
15+Replace `<name>` with the person being greeted, then run:
16+
17+```bash
18+echo "🎉 greetings <name>"
19+```
new file mode 100644
@@ -0,0 +1,19 @@
1+---
2+name: greetings
3+description: Greet somebody with a festive message.
4+keywords: greet, greetings, hello, hi, welcome, bonjour, coucou, saluer
5+---
6+
7+# Greetings
8+
9+## What to do
10+
11+The command below is for YOU to run with your `bash` tool, in this same turn.
12+Do not show it to the user, and do not answer from memory: the answer is the
13+command's output, nothing else.
14+
15+Replace `<name>` with the person being greeted, then run:
16+
17+```bash
18+echo "🎉 greetings <name>"
19+```
added demo/skills/lsp-navigation/SKILL.md +45 -0
new file mode 100644
@@ -0,0 +1,45 @@
1+---
2+name: lsp-navigation
3+description: Navigate and change code with the `lsp` tool — find where a symbol is defined, who uses it, what its type is, what the compiler reports, and rename it everywhere.
4+keywords: lsp, definition, references, usages, callers, hover, type, signature, symbols, outline, diagnostics, errors, warnings, rename, refactor, language server
5+---
6+
7+# Navigate code with the `lsp` tool
8+
9+## When to use it
10+
11+The `lsp` tool asks the project's language server what the compiler knows. Prefer it to `grep` and to reading whole files when the question is about **symbols**: where something is declared, who uses it, what type it has, what is wrong in a file, or how to rename it safely. Its description lists the servers available and the file extensions they cover; for other files, fall back to `bash` and `read_file`.
12+
13+## How to point at a symbol
14+
15+Give the **identifier** as written in the code, in `symbol`: the tool finds its first occurrence in the file. Add `line` (1-based) when the same name appears several times and you want a particular one. This is the reliable way; never guess a column.
16+
17+Only when you have an exact position — from `read_file` with `numbered=true` — may you send `line` and `column` instead (both 1-based, the column pointing inside the identifier).
18+
19+## The actions
20+
21+| You want to know… | Call |
22+| --- | --- |
23+| Where a symbol is declared | `{"action":"definition","path":"main.go","symbol":"hello"}` |
24+| Every place that uses it | `{"action":"references","path":"main.go","symbol":"hello"}` |
25+| Its signature, type and documentation | `{"action":"hover","path":"main.go","symbol":"hello"}` |
26+| What a file contains (outline) | `{"action":"symbols","path":"main.go"}` |
27+| What the compiler reports in a file | `{"action":"diagnostics","path":"main.go"}` |
28+| Rename it everywhere, files rewritten | `{"action":"rename","path":"main.go","symbol":"hello","new_name":"greet"}` |
29+| The occurrence on one line only | add `"line": 9` to any of the calls above |
30+
31+Answers come as `path:line:column <source line>`, one per location. You rarely need to open the file afterwards.
32+
33+## Procedure for a change
34+
35+1. `symbols` on the file to find the symbol, or `read_file numbered=true` if you already know roughly where it is.
36+2. `references` on the symbol to see every file the change touches. Say how many there are.
37+3. Edit with `edit_file` (or `rename` when the change is a rename — it rewrites all the files at once).
38+4. `diagnostics` on each edited file to see what the compiler reports, then fix what it says.
39+5. Only then run the tests with `bash`.
40+
41+## Rules
42+
43+- A `[lsp: …]` answer is information, not a crash: read it. "no language server for" means the extension is not covered — use `bash`. "not found in" means the identifier is not spelled as in the code — read the file and copy it. "past the end of line" means you miscounted — use `symbol` instead.
44+- `diagnostics` right after an edit may say "as last published" or "no diagnostics received": the server is still indexing. Ask again once.
45+- `rename` writes files. It NEEDS `new_name`, the new identifier; without it the tool answers with the exact call to make — make it, do not fall back to `edit_file`. Say what it changed, and never rename to a name the file already uses for something else.
new file mode 100644
@@ -0,0 +1,45 @@
1+---
2+name: lsp-navigation
3+description: Navigate and change code with the `lsp` tool — find where a symbol is defined, who uses it, what its type is, what the compiler reports, and rename it everywhere.
4+keywords: lsp, definition, references, usages, callers, hover, type, signature, symbols, outline, diagnostics, errors, warnings, rename, refactor, language server
5+---
6+
7+# Navigate code with the `lsp` tool
8+
9+## When to use it
10+
11+The `lsp` tool asks the project's language server what the compiler knows. Prefer it to `grep` and to reading whole files when the question is about **symbols**: where something is declared, who uses it, what type it has, what is wrong in a file, or how to rename it safely. Its description lists the servers available and the file extensions they cover; for other files, fall back to `bash` and `read_file`.
12+
13+## How to point at a symbol
14+
15+Give the **identifier** as written in the code, in `symbol`: the tool finds its first occurrence in the file. Add `line` (1-based) when the same name appears several times and you want a particular one. This is the reliable way; never guess a column.
16+
17+Only when you have an exact position — from `read_file` with `numbered=true` — may you send `line` and `column` instead (both 1-based, the column pointing inside the identifier).
18+
19+## The actions
20+
21+| You want to know… | Call |
22+| --- | --- |
23+| Where a symbol is declared | `{"action":"definition","path":"main.go","symbol":"hello"}` |
24+| Every place that uses it | `{"action":"references","path":"main.go","symbol":"hello"}` |
25+| Its signature, type and documentation | `{"action":"hover","path":"main.go","symbol":"hello"}` |
26+| What a file contains (outline) | `{"action":"symbols","path":"main.go"}` |
27+| What the compiler reports in a file | `{"action":"diagnostics","path":"main.go"}` |
28+| Rename it everywhere, files rewritten | `{"action":"rename","path":"main.go","symbol":"hello","new_name":"greet"}` |
29+| The occurrence on one line only | add `"line": 9` to any of the calls above |
30+
31+Answers come as `path:line:column <source line>`, one per location. You rarely need to open the file afterwards.
32+
33+## Procedure for a change
34+
35+1. `symbols` on the file to find the symbol, or `read_file numbered=true` if you already know roughly where it is.
36+2. `references` on the symbol to see every file the change touches. Say how many there are.
37+3. Edit with `edit_file` (or `rename` when the change is a rename — it rewrites all the files at once).
38+4. `diagnostics` on each edited file to see what the compiler reports, then fix what it says.
39+5. Only then run the tests with `bash`.
40+
41+## Rules
42+
43+- A `[lsp: …]` answer is information, not a crash: read it. "no language server for" means the extension is not covered — use `bash`. "not found in" means the identifier is not spelled as in the code — read the file and copy it. "past the end of line" means you miscounted — use `symbol` instead.
44+- `diagnostics` right after an edit may say "as last published" or "no diagnostics received": the server is still indexing. Ask again once.
45+- `rename` writes files. It NEEDS `new_name`, the new identifier; without it the tool answers with the exact call to make — make it, do not fall back to `edit_file`. Say what it changed, and never rename to a name the file already uses for something else.
added demo/skills/vulcan-salute/SKILL.md +19 -0
new file mode 100644
@@ -0,0 +1,19 @@
1+---
2+name: vulcan-salute
3+description: Send a vulcan salute to somebody.
4+keywords: vulcan, vulcain, salute, salut, spock, star trek, live long and prosper, longue vie
5+---
6+
7+# Vulcan salute
8+
9+## What to do
10+
11+The command below is for YOU to run with your `bash` tool, in this same turn.
12+Do not show it to the user, and do not answer from memory: the answer is the
13+command's output, nothing else.
14+
15+Replace `<name>` with the person being saluted, then run:
16+
17+```bash
18+echo "🖖 Live long and prosper! <name> 🖖"
19+```
new file mode 100644
@@ -0,0 +1,19 @@
1+---
2+name: vulcan-salute
3+description: Send a vulcan salute to somebody.
4+keywords: vulcan, vulcain, salute, salut, spock, star trek, live long and prosper, longue vie
5+---
6+
7+# Vulcan salute
8+
9+## What to do
10+
11+The command below is for YOU to run with your `bash` tool, in this same turn.
12+Do not show it to the user, and do not answer from memory: the answer is the
13+command's output, nothing else.
14+
15+Replace `<name>` with the person being saluted, then run:
16+
17+```bash
18+echo "🖖 Live long and prosper! <name> 🖖"
19+```
added demo/skills/what-time-is-it/SKILL.md +19 -0
new file mode 100644
@@ -0,0 +1,19 @@
1+---
2+name: what-time-is-it
3+description: Give the current date and time.
4+keywords: time, date, clock, now, today, heure, date du jour, quelle heure
5+---
6+
7+# What time is it?
8+
9+## What to do
10+
11+The command below is for YOU to run with your `bash` tool, in this same turn.
12+Do not show it to the user, and never guess the time: only the command knows it.
13+
14+Run:
15+
16+```bash
17+date "+%A %d %B %Y — %H:%M:%S (%Z)"
18+```
19+
new file mode 100644
@@ -0,0 +1,19 @@
1+---
2+name: what-time-is-it
3+description: Give the current date and time.
4+keywords: time, date, clock, now, today, heure, date du jour, quelle heure
5+---
6+
7+# What time is it?
8+
9+## What to do
10+
11+The command below is for YOU to run with your `bash` tool, in this same turn.
12+Do not show it to the user, and never guess the time: only the command knows it.
13+
14+Run:
15+
16+```bash
17+date "+%A %d %B %Y — %H:%M:%S (%Z)"
18+```
19+
modified docs/en/reference/acp.md +28 -2
@@ -34,6 +34,31 @@
3434 | `session/load` | With `sessions.enabled`: reads `<cwd>/<sessions.dir>/<sessionId>.json`, replaces its system prompt by the current one, registers the session (empty allow-always set), sends the replay (below) then an `available_commands_update`, and answers `{}`. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: `method not found`. |
3535 | `session/set_mode`, `session/set_config_option`, `session/list`, `session/resume`, `session/close`, `logout` | `method not found`. |
3636
37+## Skills as commands
38+
39+ACP has no notion of a skill. A prompt carries `text`, `image`, `audio`, `resource` or `resource_link` — the last being what an editor's `@` file picker sends — and there is nothing for "run this procedure".
40+
41+`AvailableCommand` is the slot, and the protocol's own examples say so: its documentation offers `create_plan` and `research_codebase`, which are procedures rather than session commands. So each skill of the catalogue is advertised there, and the editor lists it in its own `/` menu:
42+
43+```
44+/compact Compress the history now, keeping the last turns
45+/new Clear the history and start a new session
46+/bg-jobs [input: parameters, if …] Start, wait on, inspect and stop a long-running background job…
47+/greetings [input: parameters, if …] Greet somebody with a festive message.
48+/vulcan-salute [input: parameters, if …] Send a vulcan salute to somebody.
49+```
50+
51+The `input.hint` is what carries the parameters: the protocol says "all text that was typed after the command name is provided as input", which is exactly what a skill takes.
52+
53+| Aspect | Behaviour |
54+|---|---|
55+| What happens on `/greetings Bob` | **Not intercepted.** It is rewritten as the `$greetings Bob` notation and the turn runs normally, so the model still makes the `read_skill` call — the loop detector, the permission gate and the `📖 N skill(s)` recap all still see it, and `mm` never loads a skill behind the model's back. |
56+| A real command wins | `session/prompt` tries the interception first, so a skill called `compact` or `new` can never shadow one. Such a skill is **not advertised** either, and the start-up banner says so — otherwise it would be silently unreachable from the menu. |
57+| An unknown `/name` | Left exactly as typed and handed to the model, as any arbitrary slash text was before. |
58+| Typing `$greetings Bob` by hand | Works too, in the editor as in the terminal: the notation is resolved in `session/prompt` whether the menu wrote it or the user did. |
59+
60+Note the invariant changed with this: it used to be "only advertise what `session/prompt` intercepts", and it is now "only advertise what this front end **handles**" — rewriting is handling.
61+
3762 ## `session/load` replay
3863
3964 Sent before the response, one notification per element of the stored history, in order. The system prompt is not sent.
@@ -67,7 +92,7 @@ No tool runs during a replay. Logged as `[acp] session <id> loaded from <dir>: N
6792
6893 | Update | When |
6994 |--------|------|
70-| `available_commands_update` | Once per session, right after the `session/new` response has been written (the editor must know the session id first, or it drops the update). Lists the slash commands `session/prompt` intercepts. Also sent at the end of a `session/load` replay. |
95+| `available_commands_update` | Once per session, right after the `session/new` response has been written (the editor must know the session id first, or it drops the update). Lists what this front end **handles**: the slash commands `session/prompt` intercepts, then one command per **skill**. Also sent at the end of a `session/load` replay. |
7196 | `agent_message_chunk` (text) | Each streamed chunk of the model's answer, whitespace preserved; also the one-line confirmation of a slash command. |
7297 | `tool_call` status `pending` | Before a tool runs; carries `title`, `kind`, `rawInput`, and `locations` with the absolute path when a file is involved. |
7398 | `tool_call_update` status `in_progress` | Permission granted, tool executing. |
@@ -79,8 +104,9 @@ No tool runs during a replay. Logged as `[acp] session <id> loaded from <dir>: N
79104 |--------|---------------|---------|
80105 | `new` | `Clear the history and start a new session` | none |
81106 | `compact` | `Compress the history now, keeping the last turns` | none |
107+| one per skill | the skill's `description` | `{ "hint": "parameters, if this skill needs any" }` |
82108
83-Clients display the name with a leading slash and send it back as the text of a `session/prompt`.
109+Clients display the name with a leading slash and send it back as the text of a `session/prompt`. The two commands above are intercepted there; a skill's command is rewritten into the `$skill` notation and the turn runs — see [Skills as commands](#skills-as-commands).
84110
85111 ## `session/request_permission` requests sent
86112
@@ -34,6 +34,31 @@
34 | `session/load` | With `sessions.enabled`: reads `<cwd>/<sessions.dir>/<sessionId>.json`, replaces its system prompt by the current one, registers the session (empty allow-always set), sends the replay (below) then an `available_commands_update`, and answers `{}`. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: `method not found`. |34 | `session/load` | With `sessions.enabled`: reads `<cwd>/<sessions.dir>/<sessionId>.json`, replaces its system prompt by the current one, registers the session (empty allow-always set), sends the replay (below) then an `available_commands_update`, and answers `{}`. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: `method not found`. |
35 | `session/set_mode`, `session/set_config_option`, `session/list`, `session/resume`, `session/close`, `logout` | `method not found`. |35 | `session/set_mode`, `session/set_config_option`, `session/list`, `session/resume`, `session/close`, `logout` | `method not found`. |
36 36
37+## Skills as commands
38+
39+ACP has no notion of a skill. A prompt carries `text`, `image`, `audio`, `resource` or `resource_link` — the last being what an editor's `@` file picker sends — and there is nothing for "run this procedure".
40+
41+`AvailableCommand` is the slot, and the protocol's own examples say so: its documentation offers `create_plan` and `research_codebase`, which are procedures rather than session commands. So each skill of the catalogue is advertised there, and the editor lists it in its own `/` menu:
42+
43+```
44+/compact Compress the history now, keeping the last turns
45+/new Clear the history and start a new session
46+/bg-jobs [input: parameters, if …] Start, wait on, inspect and stop a long-running background job…
47+/greetings [input: parameters, if …] Greet somebody with a festive message.
48+/vulcan-salute [input: parameters, if …] Send a vulcan salute to somebody.
49+```
50+
51+The `input.hint` is what carries the parameters: the protocol says "all text that was typed after the command name is provided as input", which is exactly what a skill takes.
52+
53+| Aspect | Behaviour |
54+|---|---|
55+| What happens on `/greetings Bob` | **Not intercepted.** It is rewritten as the `$greetings Bob` notation and the turn runs normally, so the model still makes the `read_skill` call — the loop detector, the permission gate and the `📖 N skill(s)` recap all still see it, and `mm` never loads a skill behind the model's back. |
56+| A real command wins | `session/prompt` tries the interception first, so a skill called `compact` or `new` can never shadow one. Such a skill is **not advertised** either, and the start-up banner says so — otherwise it would be silently unreachable from the menu. |
57+| An unknown `/name` | Left exactly as typed and handed to the model, as any arbitrary slash text was before. |
58+| Typing `$greetings Bob` by hand | Works too, in the editor as in the terminal: the notation is resolved in `session/prompt` whether the menu wrote it or the user did. |
59+
60+Note the invariant changed with this: it used to be "only advertise what `session/prompt` intercepts", and it is now "only advertise what this front end **handles**" — rewriting is handling.
61+
37 ## `session/load` replay62 ## `session/load` replay
38 63
39 Sent before the response, one notification per element of the stored history, in order. The system prompt is not sent.64 Sent before the response, one notification per element of the stored history, in order. The system prompt is not sent.
@@ -67,7 +92,7 @@ No tool runs during a replay. Logged as `[acp] session <id> loaded from <dir>: N
67 92
68 | Update | When |93 | Update | When |
69 |--------|------|94 |--------|------|
70-| `available_commands_update` | Once per session, right after the `session/new` response has been written (the editor must know the session id first, or it drops the update). Lists the slash commands `session/prompt` intercepts. Also sent at the end of a `session/load` replay. |95+| `available_commands_update` | Once per session, right after the `session/new` response has been written (the editor must know the session id first, or it drops the update). Lists what this front end **handles**: the slash commands `session/prompt` intercepts, then one command per **skill**. Also sent at the end of a `session/load` replay. |
71 | `agent_message_chunk` (text) | Each streamed chunk of the model's answer, whitespace preserved; also the one-line confirmation of a slash command. |96 | `agent_message_chunk` (text) | Each streamed chunk of the model's answer, whitespace preserved; also the one-line confirmation of a slash command. |
72 | `tool_call` status `pending` | Before a tool runs; carries `title`, `kind`, `rawInput`, and `locations` with the absolute path when a file is involved. |97 | `tool_call` status `pending` | Before a tool runs; carries `title`, `kind`, `rawInput`, and `locations` with the absolute path when a file is involved. |
73 | `tool_call_update` status `in_progress` | Permission granted, tool executing. |98 | `tool_call_update` status `in_progress` | Permission granted, tool executing. |
@@ -79,8 +104,9 @@ No tool runs during a replay. Logged as `[acp] session <id> loaded from <dir>: N
79 |--------|---------------|---------|104 |--------|---------------|---------|
80 | `new` | `Clear the history and start a new session` | none |105 | `new` | `Clear the history and start a new session` | none |
81 | `compact` | `Compress the history now, keeping the last turns` | none |106 | `compact` | `Compress the history now, keeping the last turns` | none |
107+| one per skill | the skill's `description` | `{ "hint": "parameters, if this skill needs any" }` |
82 108
83-Clients display the name with a leading slash and send it back as the text of a `session/prompt`.109+Clients display the name with a leading slash and send it back as the text of a `session/prompt`. The two commands above are intercepted there; a skill's command is rewritten into the `$skill` notation and the turn runs — see [Skills as commands](#skills-as-commands).
84 110
85 ## `session/request_permission` requests sent111 ## `session/request_permission` requests sent
86 112
modified docs/en/reference/skill-format.md +1 -1
@@ -59,7 +59,7 @@ The `keywords` of that example reach the catalogue as `what-time-is-it — Give
5959
6060 ## Asking for a skill by name
6161
62-`$name` in a question names a skill to carry out, with the rest of the line as its parameters — `use $greetings Bob`. The window's `$` picker writes it for you; typed by hand it works in the line mode and over ACP too. It appends one line to what the model reads, and the model still makes the `read_skill` call itself. See [Slash commands — the three pickers](slash-commands.md#the-three-pickers).
62+`$name` in a question names a skill to carry out, with the rest of the line as its parameters — `use $greetings Bob`. The window's `$` picker writes it for you; typed by hand it works in the line mode and over ACP too. In an editor the skill also appears in the client's own `/` menu, with an input hint for its parameters — see [ACP — skills as commands](acp.md#skills-as-commands). It appends one line to what the model reads, and the model still makes the `read_skill` call itself. See [Slash commands — the three pickers](slash-commands.md#the-three-pickers).
6363
6464 ## Writing a skill the model will ACT on
6565
@@ -59,7 +59,7 @@ The `keywords` of that example reach the catalogue as `what-time-is-it — Give
59 59
60 ## Asking for a skill by name60 ## Asking for a skill by name
61 61
62-`$name` in a question names a skill to carry out, with the rest of the line as its parameters — `use $greetings Bob`. The window's `$` picker writes it for you; typed by hand it works in the line mode and over ACP too. It appends one line to what the model reads, and the model still makes the `read_skill` call itself. See [Slash commands — the three pickers](slash-commands.md#the-three-pickers).62+`$name` in a question names a skill to carry out, with the rest of the line as its parameters — `use $greetings Bob`. The window's `$` picker writes it for you; typed by hand it works in the line mode and over ACP too. In an editor the skill also appears in the client's own `/` menu, with an input hint for its parameters — see [ACP — skills as commands](acp.md#skills-as-commands). It appends one line to what the model reads, and the model still makes the `read_skill` call itself. See [Slash commands — the three pickers](slash-commands.md#the-three-pickers).
63 63
64 ## Writing a skill the model will ACT on64 ## Writing a skill the model will ACT on
65 65
modified docs/en/reference/slash-commands.md +1 -1
@@ -132,4 +132,4 @@ Two commands exist in ACP mode: `/new` and `/compact`. They are advertised to th
132132 | `/new` | Same as in the terminal: history back to the system prompt, token count forgotten; the session id, `cwd` and allow-always grants are kept. |
133133 | `/compact` | Same as in the terminal, always forced: `🗜️ compressed …`, `🗜️ nothing to compact: …` or `[compact: failed, history kept: …]`. Waits for a running turn to finish first. |
134134
135-`/quit` and `/abort` do not exist in ACP mode: the editor owns the process and ends it by closing stdin, and cancels a turn with its own stop key through `session/cancel`. See the [ACP reference](acp.md).
135+`/quit` and `/abort` do not exist in ACP mode: the editor owns the process and ends it by closing stdin, and cancels a turn with its own stop key through `session/cancel`. Each **skill** is offered there as a command of its own — ACP has no picker for skills, so `/greetings Bob` in the editor's menu is the equivalent of `$greetings Bob` in the window, and it is rewritten into exactly that. See the [ACP reference](acp.md#skills-as-commands).
@@ -132,4 +132,4 @@ Two commands exist in ACP mode: `/new` and `/compact`. They are advertised to th
132 | `/new` | Same as in the terminal: history back to the system prompt, token count forgotten; the session id, `cwd` and allow-always grants are kept. |132 | `/new` | Same as in the terminal: history back to the system prompt, token count forgotten; the session id, `cwd` and allow-always grants are kept. |
133 | `/compact` | Same as in the terminal, always forced: `🗜️ compressed …`, `🗜️ nothing to compact: …` or `[compact: failed, history kept: …]`. Waits for a running turn to finish first. |133 | `/compact` | Same as in the terminal, always forced: `🗜️ compressed …`, `🗜️ nothing to compact: …` or `[compact: failed, history kept: …]`. Waits for a running turn to finish first. |
134 134
135-`/quit` and `/abort` do not exist in ACP mode: the editor owns the process and ends it by closing stdin, and cancels a turn with its own stop key through `session/cancel`. See the [ACP reference](acp.md).135+`/quit` and `/abort` do not exist in ACP mode: the editor owns the process and ends it by closing stdin, and cancels a turn with its own stop key through `session/cancel`. Each **skill** is offered there as a command of its own — ACP has no picker for skills, so `/greetings Bob` in the editor's menu is the equivalent of `$greetings Bob` in the window, and it is rewritten into exactly that. See the [ACP reference](acp.md#skills-as-commands).
modified docs/fr/reference/acp.md +28 -2
@@ -34,6 +34,31 @@
3434 | `session/load` | Avec `sessions.enabled` : lit `<cwd>/<sessions.dir>/<sessionId>.json`, remplace son prompt système par l'actuel, enregistre la session (ensemble « toujours autoriser » vide), envoie le rejeu (ci-dessous) puis un `available_commands_update`, et répond `{}`. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : `method not found`. |
3535 | `session/set_mode`, `session/set_config_option`, `session/list`, `session/resume`, `session/close`, `logout` | `method not found`. |
3636
37+## Les skills comme commandes
38+
39+ACP n'a aucune notion de skill. Un prompt porte du `text`, une `image`, de l'`audio`, une `resource` ou un `resource_link` — ce dernier étant ce qu'envoie le sélecteur `@` d'un éditeur — et rien pour « exécute cette procédure ».
40+
41+`AvailableCommand` est le bon créneau, et les exemples du protocole le disent : sa documentation propose `create_plan` et `research_codebase`, qui sont des procédures et non des commandes de session. Chaque skill du catalogue y est donc annoncé, et l'éditeur le liste dans son propre menu `/` :
42+
43+```
44+/compact Compress the history now, keeping the last turns
45+/new Clear the history and start a new session
46+/bg-jobs [input: parameters, if …] Start, wait on, inspect and stop a long-running background job…
47+/greetings [input: parameters, if …] Greet somebody with a festive message.
48+/vulcan-salute [input: parameters, if …] Send a vulcan salute to somebody.
49+```
50+
51+C'est l'`input.hint` qui porte les paramètres : le protocole dit « tout le texte tapé après le nom de la commande est fourni en entrée », ce qui est exactement ce qu'attend un skill.
52+
53+| Aspect | Comportement |
54+|---|---|
55+| Ce que fait `/greetings Bob` | **Pas intercepté.** Réécrit en la notation `$greetings Bob`, et le tour se déroule normalement : c'est toujours le modèle qui fait l'appel `read_skill`, donc le détecteur de boucle, la demande de permission et le recap `📖 N skill(s)` le voient tous, et `mm` ne charge jamais un skill dans le dos du modèle. |
56+| Une vraie commande gagne | `session/prompt` tente l'interception d'abord, donc un skill nommé `compact` ou `new` ne peut jamais en masquer une. Un tel skill n'est **pas annoncé** non plus, et la bannière de démarrage le signale — sinon il serait silencieusement inatteignable depuis le menu. |
57+| Un `/nom` inconnu | Laissé tel quel et passé au modèle, comme n'importe quel texte slash arbitraire avant. |
58+| Taper `$greetings Bob` à la main | Marche aussi, dans l'éditeur comme dans le terminal : la notation est résolue dans `session/prompt`, que le menu l'ait écrite ou l'utilisateur. |
59+
60+À noter, l'invariant a changé avec ça : c'était « n'annoncer que ce que `session/prompt` intercepte », c'est maintenant « n'annoncer que ce que cette façade **traite** » — réécrire, c'est traiter.
61+
3762 ## Rejeu de `session/load`
3863
3964 Envoyé avant la réponse, une notification par élément de l'historique enregistré, dans l'ordre. Le prompt système n'est pas envoyé.
@@ -67,7 +92,7 @@ Aucun outil ne s'exécute pendant un rejeu. Journalisé comme `[acp] session <id
6792
6893 | Mise à jour | Quand |
6994 |-------------|-------|
70-| `available_commands_update` | Une fois par session, juste après l'écriture de la réponse à `session/new` (l'éditeur doit d'abord connaître l'identifiant de session, sinon il ignore la mise à jour). Liste les commandes slash que `session/prompt` intercepte. Envoyée aussi à la fin d'un rejeu de `session/load`. |
95+| `available_commands_update` | Une fois par session, juste après l'écriture de la réponse à `session/new` (l'éditeur doit connaître l'id de session d'abord, sinon il ignore la notification). Liste ce que cette façade **traite** : les commandes slash que `session/prompt` intercepte, puis une commande par **skill**. Envoyée aussi à la fin d'un rejeu `session/load`. |
7196 | `agent_message_chunk` (texte) | Chaque fragment diffusé de la réponse du modèle, espaces préservés ; aussi la ligne de confirmation d'une commande slash. |
7297 | `tool_call` statut `pending` | Avant l'exécution d'un outil ; porte `title`, `kind`, `rawInput`, et `locations` avec le chemin absolu quand un fichier est concerné. |
7398 | `tool_call_update` statut `in_progress` | Permission accordée, outil en cours d'exécution. |
@@ -79,8 +104,9 @@ Aucun outil ne s'exécute pendant un rejeu. Journalisé comme `[acp] session <id
79104 |--------|---------------|---------|
80105 | `new` | `Clear the history and start a new session` | aucun |
81106 | `compact` | `Compress the history now, keeping the last turns` | aucun |
107+| un par skill | la `description` du skill | `{ "hint": "parameters, if this skill needs any" }` |
82108
83-Les clients affichent le nom précédé d'une barre oblique et le renvoient comme texte d'un `session/prompt`.
109+Les clients affichent le nom précédé d'une barre oblique et le renvoient comme texte d'un `session/prompt`. Les deux commandes ci-dessus y sont interceptées ; la commande d'un skill est réécrite en la notation `$skill` et le tour se déroule — voir [Les skills comme commandes](#les-skills-comme-commandes).
84110
85111 ## Requêtes `session/request_permission` envoyées
86112
@@ -34,6 +34,31 @@
34 | `session/load` | Avec `sessions.enabled` : lit `<cwd>/<sessions.dir>/<sessionId>.json`, remplace son prompt système par l'actuel, enregistre la session (ensemble « toujours autoriser » vide), envoie le rejeu (ci-dessous) puis un `available_commands_update`, et répond `{}`. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : `method not found`. |34 | `session/load` | Avec `sessions.enabled` : lit `<cwd>/<sessions.dir>/<sessionId>.json`, remplace son prompt système par l'actuel, enregistre la session (ensemble « toujours autoriser » vide), envoie le rejeu (ci-dessous) puis un `available_commands_update`, et répond `{}`. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : `method not found`. |
35 | `session/set_mode`, `session/set_config_option`, `session/list`, `session/resume`, `session/close`, `logout` | `method not found`. |35 | `session/set_mode`, `session/set_config_option`, `session/list`, `session/resume`, `session/close`, `logout` | `method not found`. |
36 36
37+## Les skills comme commandes
38+
39+ACP n'a aucune notion de skill. Un prompt porte du `text`, une `image`, de l'`audio`, une `resource` ou un `resource_link` — ce dernier étant ce qu'envoie le sélecteur `@` d'un éditeur — et rien pour « exécute cette procédure ».
40+
41+`AvailableCommand` est le bon créneau, et les exemples du protocole le disent : sa documentation propose `create_plan` et `research_codebase`, qui sont des procédures et non des commandes de session. Chaque skill du catalogue y est donc annoncé, et l'éditeur le liste dans son propre menu `/` :
42+
43+```
44+/compact Compress the history now, keeping the last turns
45+/new Clear the history and start a new session
46+/bg-jobs [input: parameters, if …] Start, wait on, inspect and stop a long-running background job…
47+/greetings [input: parameters, if …] Greet somebody with a festive message.
48+/vulcan-salute [input: parameters, if …] Send a vulcan salute to somebody.
49+```
50+
51+C'est l'`input.hint` qui porte les paramètres : le protocole dit « tout le texte tapé après le nom de la commande est fourni en entrée », ce qui est exactement ce qu'attend un skill.
52+
53+| Aspect | Comportement |
54+|---|---|
55+| Ce que fait `/greetings Bob` | **Pas intercepté.** Réécrit en la notation `$greetings Bob`, et le tour se déroule normalement : c'est toujours le modèle qui fait l'appel `read_skill`, donc le détecteur de boucle, la demande de permission et le recap `📖 N skill(s)` le voient tous, et `mm` ne charge jamais un skill dans le dos du modèle. |
56+| Une vraie commande gagne | `session/prompt` tente l'interception d'abord, donc un skill nommé `compact` ou `new` ne peut jamais en masquer une. Un tel skill n'est **pas annoncé** non plus, et la bannière de démarrage le signale — sinon il serait silencieusement inatteignable depuis le menu. |
57+| Un `/nom` inconnu | Laissé tel quel et passé au modèle, comme n'importe quel texte slash arbitraire avant. |
58+| Taper `$greetings Bob` à la main | Marche aussi, dans l'éditeur comme dans le terminal : la notation est résolue dans `session/prompt`, que le menu l'ait écrite ou l'utilisateur. |
59+
60+À noter, l'invariant a changé avec ça : c'était « n'annoncer que ce que `session/prompt` intercepte », c'est maintenant « n'annoncer que ce que cette façade **traite** » — réécrire, c'est traiter.
61+
37 ## Rejeu de `session/load`62 ## Rejeu de `session/load`
38 63
39 Envoyé avant la réponse, une notification par élément de l'historique enregistré, dans l'ordre. Le prompt système n'est pas envoyé.64 Envoyé avant la réponse, une notification par élément de l'historique enregistré, dans l'ordre. Le prompt système n'est pas envoyé.
@@ -67,7 +92,7 @@ Aucun outil ne s'exécute pendant un rejeu. Journalisé comme `[acp] session <id
67 92
68 | Mise à jour | Quand |93 | Mise à jour | Quand |
69 |-------------|-------|94 |-------------|-------|
70-| `available_commands_update` | Une fois par session, juste après l'écriture de la réponse à `session/new` (l'éditeur doit d'abord connaître l'identifiant de session, sinon il ignore la mise à jour). Liste les commandes slash que `session/prompt` intercepte. Envoyée aussi à la fin d'un rejeu de `session/load`. |95+| `available_commands_update` | Une fois par session, juste après l'écriture de la réponse à `session/new` (l'éditeur doit connaître l'id de session d'abord, sinon il ignore la notification). Liste ce que cette façade **traite** : les commandes slash que `session/prompt` intercepte, puis une commande par **skill**. Envoyée aussi à la fin d'un rejeu `session/load`. |
71 | `agent_message_chunk` (texte) | Chaque fragment diffusé de la réponse du modèle, espaces préservés ; aussi la ligne de confirmation d'une commande slash. |96 | `agent_message_chunk` (texte) | Chaque fragment diffusé de la réponse du modèle, espaces préservés ; aussi la ligne de confirmation d'une commande slash. |
72 | `tool_call` statut `pending` | Avant l'exécution d'un outil ; porte `title`, `kind`, `rawInput`, et `locations` avec le chemin absolu quand un fichier est concerné. |97 | `tool_call` statut `pending` | Avant l'exécution d'un outil ; porte `title`, `kind`, `rawInput`, et `locations` avec le chemin absolu quand un fichier est concerné. |
73 | `tool_call_update` statut `in_progress` | Permission accordée, outil en cours d'exécution. |98 | `tool_call_update` statut `in_progress` | Permission accordée, outil en cours d'exécution. |
@@ -79,8 +104,9 @@ Aucun outil ne s'exécute pendant un rejeu. Journalisé comme `[acp] session <id
79 |--------|---------------|---------|104 |--------|---------------|---------|
80 | `new` | `Clear the history and start a new session` | aucun |105 | `new` | `Clear the history and start a new session` | aucun |
81 | `compact` | `Compress the history now, keeping the last turns` | aucun |106 | `compact` | `Compress the history now, keeping the last turns` | aucun |
107+| un par skill | la `description` du skill | `{ "hint": "parameters, if this skill needs any" }` |
82 108
83-Les clients affichent le nom précédé d'une barre oblique et le renvoient comme texte d'un `session/prompt`.109+Les clients affichent le nom précédé d'une barre oblique et le renvoient comme texte d'un `session/prompt`. Les deux commandes ci-dessus y sont interceptées ; la commande d'un skill est réécrite en la notation `$skill` et le tour se déroule — voir [Les skills comme commandes](#les-skills-comme-commandes).
84 110
85 ## Requêtes `session/request_permission` envoyées111 ## Requêtes `session/request_permission` envoyées
86 112
modified docs/fr/reference/skill-format.md +1 -1
@@ -58,7 +58,7 @@ Les `keywords` de cet exemple arrivent dans le catalogue sous la forme `what-tim
5858
5959 ## Demander un skill par son nom
6060
61-`$nom` dans une question désigne un skill à exécuter, avec le reste de la ligne comme paramètres — `use $greetings Bob`. Le sélecteur `$` de la fenêtre l'écrit pour vous ; tapé à la main il marche aussi en mode ligne et en ACP. Il ajoute une ligne à ce que lit le modèle, et c'est toujours le modèle qui fait l'appel `read_skill`. Voir [Commandes slash — les trois sélecteurs](slash-commands.md#les-trois-sélecteurs).
61+`$nom` dans une question désigne un skill à exécuter, avec le reste de la ligne comme paramètres — `use $greetings Bob`. Le sélecteur `$` de la fenêtre l'écrit pour vous ; tapé à la main il marche aussi en mode ligne et en ACP. Dans un éditeur, le skill apparaît en plus dans le menu `/` du client, avec une aide de saisie pour ses paramètres — voir [ACP — les skills comme commandes](acp.md#les-skills-comme-commandes). Il ajoute une ligne à ce que lit le modèle, et c'est toujours le modèle qui fait l'appel `read_skill`. Voir [Commandes slash — les trois sélecteurs](slash-commands.md#les-trois-sélecteurs).
6262
6363 ## Écrire un skill que le modèle va EXÉCUTER
6464
@@ -58,7 +58,7 @@ Les `keywords` de cet exemple arrivent dans le catalogue sous la forme `what-tim
58 58
59 ## Demander un skill par son nom59 ## Demander un skill par son nom
60 60
61-`$nom` dans une question désigne un skill à exécuter, avec le reste de la ligne comme paramètres — `use $greetings Bob`. Le sélecteur `$` de la fenêtre l'écrit pour vous ; tapé à la main il marche aussi en mode ligne et en ACP. Il ajoute une ligne à ce que lit le modèle, et c'est toujours le modèle qui fait l'appel `read_skill`. Voir [Commandes slash — les trois sélecteurs](slash-commands.md#les-trois-sélecteurs).61+`$nom` dans une question désigne un skill à exécuter, avec le reste de la ligne comme paramètres — `use $greetings Bob`. Le sélecteur `$` de la fenêtre l'écrit pour vous ; tapé à la main il marche aussi en mode ligne et en ACP. Dans un éditeur, le skill apparaît en plus dans le menu `/` du client, avec une aide de saisie pour ses paramètres — voir [ACP — les skills comme commandes](acp.md#les-skills-comme-commandes). Il ajoute une ligne à ce que lit le modèle, et c'est toujours le modèle qui fait l'appel `read_skill`. Voir [Commandes slash — les trois sélecteurs](slash-commands.md#les-trois-sélecteurs).
62 62
63 ## Écrire un skill que le modèle va EXÉCUTER63 ## Écrire un skill que le modèle va EXÉCUTER
64 64
modified docs/fr/reference/slash-commands.md +2 -0
@@ -132,4 +132,6 @@ Deux commandes existent en mode ACP : `/new` et `/compact`. Elles sont annoncée
132132 | `/new` | Comme dans le terminal : historique ramené au prompt système, compte de tokens oublié ; l'identifiant de session, le `cwd` et les autorisations « toujours autoriser » sont conservés. |
133133 | `/compact` | Comme dans le terminal, toujours forcée : `🗜️ compressed …`, `🗜️ nothing to compact: …` ou `[compact: failed, history kept: …]`. Attend d'abord la fin d'un tour en cours. |
134134
135+Chaque **skill** y est proposé comme une commande à part entière : ACP n'a pas de sélecteur pour les skills, donc `/greetings Bob` dans le menu de l'éditeur est l'équivalent de `$greetings Bob` dans la fenêtre, et il est réécrit en exactement ça — voir la [référence ACP](acp.md#les-skills-comme-commandes).
136+
135137 `/quit` et `/abort` n'existent pas en mode ACP : l'éditeur possède le processus et le termine en fermant stdin, et il annule un tour avec sa propre touche d'arrêt via `session/cancel`. Voir la [référence ACP](acp.md).
@@ -132,4 +132,6 @@ Deux commandes existent en mode ACP : `/new` et `/compact`. Elles sont annoncée
132 | `/new` | Comme dans le terminal : historique ramené au prompt système, compte de tokens oublié ; l'identifiant de session, le `cwd` et les autorisations « toujours autoriser » sont conservés. |132 | `/new` | Comme dans le terminal : historique ramené au prompt système, compte de tokens oublié ; l'identifiant de session, le `cwd` et les autorisations « toujours autoriser » sont conservés. |
133 | `/compact` | Comme dans le terminal, toujours forcée : `🗜️ compressed …`, `🗜️ nothing to compact: …` ou `[compact: failed, history kept: …]`. Attend d'abord la fin d'un tour en cours. |133 | `/compact` | Comme dans le terminal, toujours forcée : `🗜️ compressed …`, `🗜️ nothing to compact: …` ou `[compact: failed, history kept: …]`. Attend d'abord la fin d'un tour en cours. |
134 134
135+Chaque **skill** y est proposé comme une commande à part entière : ACP n'a pas de sélecteur pour les skills, donc `/greetings Bob` dans le menu de l'éditeur est l'équivalent de `$greetings Bob` dans la fenêtre, et il est réécrit en exactement ça — voir la [référence ACP](acp.md#les-skills-comme-commandes).
136+
135 `/quit` et `/abort` n'existent pas en mode ACP : l'éditeur possède le processus et le termine en fermant stdin, et il annule un tour avec sa propre touche d'arrêt via `session/cancel`. Voir la [référence ACP](acp.md).137 `/quit` et `/abort` n'existent pas en mode ACP : l'éditeur possède le processus et le termine en fermant stdin, et il annule un tour avec sa propre touche d'arrêt via `session/cancel`. Voir la [référence ACP](acp.md).
modified internal/acp/acp.go +97 -6
@@ -242,20 +242,78 @@ func (f *front) NewSession(ctx context.Context, p sdk.NewSessionRequest) (sdk.Ne
242242 return sdk.NewSessionResponse{SessionId: sid}, nil
243243 }
244244
245-// availableCommands lists the slash commands this front end handles itself,
246-// in the shape ACP advertises them: bare names, the client adds the slash.
247-// The list is the shared table's Editor slice (internal/command): only what
248-// slashCommand intercepts — announcing /quit or /abort, which the REPL has
249-// and this front end does not, would promise the editor something the next
250-// prompt would hand to the model.
245+// availableCommands lists what this front end HANDLES, in the shape ACP
246+// advertises it: bare names, the client adds the slash. The editor shows them
247+// in its own `/` menu.
248+//
249+// Two kinds, in this order.
250+//
251+// First the shared table's Editor slice (internal/command): what slashCommand
252+// intercepts. Announcing /quit or /abort, which the REPL has and this front
253+// end does not, would promise the editor something the next prompt would hand
254+// to the model.
255+//
256+// Then one per SKILL. ACP has no notion of a skill — a prompt carries text,
257+// images, audio, a resource or a resource_link, and `resource_link` is the
258+// `@` file mention — but `AvailableCommand` is the right slot, and the
259+// protocol's own examples say so: its doc comment offers 'create_plan' and
260+// 'research_codebase', which are procedures, not session commands. The
261+// `Input.Unstructured` hint carries the rest: "all text that was typed after
262+// the command name is provided as input", which is exactly the parameters a
263+// skill takes.
264+//
265+// A skill is advertised but NOT intercepted: `/greetings Bob` is rewritten as
266+// the `$greetings Bob` notation and the turn runs normally, so the model still
267+// makes the read_skill call and the loop detector, the permission gate and the
268+// recap all still see it. That is why the invariant here is "what this front
269+// end handles" rather than "what it intercepts" — rewriting is handling.
251270 func availableCommands() []sdk.AvailableCommand {
252271 var out []sdk.AvailableCommand
253272 for _, c := range command.For(command.Editor) {
254273 out = append(out, sdk.AvailableCommand{Name: c.Name, Description: c.Description})
255274 }
275+ for _, s := range skills.Declared() {
276+ if commandNamed(s.Name) {
277+ // A skill called "compact" or "new" would shadow a real command in
278+ // the editor's menu, and the next prompt would be ambiguous. The
279+ // command wins; the skill is simply not offered, and the banner
280+ // says so (main.go) rather than leaving it silently unreachable.
281+ continue
282+ }
283+ out = append(out, sdk.AvailableCommand{
284+ Name: s.Name,
285+ Description: s.Description,
286+ Input: &sdk.AvailableCommandInput{
287+ Unstructured: &sdk.UnstructuredCommandInput{Hint: skillHint(s)},
288+ },
289+ })
290+ }
256291 return out
257292 }
258293
294+// commandNamed reports whether a real slash command already owns this name —
295+// checked against the WHOLE table, not just the Editor slice: a client may
296+// type /quit, which this front end does not intercept but must not turn into
297+// a skill either.
298+func commandNamed(name string) bool {
299+ for _, c := range command.All() {
300+ if c.Name == name {
301+ return true
302+ }
303+ }
304+ return false
305+}
306+
307+// skillHint is what the editor shows where the parameters go. A skill rarely
308+// says what it wants in machine-readable form, so this is deliberately vague
309+// and short rather than wrong.
310+func skillHint(s skills.Skill) string {
311+ if s.Description != "" {
312+ return "parameters, if this skill needs any"
313+ }
314+ return "parameters"
315+}
316+
259317 // --- session/prompt -----------------------------------------------------------
260318
261319 func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResponse, error) {
@@ -278,6 +336,12 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
278336 return resp, nil
279337 }
280338
339+ // A command the editor offered for a SKILL: "/greetings Bob" becomes the
340+ // `$greetings Bob` notation and the turn runs normally from here. Done
341+ // after slashCommand, so a real command always wins whatever a skill is
342+ // called.
343+ input = skillCommand(input)
344+
281345 // One cancellable context per turn: session/cancel fires s.cancel, the
282346 // generation and any pending permission request fall with it, and the spec's
283347 // contract — still answer the prompt, with stopReason "cancelled" — is met
@@ -364,6 +428,33 @@ func (f *front) slashCommand(ctx context.Context, sid sdk.SessionId, s *session,
364428 return sdk.PromptResponse{}, false
365429 }
366430
431+// skillCommand turns a slash command that names a skill into the `$skill`
432+// notation, so one code path — skills.ExpandRefs, shared by all three front
433+// ends — decides what a named skill means.
434+//
435+// Only the first word, only at the start, and only when it names a skill the
436+// catalogue has and no real command owns. Anything else is returned untouched
437+// and goes to the model as typed, which is what a client's arbitrary slash
438+// text did before.
439+func skillCommand(input string) string {
440+ rest, ok := strings.CutPrefix(strings.TrimSpace(input), "/")
441+ if !ok {
442+ return input
443+ }
444+ name, args, _ := strings.Cut(rest, " ")
445+ if name == "" || commandNamed(name) {
446+ return input
447+ }
448+ s, found := skills.Find(skills.Declared(), name)
449+ if !found {
450+ return input
451+ }
452+ if args = strings.TrimSpace(args); args != "" {
453+ return "$" + s.Name + " " + args
454+ }
455+ return "$" + s.Name
456+}
457+
367458 // startNewSession is the ACP side of /new: the session keeps its id, its cwd
368459 // and the "allow always" grants — the dialog promised them "for this session",
369460 // and the session, as the editor sees it, is the same one — but its history
@@ -242,20 +242,78 @@ func (f *front) NewSession(ctx context.Context, p sdk.NewSessionRequest) (sdk.Ne
242 return sdk.NewSessionResponse{SessionId: sid}, nil242 return sdk.NewSessionResponse{SessionId: sid}, nil
243 }243 }
244 244
245-// availableCommands lists the slash commands this front end handles itself,245+// availableCommands lists what this front end HANDLES, in the shape ACP
246-// in the shape ACP advertises them: bare names, the client adds the slash.246+// advertises it: bare names, the client adds the slash. The editor shows them
247-// The list is the shared table's Editor slice (internal/command): only what247+// in its own `/` menu.
248-// slashCommand intercepts — announcing /quit or /abort, which the REPL has248+//
249-// and this front end does not, would promise the editor something the next249+// Two kinds, in this order.
250-// prompt would hand to the model.250+//
251+// First the shared table's Editor slice (internal/command): what slashCommand
252+// intercepts. Announcing /quit or /abort, which the REPL has and this front
253+// end does not, would promise the editor something the next prompt would hand
254+// to the model.
255+//
256+// Then one per SKILL. ACP has no notion of a skill — a prompt carries text,
257+// images, audio, a resource or a resource_link, and `resource_link` is the
258+// `@` file mention — but `AvailableCommand` is the right slot, and the
259+// protocol's own examples say so: its doc comment offers 'create_plan' and
260+// 'research_codebase', which are procedures, not session commands. The
261+// `Input.Unstructured` hint carries the rest: "all text that was typed after
262+// the command name is provided as input", which is exactly the parameters a
263+// skill takes.
264+//
265+// A skill is advertised but NOT intercepted: `/greetings Bob` is rewritten as
266+// the `$greetings Bob` notation and the turn runs normally, so the model still
267+// makes the read_skill call and the loop detector, the permission gate and the
268+// recap all still see it. That is why the invariant here is "what this front
269+// end handles" rather than "what it intercepts" — rewriting is handling.
251 func availableCommands() []sdk.AvailableCommand {270 func availableCommands() []sdk.AvailableCommand {
252 var out []sdk.AvailableCommand271 var out []sdk.AvailableCommand
253 for _, c := range command.For(command.Editor) {272 for _, c := range command.For(command.Editor) {
254 out = append(out, sdk.AvailableCommand{Name: c.Name, Description: c.Description})273 out = append(out, sdk.AvailableCommand{Name: c.Name, Description: c.Description})
255 }274 }
275+ for _, s := range skills.Declared() {
276+ if commandNamed(s.Name) {
277+ // A skill called "compact" or "new" would shadow a real command in
278+ // the editor's menu, and the next prompt would be ambiguous. The
279+ // command wins; the skill is simply not offered, and the banner
280+ // says so (main.go) rather than leaving it silently unreachable.
281+ continue
282+ }
283+ out = append(out, sdk.AvailableCommand{
284+ Name: s.Name,
285+ Description: s.Description,
286+ Input: &sdk.AvailableCommandInput{
287+ Unstructured: &sdk.UnstructuredCommandInput{Hint: skillHint(s)},
288+ },
289+ })
290+ }
256 return out291 return out
257 }292 }
258 293
294+// commandNamed reports whether a real slash command already owns this name —
295+// checked against the WHOLE table, not just the Editor slice: a client may
296+// type /quit, which this front end does not intercept but must not turn into
297+// a skill either.
298+func commandNamed(name string) bool {
299+ for _, c := range command.All() {
300+ if c.Name == name {
301+ return true
302+ }
303+ }
304+ return false
305+}
306+
307+// skillHint is what the editor shows where the parameters go. A skill rarely
308+// says what it wants in machine-readable form, so this is deliberately vague
309+// and short rather than wrong.
310+func skillHint(s skills.Skill) string {
311+ if s.Description != "" {
312+ return "parameters, if this skill needs any"
313+ }
314+ return "parameters"
315+}
316+
259 // --- session/prompt -----------------------------------------------------------317 // --- session/prompt -----------------------------------------------------------
260 318
261 func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResponse, error) {319 func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResponse, error) {
@@ -278,6 +336,12 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
278 return resp, nil336 return resp, nil
279 }337 }
280 338
339+ // A command the editor offered for a SKILL: "/greetings Bob" becomes the
340+ // `$greetings Bob` notation and the turn runs normally from here. Done
341+ // after slashCommand, so a real command always wins whatever a skill is
342+ // called.
343+ input = skillCommand(input)
344+
281 // One cancellable context per turn: session/cancel fires s.cancel, the345 // One cancellable context per turn: session/cancel fires s.cancel, the
282 // generation and any pending permission request fall with it, and the spec's346 // generation and any pending permission request fall with it, and the spec's
283 // contract — still answer the prompt, with stopReason "cancelled" — is met347 // contract — still answer the prompt, with stopReason "cancelled" — is met
@@ -364,6 +428,33 @@ func (f *front) slashCommand(ctx context.Context, sid sdk.SessionId, s *session,
364 return sdk.PromptResponse{}, false428 return sdk.PromptResponse{}, false
365 }429 }
366 430
431+// skillCommand turns a slash command that names a skill into the `$skill`
432+// notation, so one code path — skills.ExpandRefs, shared by all three front
433+// ends — decides what a named skill means.
434+//
435+// Only the first word, only at the start, and only when it names a skill the
436+// catalogue has and no real command owns. Anything else is returned untouched
437+// and goes to the model as typed, which is what a client's arbitrary slash
438+// text did before.
439+func skillCommand(input string) string {
440+ rest, ok := strings.CutPrefix(strings.TrimSpace(input), "/")
441+ if !ok {
442+ return input
443+ }
444+ name, args, _ := strings.Cut(rest, " ")
445+ if name == "" || commandNamed(name) {
446+ return input
447+ }
448+ s, found := skills.Find(skills.Declared(), name)
449+ if !found {
450+ return input
451+ }
452+ if args = strings.TrimSpace(args); args != "" {
453+ return "$" + s.Name + " " + args
454+ }
455+ return "$" + s.Name
456+}
457+
367 // startNewSession is the ACP side of /new: the session keeps its id, its cwd458 // startNewSession is the ACP side of /new: the session keeps its id, its cwd
368 // and the "allow always" grants — the dialog promised them "for this session",459 // and the "allow always" grants — the dialog promised them "for this session",
369 // and the session, as the editor sees it, is the same one — but its history460 // and the session, as the editor sees it, is the same one — but its history
modified internal/acp/acp_test.go +8 -1
@@ -55,11 +55,18 @@ func TestToolKindDefaultsToExecute(t *testing.T) {
5555 // as a question; the editor's own stop key cancels a turn) and no less (a
5656 // command that works but is not listed is one the user never finds). Names
5757 // are bare: ACP clients add the slash.
58+// With no skills declared, the advertisement is the shared table's Editor
59+// slice and nothing else: every name comes back to a handler in slashCommand.
60+//
61+// Skills are advertised too when there are any, and they are NOT intercepted
62+// — they are rewritten into the `$skill` notation, which is why the invariant
63+// is "what this front end handles". That half is pinned in skillcmd_test.go;
64+// this test keeps the command half honest.
5865 func TestAvailableCommandsMatchWhatPromptIntercepts(t *testing.T) {
5966 want := []string{"compact", history.NewCommandName} // the shared table's display order
6067 cmds := availableCommands()
6168 if len(cmds) != len(want) {
62- t.Fatalf("availableCommands: %d command(s), want %d: %+v", len(cmds), len(want), cmds)
69+ t.Fatalf("availableCommands: %d command(s) with no skills declared, want %d: %+v", len(cmds), len(want), cmds)
6370 }
6471 for i, c := range cmds {
6572 if c.Name != want[i] {
@@ -55,11 +55,18 @@ func TestToolKindDefaultsToExecute(t *testing.T) {
55 // as a question; the editor's own stop key cancels a turn) and no less (a55 // as a question; the editor's own stop key cancels a turn) and no less (a
56 // command that works but is not listed is one the user never finds). Names56 // command that works but is not listed is one the user never finds). Names
57 // are bare: ACP clients add the slash.57 // are bare: ACP clients add the slash.
58+// With no skills declared, the advertisement is the shared table's Editor
59+// slice and nothing else: every name comes back to a handler in slashCommand.
60+//
61+// Skills are advertised too when there are any, and they are NOT intercepted
62+// — they are rewritten into the `$skill` notation, which is why the invariant
63+// is "what this front end handles". That half is pinned in skillcmd_test.go;
64+// this test keeps the command half honest.
58 func TestAvailableCommandsMatchWhatPromptIntercepts(t *testing.T) {65 func TestAvailableCommandsMatchWhatPromptIntercepts(t *testing.T) {
59 want := []string{"compact", history.NewCommandName} // the shared table's display order66 want := []string{"compact", history.NewCommandName} // the shared table's display order
60 cmds := availableCommands()67 cmds := availableCommands()
61 if len(cmds) != len(want) {68 if len(cmds) != len(want) {
62- t.Fatalf("availableCommands: %d command(s), want %d: %+v", len(cmds), len(want), cmds)69+ t.Fatalf("availableCommands: %d command(s) with no skills declared, want %d: %+v", len(cmds), len(want), cmds)
63 }70 }
64 for i, c := range cmds {71 for i, c := range cmds {
65 if c.Name != want[i] {72 if c.Name != want[i] {
added internal/acp/skillcmd_test.go +147 -0
new file mode 100644
@@ -0,0 +1,147 @@
1+package acp
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "mm/internal/command"
8+ "mm/internal/skills"
9+)
10+
11+func declare(t *testing.T, list ...skills.Skill) {
12+ t.Helper()
13+ saved := skills.Declared()
14+ t.Cleanup(func() { skills.Declare(saved) })
15+ skills.Declare(list)
16+}
17+
18+// ACP has no notion of a skill, so each one is offered as an AvailableCommand
19+// — the slot the protocol's own examples ('create_plan', 'research_codebase')
20+// were written for — with the input hint that carries its parameters.
21+func TestSkillsAreAdvertisedAsCommands(t *testing.T) {
22+ declare(t,
23+ skills.Skill{Name: "greetings", Description: "greet somebody"},
24+ skills.Skill{Name: "bg-jobs", Description: "background jobs"},
25+ )
26+
27+ cmds := availableCommands()
28+ byName := map[string]int{}
29+ for i, c := range cmds {
30+ byName[c.Name] = i
31+ }
32+
33+ // The real commands come first and keep their shape: no input spec.
34+ for _, c := range command.For(command.Editor) {
35+ i, ok := byName[c.Name]
36+ if !ok {
37+ t.Fatalf("%q is no longer advertised", c.Name)
38+ }
39+ if cmds[i].Input != nil {
40+ t.Errorf("%q takes no input but advertises one", c.Name)
41+ }
42+ delete(byName, c.Name)
43+ }
44+
45+ // Then the skills, each with a description and an input hint.
46+ for _, name := range []string{"greetings", "bg-jobs"} {
47+ i, ok := byName[name]
48+ if !ok {
49+ t.Fatalf("skill %q is not advertised: %+v", name, cmds)
50+ }
51+ c := cmds[i]
52+ if c.Description == "" {
53+ t.Errorf("%q is advertised with no description", name)
54+ }
55+ if c.Input == nil || c.Input.Unstructured == nil || c.Input.Unstructured.Hint == "" {
56+ t.Errorf("%q has no input hint, so the editor cannot show where parameters go", name)
57+ }
58+ }
59+}
60+
61+// A skill whose name a real command owns is NOT advertised: it would sit in
62+// the editor's menu next to the command with no way to tell them apart, and
63+// the next prompt would be ambiguous.
64+func TestASkillNamedLikeACommandIsNotAdvertised(t *testing.T) {
65+ declare(t,
66+ skills.Skill{Name: "compact", Description: "a skill that shadows a command"},
67+ skills.Skill{Name: "quit", Description: "a skill that shadows a command the editor never gets"},
68+ skills.Skill{Name: "greetings", Description: "greet somebody"},
69+ )
70+
71+ var skillish []string
72+ for _, c := range availableCommands() {
73+ if c.Input != nil {
74+ skillish = append(skillish, c.Name)
75+ }
76+ }
77+ if len(skillish) != 1 || skillish[0] != "greetings" {
78+ t.Errorf("advertised skills = %v, want only greetings", skillish)
79+ }
80+ // And the real command is still there, exactly once.
81+ n := 0
82+ for _, c := range availableCommands() {
83+ if c.Name == "compact" {
84+ n++
85+ }
86+ }
87+ if n != 1 {
88+ t.Errorf("%q advertised %d times", "compact", n)
89+ }
90+}
91+
92+// The rewrite: "/greetings Bob" becomes the `$greetings Bob` notation, so one
93+// code path decides what a named skill means for all three front ends.
94+func TestSkillCommandRewrite(t *testing.T) {
95+ declare(t,
96+ skills.Skill{Name: "greetings", Description: "greet somebody"},
97+ skills.Skill{Name: "bg-jobs", Description: "background jobs"},
98+ )
99+
100+ cases := []struct{ name, in, want string }{
101+ {"with parameters", "/greetings Bob Morane", "$greetings Bob Morane"},
102+ {"without parameters", "/bg-jobs", "$bg-jobs"},
103+ {"leading space", " /greetings Bob", "$greetings Bob"},
104+ {"a real command is left for slashCommand", "/compact", "/compact"},
105+ {"a command this front end does not intercept stays as typed", "/quit", "/quit"},
106+ {"an unknown name stays as typed", "/nosuchskill x", "/nosuchskill x"},
107+ {"not a slash command at all", "use $greetings Bob", "use $greetings Bob"},
108+ {"a bare slash", "/", "/"},
109+ }
110+ for _, tc := range cases {
111+ t.Run(tc.name, func(t *testing.T) {
112+ if got := skillCommand(tc.in); got != tc.want {
113+ t.Errorf("skillCommand(%q) = %q, want %q", tc.in, got, tc.want)
114+ }
115+ })
116+ }
117+}
118+
119+// The rewrite feeds skills.ExpandRefs, which is what actually tells the model
120+// to load the skill — the round trip that makes the editor's menu mean the
121+// same thing as the window's `$` picker.
122+func TestTheRewriteReachesTheModelAsASkillLine(t *testing.T) {
123+ list := []skills.Skill{{Name: "greetings", Description: "greet somebody"}}
124+ declare(t, list...)
125+
126+ expanded, refs := skills.ExpandRefs(skillCommand("/greetings Bob Morane"), list)
127+ if len(refs) != 1 || refs[0].Name != "greetings" || refs[0].Args != "Bob Morane" {
128+ t.Fatalf("refs = %+v", refs)
129+ }
130+ if !strings.Contains(expanded, "[skill: greetings") || !strings.Contains(expanded, "parameters: Bob Morane") {
131+ t.Errorf("expansion = %q", expanded)
132+ }
133+}
134+
135+// With no skills declared — an agent that ships none — nothing changes: the
136+// advertisement is the two commands it always was, and no rewrite happens.
137+func TestNoSkillsChangesNothing(t *testing.T) {
138+ declare(t)
139+
140+ cmds := availableCommands()
141+ if len(cmds) != len(command.For(command.Editor)) {
142+ t.Errorf("%d commands advertised with no skills: %+v", len(cmds), cmds)
143+ }
144+ if got := skillCommand("/greetings Bob"); got != "/greetings Bob" {
145+ t.Errorf("skillCommand rewrote %q with no catalogue", got)
146+ }
147+}
new file mode 100644
@@ -0,0 +1,147 @@
1+package acp
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "mm/internal/command"
8+ "mm/internal/skills"
9+)
10+
11+func declare(t *testing.T, list ...skills.Skill) {
12+ t.Helper()
13+ saved := skills.Declared()
14+ t.Cleanup(func() { skills.Declare(saved) })
15+ skills.Declare(list)
16+}
17+
18+// ACP has no notion of a skill, so each one is offered as an AvailableCommand
19+// — the slot the protocol's own examples ('create_plan', 'research_codebase')
20+// were written for — with the input hint that carries its parameters.
21+func TestSkillsAreAdvertisedAsCommands(t *testing.T) {
22+ declare(t,
23+ skills.Skill{Name: "greetings", Description: "greet somebody"},
24+ skills.Skill{Name: "bg-jobs", Description: "background jobs"},
25+ )
26+
27+ cmds := availableCommands()
28+ byName := map[string]int{}
29+ for i, c := range cmds {
30+ byName[c.Name] = i
31+ }
32+
33+ // The real commands come first and keep their shape: no input spec.
34+ for _, c := range command.For(command.Editor) {
35+ i, ok := byName[c.Name]
36+ if !ok {
37+ t.Fatalf("%q is no longer advertised", c.Name)
38+ }
39+ if cmds[i].Input != nil {
40+ t.Errorf("%q takes no input but advertises one", c.Name)
41+ }
42+ delete(byName, c.Name)
43+ }
44+
45+ // Then the skills, each with a description and an input hint.
46+ for _, name := range []string{"greetings", "bg-jobs"} {
47+ i, ok := byName[name]
48+ if !ok {
49+ t.Fatalf("skill %q is not advertised: %+v", name, cmds)
50+ }
51+ c := cmds[i]
52+ if c.Description == "" {
53+ t.Errorf("%q is advertised with no description", name)
54+ }
55+ if c.Input == nil || c.Input.Unstructured == nil || c.Input.Unstructured.Hint == "" {
56+ t.Errorf("%q has no input hint, so the editor cannot show where parameters go", name)
57+ }
58+ }
59+}
60+
61+// A skill whose name a real command owns is NOT advertised: it would sit in
62+// the editor's menu next to the command with no way to tell them apart, and
63+// the next prompt would be ambiguous.
64+func TestASkillNamedLikeACommandIsNotAdvertised(t *testing.T) {
65+ declare(t,
66+ skills.Skill{Name: "compact", Description: "a skill that shadows a command"},
67+ skills.Skill{Name: "quit", Description: "a skill that shadows a command the editor never gets"},
68+ skills.Skill{Name: "greetings", Description: "greet somebody"},
69+ )
70+
71+ var skillish []string
72+ for _, c := range availableCommands() {
73+ if c.Input != nil {
74+ skillish = append(skillish, c.Name)
75+ }
76+ }
77+ if len(skillish) != 1 || skillish[0] != "greetings" {
78+ t.Errorf("advertised skills = %v, want only greetings", skillish)
79+ }
80+ // And the real command is still there, exactly once.
81+ n := 0
82+ for _, c := range availableCommands() {
83+ if c.Name == "compact" {
84+ n++
85+ }
86+ }
87+ if n != 1 {
88+ t.Errorf("%q advertised %d times", "compact", n)
89+ }
90+}
91+
92+// The rewrite: "/greetings Bob" becomes the `$greetings Bob` notation, so one
93+// code path decides what a named skill means for all three front ends.
94+func TestSkillCommandRewrite(t *testing.T) {
95+ declare(t,
96+ skills.Skill{Name: "greetings", Description: "greet somebody"},
97+ skills.Skill{Name: "bg-jobs", Description: "background jobs"},
98+ )
99+
100+ cases := []struct{ name, in, want string }{
101+ {"with parameters", "/greetings Bob Morane", "$greetings Bob Morane"},
102+ {"without parameters", "/bg-jobs", "$bg-jobs"},
103+ {"leading space", " /greetings Bob", "$greetings Bob"},
104+ {"a real command is left for slashCommand", "/compact", "/compact"},
105+ {"a command this front end does not intercept stays as typed", "/quit", "/quit"},
106+ {"an unknown name stays as typed", "/nosuchskill x", "/nosuchskill x"},
107+ {"not a slash command at all", "use $greetings Bob", "use $greetings Bob"},
108+ {"a bare slash", "/", "/"},
109+ }
110+ for _, tc := range cases {
111+ t.Run(tc.name, func(t *testing.T) {
112+ if got := skillCommand(tc.in); got != tc.want {
113+ t.Errorf("skillCommand(%q) = %q, want %q", tc.in, got, tc.want)
114+ }
115+ })
116+ }
117+}
118+
119+// The rewrite feeds skills.ExpandRefs, which is what actually tells the model
120+// to load the skill — the round trip that makes the editor's menu mean the
121+// same thing as the window's `$` picker.
122+func TestTheRewriteReachesTheModelAsASkillLine(t *testing.T) {
123+ list := []skills.Skill{{Name: "greetings", Description: "greet somebody"}}
124+ declare(t, list...)
125+
126+ expanded, refs := skills.ExpandRefs(skillCommand("/greetings Bob Morane"), list)
127+ if len(refs) != 1 || refs[0].Name != "greetings" || refs[0].Args != "Bob Morane" {
128+ t.Fatalf("refs = %+v", refs)
129+ }
130+ if !strings.Contains(expanded, "[skill: greetings") || !strings.Contains(expanded, "parameters: Bob Morane") {
131+ t.Errorf("expansion = %q", expanded)
132+ }
133+}
134+
135+// With no skills declared — an agent that ships none — nothing changes: the
136+// advertisement is the two commands it always was, and no rewrite happens.
137+func TestNoSkillsChangesNothing(t *testing.T) {
138+ declare(t)
139+
140+ cmds := availableCommands()
141+ if len(cmds) != len(command.For(command.Editor)) {
142+ t.Errorf("%d commands advertised with no skills: %+v", len(cmds), cmds)
143+ }
144+ if got := skillCommand("/greetings Bob"); got != "/greetings Bob" {
145+ t.Errorf("skillCommand rewrote %q with no catalogue", got)
146+ }
147+}
modified internal/skills/report.go +21 -0
@@ -43,6 +43,27 @@ func Truncated(list []Skill) []string {
4343 return out
4444 }
4545
46+// Shadowed lists the skills whose name a slash command already owns.
47+//
48+// It matters in an editor: over ACP each skill is offered in the client's
49+// own `/` menu (see internal/acp), and a skill called "compact" or "new"
50+// would sit in that menu next to the real command with no way to tell them
51+// apart. The command wins and the skill is not offered, so the only way the
52+// author learns of it is a warning at start-up.
53+func Shadowed(list []Skill, taken []string) []string {
54+ owned := map[string]bool{}
55+ for _, name := range taken {
56+ owned[name] = true
57+ }
58+ var out []string
59+ for _, s := range list {
60+ if owned[s.Name] {
61+ out = append(out, s.Name)
62+ }
63+ }
64+ return out
65+}
66+
4667 // Undescribed lists the skills whose front matter gave no description. They
4768 // still appear in the catalogue, as a bare name — the worst entry there is,
4869 // since the model has nothing to match a request against.
@@ -43,6 +43,27 @@ func Truncated(list []Skill) []string {
43 return out43 return out
44 }44 }
45 45
46+// Shadowed lists the skills whose name a slash command already owns.
47+//
48+// It matters in an editor: over ACP each skill is offered in the client's
49+// own `/` menu (see internal/acp), and a skill called "compact" or "new"
50+// would sit in that menu next to the real command with no way to tell them
51+// apart. The command wins and the skill is not offered, so the only way the
52+// author learns of it is a warning at start-up.
53+func Shadowed(list []Skill, taken []string) []string {
54+ owned := map[string]bool{}
55+ for _, name := range taken {
56+ owned[name] = true
57+ }
58+ var out []string
59+ for _, s := range list {
60+ if owned[s.Name] {
61+ out = append(out, s.Name)
62+ }
63+ }
64+ return out
65+}
66+
46 // Undescribed lists the skills whose front matter gave no description. They67 // Undescribed lists the skills whose front matter gave no description. They
47 // still appear in the catalogue, as a bare name — the worst entry there is,68 // still appear in the catalogue, as a bare name — the worst entry there is,
48 // since the model has nothing to match a request against.69 // since the model has nothing to match a request against.
modified main.go +17 -0
@@ -37,6 +37,7 @@ import (
3737
3838 "mm/internal/acp"
3939 "mm/internal/agent"
40+ "mm/internal/command"
4041 "mm/internal/config"
4142 "mm/internal/detector"
4243 "mm/internal/engine"
@@ -300,6 +301,13 @@ func printBanner(ctx context.Context, e *engine.Engine, path string, set toolSet
300301 fmt.Sprintf("%d skill(s) with no description (%s) — the model sees only the name and will pick blindly",
301302 len(bare), strings.Join(bare, ", ")))
302303 }
304+ // A skill whose name a slash command owns is unreachable from an editor's
305+ // command menu, and nothing else would say so.
306+ if shadowed := skills.Shadowed(set.skills, commandNames()); len(shadowed) > 0 {
307+ info.Warnings = append(info.Warnings,
308+ fmt.Sprintf("skill(s) named like a slash command (%s) — reachable as $%s in a question, but not from an editor's / menu",
309+ strings.Join(shadowed, ", "), shadowed[0]))
310+ }
303311 // Only the first maxKeywords of a list reach the catalogue, so the order
304312 // of a keyword line decides what the model gets to match against. An
305313 // author cannot see that from their own file.
@@ -334,6 +342,15 @@ func printBanner(ctx context.Context, e *engine.Engine, path string, set toolSet
334342 dim, version.Short(), path, e.Backend.Provider, config.Cfg.Model, ctxCol, toolCol, skillCount, off)
335343 }
336344
345+// commandNames is the slash commands' names, for the shadowing check.
346+func commandNames() []string {
347+ var out []string
348+ for _, c := range command.All() {
349+ out = append(out, c.Name)
350+ }
351+ return out
352+}
353+
337354 // windowMode decides whether the window front end runs, and where the banner
338355 // goes. The window owns the screen: the spinner must not draw on it, and
339356 // the banner is kept in the returned buffer to be shown INSIDE the window as
@@ -37,6 +37,7 @@ import (
37 37
38 "mm/internal/acp"38 "mm/internal/acp"
39 "mm/internal/agent"39 "mm/internal/agent"
40+ "mm/internal/command"
40 "mm/internal/config"41 "mm/internal/config"
41 "mm/internal/detector"42 "mm/internal/detector"
42 "mm/internal/engine"43 "mm/internal/engine"
@@ -300,6 +301,13 @@ func printBanner(ctx context.Context, e *engine.Engine, path string, set toolSet
300 fmt.Sprintf("%d skill(s) with no description (%s) — the model sees only the name and will pick blindly",301 fmt.Sprintf("%d skill(s) with no description (%s) — the model sees only the name and will pick blindly",
301 len(bare), strings.Join(bare, ", ")))302 len(bare), strings.Join(bare, ", ")))
302 }303 }
304+ // A skill whose name a slash command owns is unreachable from an editor's
305+ // command menu, and nothing else would say so.
306+ if shadowed := skills.Shadowed(set.skills, commandNames()); len(shadowed) > 0 {
307+ info.Warnings = append(info.Warnings,
308+ fmt.Sprintf("skill(s) named like a slash command (%s) — reachable as $%s in a question, but not from an editor's / menu",
309+ strings.Join(shadowed, ", "), shadowed[0]))
310+ }
303 // Only the first maxKeywords of a list reach the catalogue, so the order311 // Only the first maxKeywords of a list reach the catalogue, so the order
304 // of a keyword line decides what the model gets to match against. An312 // of a keyword line decides what the model gets to match against. An
305 // author cannot see that from their own file.313 // author cannot see that from their own file.
@@ -334,6 +342,15 @@ func printBanner(ctx context.Context, e *engine.Engine, path string, set toolSet
334 dim, version.Short(), path, e.Backend.Provider, config.Cfg.Model, ctxCol, toolCol, skillCount, off)342 dim, version.Short(), path, e.Backend.Provider, config.Cfg.Model, ctxCol, toolCol, skillCount, off)
335 }343 }
336 344
345+// commandNames is the slash commands' names, for the shadowing check.
346+func commandNames() []string {
347+ var out []string
348+ for _, c := range command.All() {
349+ out = append(out, c.Name)
350+ }
351+ return out
352+}
353+
337 // windowMode decides whether the window front end runs, and where the banner354 // windowMode decides whether the window front end runs, and where the banner
338 // goes. The window owns the screen: the spinner must not draw on it, and355 // goes. The window owns the screen: the spinner must not draw on it, and
339 // the banner is kept in the returned buffer to be shown INSIDE the window as356 // the banner is kept in the returned buffer to be shown INSIDE the window as