bots-garden/mini-mepublic Fork
⑂ e72e039
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

✨ mm acp-create: a /skillcreate command (ticket 213)

/skillcreate [--name] [--description] [--keywords] [--body] runs mm
skillcreate's wizard from the editor, in conversation mode or with forms
(the text is always asked as a message, line breaks kept). The session
drives either flow through a small wizard interface; /preview, /back,
/restart, /cancel act on the current one. During the agent's questions
the skill runs beside them and the agent's question comes back after.

skillcreate.LocateIn(dir) finds the skills directory of a project
without config.Load (global settings untouched); with no agent file
yet, the layout answered in the session's agent wizard is followed.
The SKILL.md is byte for byte what mm skillcreate writes. Docs EN+FR.

Ticket 213 closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
agent committed 2026-11-15T04:49:51+01:00 Browse files
e72e039 parent: f81c3b6
modified .tickets/issues/0213-mm-acp-create-a-skillcreate-command-mm-skillcrea.yaml +89 -9
@@ -1,12 +1,12 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 213
33 title: "mm acp-create: a /skillcreate command (mm skillcreate from the editor)"
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-11-14T07:25:21.000Z
9-updatedAt: 2026-11-14T07:25:21.000Z
9+updatedAt: 2026-11-15T03:49:13.000Z
1010 labels:
1111 - enhancement
1212 - kind::feature
@@ -91,50 +91,130 @@ body: |
9191 tasks:
9292 - id: 1
9393 title: Locate the skills directory for a given project directory, without loading the config into the global settings
94- state: open
94+ state: closed
9595 priority: high
9696 author:
9797 name: k33g
9898 email: ph.charriere@gmail.com
9999 createdAt: 2026-11-14T07:25:21.000Z
100+ updatedAt: 2026-11-15T03:02:04.000Z
101+ closedAt: 2026-11-15T03:02:04.000Z
100102 - id: 2
101103 title: Let the acp-create session drive either flow (agent or skill) - one interface over agentinit.Flow and skillcreate.Flow
102- state: open
104+ state: closed
103105 priority: high
104106 author:
105107 name: k33g
106108 email: ph.charriere@gmail.com
107109 createdAt: 2026-11-14T07:25:21.000Z
110+ updatedAt: 2026-11-15T03:02:04.000Z
111+ closedAt: 2026-11-15T03:02:04.000Z
108112 - id: 3
109113 title: /skillcreate command - flags as given answers, conversation mode and forms, multi-line text
110- state: open
114+ state: closed
111115 priority: medium
112116 author:
113117 name: k33g
114118 email: ph.charriere@gmail.com
115119 createdAt: 2026-11-14T07:25:21.000Z
120+ updatedAt: 2026-11-15T03:02:04.000Z
121+ closedAt: 2026-11-15T03:02:04.000Z
116122 - id: 4
117123 title: End message - written path, mm reads it back, the skillsDir note, reload the agent to see the skill
118- state: open
124+ state: closed
119125 priority: medium
120126 author:
121127 name: k33g
122128 email: ph.charriere@gmail.com
123129 createdAt: 2026-11-14T07:25:21.000Z
130+ updatedAt: 2026-11-15T03:02:04.000Z
131+ closedAt: 2026-11-15T03:02:04.000Z
124132 - id: 5
125133 title: Tests with the fake client - same SKILL.md as mm skillcreate with the same answers, both modes
126- state: open
134+ state: closed
127135 priority: medium
128136 author:
129137 name: k33g
130138 email: ph.charriere@gmail.com
131139 createdAt: 2026-11-14T07:25:21.000Z
140+ updatedAt: 2026-11-15T03:02:04.000Z
141+ closedAt: 2026-11-15T03:02:04.000Z
132142 - id: 6
133143 title: Docs EN+FR - how-to create-an-agent-from-an-editor, cli.md acp-create section
134- state: open
144+ state: closed
135145 priority: medium
136146 author:
137147 name: k33g
138148 email: ph.charriere@gmail.com
139149 createdAt: 2026-11-14T07:25:21.000Z
140-comments: []
150+ updatedAt: 2026-11-15T03:02:04.000Z
151+ closedAt: 2026-11-15T03:02:04.000Z
152+comments:
153+ - id: 1
154+ author:
155+ name: k33g
156+ email: ph.charriere@gmail.com
157+ createdAt: 2026-11-15T03:02:04.000Z
158+ body: |
159+ Implemented on `feature/acp-skillcreate` (uncommitted).
160+
161+ - `skillcreate.LocateIn(dir)`: the skills directory of the project in
162+ dir (agent.yaml, then .mm/agent.yaml; skillsDir read with yaml, not
163+ `config.Load`; mm's default `skills` kept as a const, a test checks
164+ it equals internal/config's). `skillcreate.WriteSkill` (shared with
165+ mm skillcreate's `finish`), `Flow.SetGiven` exported.
166+ - acp-create: a `wizard` interface (both flows satisfy it) and a
167+ `run` per wizard (asked, answered, deps, finish, preview); the
168+ session asks the skill run while one exists, else the agent's.
169+ `/skillcreate [--name] [--description] [--keywords] [--body]`
170+ (quoted values), `/cancel`; `/preview`, `/back`, `/write`,
171+ `/restart`, `/start` act on the current run.
172+ - Open decision taken as proposed: during the agent's questions, the
173+ skill runs beside them, the answers are kept, and the agent's current
174+ question comes back once the skill is written or cancelled.
175+ - The text (`body`) is asked as a message even in form mode (no known
176+ multi-line field in forms); the one-line answers go in forms. An
177+ optional answer says "`.` for none".
178+
179+ Tests: SKILL.md byte for byte what `mm skillcreate --yes --line`
180+ writes with the same answers, line breaks kept, read back by mm; back
181+ to the agent with its answers; `/cancel`; an existing skill asks
182+ before it is replaced (`no` → name again); forms (name, description,
183+ keywords in one form, text as a message); global `config.Cfg.SkillsDir`
184+ unchanged; `splitArgs`. End to end with the real binary over stdio in
185+ a `.mm` project: `.mm/skills/vulcan-salute/SKILL.md` written,
186+ `✅ mm reads it`.
187+
188+ Limit: before the agent file is written, `.mm/` does not exist yet, so
189+ a skill made half way through a Project-layout agent goes to
190+ `skills/` (with the note) — documented: create the agent first.
191+ Not tried in Zed.
192+ - id: 2
193+ author:
194+ name: k33g
195+ email: ph.charriere@gmail.com
196+ createdAt: 2026-11-15T03:32:10.000Z
197+ body: |
198+ User (2026-11-15): `/skillcreate` did not check whether the agent uses
199+ a `.mm` folder. Reproduced with the real binary: with the agent file
200+ written (skills on or off), the skill went to `.mm/skills`; with the
201+ agent **still being created** in the session and the `.mm` layout
202+ answered, it went to `skills/` (`.mm/` only exists once the agent file
203+ is written — the limit noted in comment 1).
204+
205+ Fixed: with no agent file on disk, `/skillcreate` reads the answers
206+ of the session's agent wizard (`agentinit.Flow.Choices`, no server
207+ call): layout answered → that agent's `skillsDir` (`.mm/skills` with
208+ the `.mm` layout, `skills` otherwise), with a note when "Use skills?"
209+ is not answered yet or answered no. Layout not answered → LocateIn as
210+ before. Checked again on the four cases (written with/without skills,
211+ in progress, in progress with `--skills yes`): all in `.mm/skills`.
212+ Test `TestPendingAgentPlace`; docs EN+FR updated (the limit is gone).
213+ - id: 3
214+ author:
215+ name: k33g
216+ email: ph.charriere@gmail.com
217+ createdAt: 2026-11-15T03:36:45.000Z
218+ body: |
219+ Tried by the user (2026-11-15): works.
220+
@@ -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: 2132 id: 213
3 title: "mm acp-create: a /skillcreate command (mm skillcreate from the editor)"3 title: "mm acp-create: a /skillcreate command (mm skillcreate from the editor)"
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-11-14T07:25:21.000Z8 createdAt: 2026-11-14T07:25:21.000Z
9-updatedAt: 2026-11-14T07:25:21.000Z9+updatedAt: 2026-11-15T03:49:13.000Z
10 labels:10 labels:
11 - enhancement11 - enhancement
12 - kind::feature12 - kind::feature
@@ -91,50 +91,130 @@ body: |
91 tasks:91 tasks:
92 - id: 192 - id: 1
93 title: Locate the skills directory for a given project directory, without loading the config into the global settings93 title: Locate the skills directory for a given project directory, without loading the config into the global settings
94- state: open94+ state: closed
95 priority: high95 priority: high
96 author:96 author:
97 name: k33g97 name: k33g
98 email: ph.charriere@gmail.com98 email: ph.charriere@gmail.com
99 createdAt: 2026-11-14T07:25:21.000Z99 createdAt: 2026-11-14T07:25:21.000Z
100+ updatedAt: 2026-11-15T03:02:04.000Z
101+ closedAt: 2026-11-15T03:02:04.000Z
100 - id: 2102 - id: 2
101 title: Let the acp-create session drive either flow (agent or skill) - one interface over agentinit.Flow and skillcreate.Flow103 title: Let the acp-create session drive either flow (agent or skill) - one interface over agentinit.Flow and skillcreate.Flow
102- state: open104+ state: closed
103 priority: high105 priority: high
104 author:106 author:
105 name: k33g107 name: k33g
106 email: ph.charriere@gmail.com108 email: ph.charriere@gmail.com
107 createdAt: 2026-11-14T07:25:21.000Z109 createdAt: 2026-11-14T07:25:21.000Z
110+ updatedAt: 2026-11-15T03:02:04.000Z
111+ closedAt: 2026-11-15T03:02:04.000Z
108 - id: 3112 - id: 3
109 title: /skillcreate command - flags as given answers, conversation mode and forms, multi-line text113 title: /skillcreate command - flags as given answers, conversation mode and forms, multi-line text
110- state: open114+ state: closed
111 priority: medium115 priority: medium
112 author:116 author:
113 name: k33g117 name: k33g
114 email: ph.charriere@gmail.com118 email: ph.charriere@gmail.com
115 createdAt: 2026-11-14T07:25:21.000Z119 createdAt: 2026-11-14T07:25:21.000Z
120+ updatedAt: 2026-11-15T03:02:04.000Z
121+ closedAt: 2026-11-15T03:02:04.000Z
116 - id: 4122 - id: 4
117 title: End message - written path, mm reads it back, the skillsDir note, reload the agent to see the skill123 title: End message - written path, mm reads it back, the skillsDir note, reload the agent to see the skill
118- state: open124+ state: closed
119 priority: medium125 priority: medium
120 author:126 author:
121 name: k33g127 name: k33g
122 email: ph.charriere@gmail.com128 email: ph.charriere@gmail.com
123 createdAt: 2026-11-14T07:25:21.000Z129 createdAt: 2026-11-14T07:25:21.000Z
130+ updatedAt: 2026-11-15T03:02:04.000Z
131+ closedAt: 2026-11-15T03:02:04.000Z
124 - id: 5132 - id: 5
125 title: Tests with the fake client - same SKILL.md as mm skillcreate with the same answers, both modes133 title: Tests with the fake client - same SKILL.md as mm skillcreate with the same answers, both modes
126- state: open134+ state: closed
127 priority: medium135 priority: medium
128 author:136 author:
129 name: k33g137 name: k33g
130 email: ph.charriere@gmail.com138 email: ph.charriere@gmail.com
131 createdAt: 2026-11-14T07:25:21.000Z139 createdAt: 2026-11-14T07:25:21.000Z
140+ updatedAt: 2026-11-15T03:02:04.000Z
141+ closedAt: 2026-11-15T03:02:04.000Z
132 - id: 6142 - id: 6
133 title: Docs EN+FR - how-to create-an-agent-from-an-editor, cli.md acp-create section143 title: Docs EN+FR - how-to create-an-agent-from-an-editor, cli.md acp-create section
134- state: open144+ state: closed
135 priority: medium145 priority: medium
136 author:146 author:
137 name: k33g147 name: k33g
138 email: ph.charriere@gmail.com148 email: ph.charriere@gmail.com
139 createdAt: 2026-11-14T07:25:21.000Z149 createdAt: 2026-11-14T07:25:21.000Z
140-comments: []150+ updatedAt: 2026-11-15T03:02:04.000Z
151+ closedAt: 2026-11-15T03:02:04.000Z
152+comments:
153+ - id: 1
154+ author:
155+ name: k33g
156+ email: ph.charriere@gmail.com
157+ createdAt: 2026-11-15T03:02:04.000Z
158+ body: |
159+ Implemented on `feature/acp-skillcreate` (uncommitted).
160+
161+ - `skillcreate.LocateIn(dir)`: the skills directory of the project in
162+ dir (agent.yaml, then .mm/agent.yaml; skillsDir read with yaml, not
163+ `config.Load`; mm's default `skills` kept as a const, a test checks
164+ it equals internal/config's). `skillcreate.WriteSkill` (shared with
165+ mm skillcreate's `finish`), `Flow.SetGiven` exported.
166+ - acp-create: a `wizard` interface (both flows satisfy it) and a
167+ `run` per wizard (asked, answered, deps, finish, preview); the
168+ session asks the skill run while one exists, else the agent's.
169+ `/skillcreate [--name] [--description] [--keywords] [--body]`
170+ (quoted values), `/cancel`; `/preview`, `/back`, `/write`,
171+ `/restart`, `/start` act on the current run.
172+ - Open decision taken as proposed: during the agent's questions, the
173+ skill runs beside them, the answers are kept, and the agent's current
174+ question comes back once the skill is written or cancelled.
175+ - The text (`body`) is asked as a message even in form mode (no known
176+ multi-line field in forms); the one-line answers go in forms. An
177+ optional answer says "`.` for none".
178+
179+ Tests: SKILL.md byte for byte what `mm skillcreate --yes --line`
180+ writes with the same answers, line breaks kept, read back by mm; back
181+ to the agent with its answers; `/cancel`; an existing skill asks
182+ before it is replaced (`no` → name again); forms (name, description,
183+ keywords in one form, text as a message); global `config.Cfg.SkillsDir`
184+ unchanged; `splitArgs`. End to end with the real binary over stdio in
185+ a `.mm` project: `.mm/skills/vulcan-salute/SKILL.md` written,
186+ `✅ mm reads it`.
187+
188+ Limit: before the agent file is written, `.mm/` does not exist yet, so
189+ a skill made half way through a Project-layout agent goes to
190+ `skills/` (with the note) — documented: create the agent first.
191+ Not tried in Zed.
192+ - id: 2
193+ author:
194+ name: k33g
195+ email: ph.charriere@gmail.com
196+ createdAt: 2026-11-15T03:32:10.000Z
197+ body: |
198+ User (2026-11-15): `/skillcreate` did not check whether the agent uses
199+ a `.mm` folder. Reproduced with the real binary: with the agent file
200+ written (skills on or off), the skill went to `.mm/skills`; with the
201+ agent **still being created** in the session and the `.mm` layout
202+ answered, it went to `skills/` (`.mm/` only exists once the agent file
203+ is written — the limit noted in comment 1).
204+
205+ Fixed: with no agent file on disk, `/skillcreate` reads the answers
206+ of the session's agent wizard (`agentinit.Flow.Choices`, no server
207+ call): layout answered → that agent's `skillsDir` (`.mm/skills` with
208+ the `.mm` layout, `skills` otherwise), with a note when "Use skills?"
209+ is not answered yet or answered no. Layout not answered → LocateIn as
210+ before. Checked again on the four cases (written with/without skills,
211+ in progress, in progress with `--skills yes`): all in `.mm/skills`.
212+ Test `TestPendingAgentPlace`; docs EN+FR updated (the limit is gone).
213+ - id: 3
214+ author:
215+ name: k33g
216+ email: ph.charriere@gmail.com
217+ createdAt: 2026-11-15T03:36:45.000Z
218+ body: |
219+ Tried by the user (2026-11-15): works.
220+
modified docs/en/how-to/create-an-agent-from-an-editor.md +12 -0
@@ -45,8 +45,20 @@ This guide shows how to create an agent.yaml without a terminal, from any editor
4545 | `/dockerinit` | Writes a `Dockerfile` and a `compose.yaml` in the project to run the agent in a container — `mm dockerinit` for this project. Takes its flags (`--name`, `--agent`); existing files are kept unless you add `--overwrite`. |
4646 | `/zedconfig` | Shows Zed's `agent_servers` entries for this project's agent, from what the project holds: `mm acp <agent file>` always; with a `compose.yaml`, `docker exec -i <container> mm -acp -config <agent file>`; with a `sbxenv.yaml`, `sbx exec -i <sandbox> mm -acp -config <agent file>` — each with what to start first. |
4747 | `/sbxinit` | Writes a `sbxenv.yaml` in the project to run the agent in a Docker Sandbox — `mm sbxinit` for this project. Takes its flags (`--name`, `--workspace`, `--agent`); an existing `sbxenv.yaml` is kept unless you add `--overwrite`. Works at any time; after the agent is written, the sandbox is named after it. |
48+| `/skillcreate` | Creates a skill for the project's agent — `mm skillcreate` for this project: see [Create a skill from the same thread](#create-a-skill-from-the-same-thread). |
49+| `/cancel` | Gives up the skill being created; the agent's questions come back. |
4850 | `/help` | Lists these commands. |
4951
52+## Create a skill from the same thread
53+
54+Send `/skillcreate`. It asks what `mm skillcreate` asks in a terminal — the skill's name, a one-line description, keywords (`.` for none) and the text — and writes `<skillsDir>/<name>/SKILL.md` where the project's agent reads its skills: the `skillsDir` of its `agent.yaml` or `.mm/agent.yaml`. Without an agent file, or with `skillsDir: no-skills`, the skill goes to `.mm/skills` when the project has a `.mm` folder — or when the agent being created in this thread uses one —, else to `skills`, and mm says which `skillsDir` to set.
55+
56+- **The flags answer in advance:** `/skillcreate --name "Vulcan Salute" --description "Greets the Vulcan way" --keywords "vulcan, greeting"`. Quote a value with spaces.
57+- **The text** is one message: several lines are fine. With a client that shows forms, the other questions come in a form and the text is still asked as a message.
58+- **An existing skill** is replaced only if you answer `yes`; `no` asks for another name.
59+- **In the middle of the agent's questions,** the answers given so far are kept: once the skill is written — or given up with `/cancel` — the agent's current question comes back. Before its file is written, the skill goes where that agent will read its skills: `.mm/skills` once you answered `yes` to *Keep everything in a .mm folder?*, and mm reminds you to answer `yes` to *Use skills?*.
60+- After writing, mm reads the skill back (`✅ mm reads it: …`). An agent already running sees it once it restarts (*Reload Agent*).
61+
5062 ## Variations
5163
5264 - **Fix answers in advance.** The flags of `mm init` work here (`--provider`, `--base-url`, `--model`, `--usage`, `--project yes`…): a question whose flag is given is not asked. For example `"args": ["acp-create", "--provider", "llmman", "--project", "yes"]`.
@@ -45,8 +45,20 @@ This guide shows how to create an agent.yaml without a terminal, from any editor
45 | `/dockerinit` | Writes a `Dockerfile` and a `compose.yaml` in the project to run the agent in a container — `mm dockerinit` for this project. Takes its flags (`--name`, `--agent`); existing files are kept unless you add `--overwrite`. |45 | `/dockerinit` | Writes a `Dockerfile` and a `compose.yaml` in the project to run the agent in a container — `mm dockerinit` for this project. Takes its flags (`--name`, `--agent`); existing files are kept unless you add `--overwrite`. |
46 | `/zedconfig` | Shows Zed's `agent_servers` entries for this project's agent, from what the project holds: `mm acp <agent file>` always; with a `compose.yaml`, `docker exec -i <container> mm -acp -config <agent file>`; with a `sbxenv.yaml`, `sbx exec -i <sandbox> mm -acp -config <agent file>` — each with what to start first. |46 | `/zedconfig` | Shows Zed's `agent_servers` entries for this project's agent, from what the project holds: `mm acp <agent file>` always; with a `compose.yaml`, `docker exec -i <container> mm -acp -config <agent file>`; with a `sbxenv.yaml`, `sbx exec -i <sandbox> mm -acp -config <agent file>` — each with what to start first. |
47 | `/sbxinit` | Writes a `sbxenv.yaml` in the project to run the agent in a Docker Sandbox — `mm sbxinit` for this project. Takes its flags (`--name`, `--workspace`, `--agent`); an existing `sbxenv.yaml` is kept unless you add `--overwrite`. Works at any time; after the agent is written, the sandbox is named after it. |47 | `/sbxinit` | Writes a `sbxenv.yaml` in the project to run the agent in a Docker Sandbox — `mm sbxinit` for this project. Takes its flags (`--name`, `--workspace`, `--agent`); an existing `sbxenv.yaml` is kept unless you add `--overwrite`. Works at any time; after the agent is written, the sandbox is named after it. |
48+| `/skillcreate` | Creates a skill for the project's agent — `mm skillcreate` for this project: see [Create a skill from the same thread](#create-a-skill-from-the-same-thread). |
49+| `/cancel` | Gives up the skill being created; the agent's questions come back. |
48 | `/help` | Lists these commands. |50 | `/help` | Lists these commands. |
49 51
52+## Create a skill from the same thread
53+
54+Send `/skillcreate`. It asks what `mm skillcreate` asks in a terminal — the skill's name, a one-line description, keywords (`.` for none) and the text — and writes `<skillsDir>/<name>/SKILL.md` where the project's agent reads its skills: the `skillsDir` of its `agent.yaml` or `.mm/agent.yaml`. Without an agent file, or with `skillsDir: no-skills`, the skill goes to `.mm/skills` when the project has a `.mm` folder — or when the agent being created in this thread uses one —, else to `skills`, and mm says which `skillsDir` to set.
55+
56+- **The flags answer in advance:** `/skillcreate --name "Vulcan Salute" --description "Greets the Vulcan way" --keywords "vulcan, greeting"`. Quote a value with spaces.
57+- **The text** is one message: several lines are fine. With a client that shows forms, the other questions come in a form and the text is still asked as a message.
58+- **An existing skill** is replaced only if you answer `yes`; `no` asks for another name.
59+- **In the middle of the agent's questions,** the answers given so far are kept: once the skill is written — or given up with `/cancel` — the agent's current question comes back. Before its file is written, the skill goes where that agent will read its skills: `.mm/skills` once you answered `yes` to *Keep everything in a .mm folder?*, and mm reminds you to answer `yes` to *Use skills?*.
60+- After writing, mm reads the skill back (`✅ mm reads it: …`). An agent already running sees it once it restarts (*Reload Agent*).
61+
50 ## Variations62 ## Variations
51 63
52 - **Fix answers in advance.** The flags of `mm init` work here (`--provider`, `--base-url`, `--model`, `--usage`, `--project yes`…): a question whose flag is given is not asked. For example `"args": ["acp-create", "--provider", "llmman", "--project", "yes"]`.64 - **Fix answers in advance.** The flags of `mm init` work here (`--provider`, `--base-url`, `--model`, `--usage`, `--project yes`…): a question whose flag is given is not asked. For example `"args": ["acp-create", "--provider", "llmman", "--project", "yes"]`.
modified docs/en/reference/cli.md +1 -1
@@ -280,7 +280,7 @@ An agent file outside the workspace only gives a warning: see [when the agent fi
280280 | Sessions | One wizard per `session/new`. Relative files are written in the session's `cwd`. After the `session/new` response, an `agent_message_chunk` says to send `/start`. `/start` or the first prompt, whatever its text, starts the wizard. |
281281 | Questions | Chosen from the client's capabilities only. `clientCapabilities.elicitation.form` present: `elicitation/create` forms (session-scoped), as many questions per form as possible — a form never holds a question together with one its choices or default depend on (model after server, family after model, ask-first and name after usage, skills and file after layout, overwrite after file). Absent: one `agent_message_chunk` per question, numbered choices; the next prompt answers it — a number, a value, or `.` / `default` / `/skip` for the default. |
282282 | Form outcomes | Declined or cancelled: nothing written, the next prompt asks again. Three forms in a row with no usable answer: the wizard stops. |
283-| Commands (`available_commands_update`) | `start`, `preview`, `back`, `write`, `restart`, `dockerinit`, `zedconfig`, `sbxinit`, `help`. `zedconfig`: one `agent_servers` JSON block for the agent file found in the session's `cwd` (`agent.yaml`, then `.mm/agent.yaml`) — an entry `mm-<project>` running `mm acp <absolute path>`; with `compose.yaml`, `mm-<project> (container)` running `docker exec -i <container_name> mm -acp -config <path>`; with `sbxenv.yaml`, `mm-<project> (sandbox)` running `sbx exec -i <name> mm -acp -config <path>` — then one line per entry saying what to start first. No agent file: a pointer to `/start`. `dockerinit` and `sbxinit` (input hint: their flags) run [`mm dockerinit`](#mm-dockerinit--the-container-files) and [`mm sbxinit`](#mm-sbxinit--the-sandbox-file) with the session's `cwd` as current directory, their output in one message; `--overwrite` replaces existing files, which are otherwise kept (nothing can be asked). |
283+| Commands (`available_commands_update`) | `start`, `preview`, `back`, `write`, `restart`, `skillcreate`, `cancel`, `dockerinit`, `zedconfig`, `sbxinit`, `help`. `zedconfig`: one `agent_servers` JSON block for the agent file found in the session's `cwd` (`agent.yaml`, then `.mm/agent.yaml`) — an entry `mm-<project>` running `mm acp <absolute path>`; with `compose.yaml`, `mm-<project> (container)` running `docker exec -i <container_name> mm -acp -config <path>`; with `sbxenv.yaml`, `mm-<project> (sandbox)` running `sbx exec -i <name> mm -acp -config <path>` — then one line per entry saying what to start first. No agent file: a pointer to `/start`. `skillcreate` (input hint: `--name`, `--description`, `--keywords`, quoted values allowed) asks the questions of [`mm skillcreate`](#mm-skillcreate--the-skill-wizard) — forms for the one-line answers when the client shows them, the text always as a message — and writes the skill in the `skillsDir` of the agent file found in the session's `cwd` (`agent.yaml`, then `.mm/agent.yaml`, read without loading it into the process's settings), else — no agent file yet — the `skillsDir` the agent being created in this session will have (`.mm/skills` with the `.mm` layout), else `.mm/skills` or `skills`, with a note; the agent's answers are kept and its current question comes back after the skill, or after `cancel`. `dockerinit` and `sbxinit` (input hint: their flags) run [`mm dockerinit`](#mm-dockerinit--the-container-files) and [`mm sbxinit`](#mm-sbxinit--the-sandbox-file) with the session's `cwd` as current directory, their output in one message; `--overwrite` replaces existing files, which are otherwise kept (nothing can be asked). |
284284 | After writing | The messages of `mm init` (`✅ wrote <file>`, `✅ mm loads it`), then `mm acp <absolute path>`, a pointer to `/zedconfig`, and one to `/dockerinit` and `/sbxinit`; for a client whose `clientInfo.name` contains `zed`, an `agent_servers` snippet too. Later prompts answer that the file is written. |
285285 | Exit codes | 0 the client closed the connection, or `-h`; 1 a refused flag value or presets that do not load; 2 a bad flag. |
286286
@@ -280,7 +280,7 @@ An agent file outside the workspace only gives a warning: see [when the agent fi
280 | Sessions | One wizard per `session/new`. Relative files are written in the session's `cwd`. After the `session/new` response, an `agent_message_chunk` says to send `/start`. `/start` or the first prompt, whatever its text, starts the wizard. |280 | Sessions | One wizard per `session/new`. Relative files are written in the session's `cwd`. After the `session/new` response, an `agent_message_chunk` says to send `/start`. `/start` or the first prompt, whatever its text, starts the wizard. |
281 | Questions | Chosen from the client's capabilities only. `clientCapabilities.elicitation.form` present: `elicitation/create` forms (session-scoped), as many questions per form as possible — a form never holds a question together with one its choices or default depend on (model after server, family after model, ask-first and name after usage, skills and file after layout, overwrite after file). Absent: one `agent_message_chunk` per question, numbered choices; the next prompt answers it — a number, a value, or `.` / `default` / `/skip` for the default. |281 | Questions | Chosen from the client's capabilities only. `clientCapabilities.elicitation.form` present: `elicitation/create` forms (session-scoped), as many questions per form as possible — a form never holds a question together with one its choices or default depend on (model after server, family after model, ask-first and name after usage, skills and file after layout, overwrite after file). Absent: one `agent_message_chunk` per question, numbered choices; the next prompt answers it — a number, a value, or `.` / `default` / `/skip` for the default. |
282 | Form outcomes | Declined or cancelled: nothing written, the next prompt asks again. Three forms in a row with no usable answer: the wizard stops. |282 | Form outcomes | Declined or cancelled: nothing written, the next prompt asks again. Three forms in a row with no usable answer: the wizard stops. |
283-| Commands (`available_commands_update`) | `start`, `preview`, `back`, `write`, `restart`, `dockerinit`, `zedconfig`, `sbxinit`, `help`. `zedconfig`: one `agent_servers` JSON block for the agent file found in the session's `cwd` (`agent.yaml`, then `.mm/agent.yaml`) — an entry `mm-<project>` running `mm acp <absolute path>`; with `compose.yaml`, `mm-<project> (container)` running `docker exec -i <container_name> mm -acp -config <path>`; with `sbxenv.yaml`, `mm-<project> (sandbox)` running `sbx exec -i <name> mm -acp -config <path>` — then one line per entry saying what to start first. No agent file: a pointer to `/start`. `dockerinit` and `sbxinit` (input hint: their flags) run [`mm dockerinit`](#mm-dockerinit--the-container-files) and [`mm sbxinit`](#mm-sbxinit--the-sandbox-file) with the session's `cwd` as current directory, their output in one message; `--overwrite` replaces existing files, which are otherwise kept (nothing can be asked). |283+| Commands (`available_commands_update`) | `start`, `preview`, `back`, `write`, `restart`, `skillcreate`, `cancel`, `dockerinit`, `zedconfig`, `sbxinit`, `help`. `zedconfig`: one `agent_servers` JSON block for the agent file found in the session's `cwd` (`agent.yaml`, then `.mm/agent.yaml`) — an entry `mm-<project>` running `mm acp <absolute path>`; with `compose.yaml`, `mm-<project> (container)` running `docker exec -i <container_name> mm -acp -config <path>`; with `sbxenv.yaml`, `mm-<project> (sandbox)` running `sbx exec -i <name> mm -acp -config <path>` — then one line per entry saying what to start first. No agent file: a pointer to `/start`. `skillcreate` (input hint: `--name`, `--description`, `--keywords`, quoted values allowed) asks the questions of [`mm skillcreate`](#mm-skillcreate--the-skill-wizard) — forms for the one-line answers when the client shows them, the text always as a message — and writes the skill in the `skillsDir` of the agent file found in the session's `cwd` (`agent.yaml`, then `.mm/agent.yaml`, read without loading it into the process's settings), else — no agent file yet — the `skillsDir` the agent being created in this session will have (`.mm/skills` with the `.mm` layout), else `.mm/skills` or `skills`, with a note; the agent's answers are kept and its current question comes back after the skill, or after `cancel`. `dockerinit` and `sbxinit` (input hint: their flags) run [`mm dockerinit`](#mm-dockerinit--the-container-files) and [`mm sbxinit`](#mm-sbxinit--the-sandbox-file) with the session's `cwd` as current directory, their output in one message; `--overwrite` replaces existing files, which are otherwise kept (nothing can be asked). |
284 | After writing | The messages of `mm init` (`✅ wrote <file>`, `✅ mm loads it`), then `mm acp <absolute path>`, a pointer to `/zedconfig`, and one to `/dockerinit` and `/sbxinit`; for a client whose `clientInfo.name` contains `zed`, an `agent_servers` snippet too. Later prompts answer that the file is written. |284 | After writing | The messages of `mm init` (`✅ wrote <file>`, `✅ mm loads it`), then `mm acp <absolute path>`, a pointer to `/zedconfig`, and one to `/dockerinit` and `/sbxinit`; for a client whose `clientInfo.name` contains `zed`, an `agent_servers` snippet too. Later prompts answer that the file is written. |
285 | Exit codes | 0 the client closed the connection, or `-h`; 1 a refused flag value or presets that do not load; 2 a bad flag. |285 | Exit codes | 0 the client closed the connection, or `-h`; 1 a refused flag value or presets that do not load; 2 a bad flag. |
286 286
modified docs/fr/how-to/create-an-agent-from-an-editor.md +12 -0
@@ -45,8 +45,20 @@ Ce guide montre comment créer un agent.yaml sans terminal, depuis n'importe que
4545 | `/dockerinit` | Écrit un `Dockerfile` et un `compose.yaml` dans le projet pour faire tourner l'agent dans un conteneur — `mm dockerinit` pour ce projet. Accepte ses options (`--name`, `--agent`) ; les fichiers existants sont gardés sauf si vous ajoutez `--overwrite`. |
4646 | `/zedconfig` | Affiche les entrées `agent_servers` de Zed pour l'agent de ce projet, d'après ce que contient le projet : `mm acp <fichier d'agent>` toujours ; avec un `compose.yaml`, `docker exec -i <conteneur> mm -acp -config <fichier d'agent>` ; avec un `sbxenv.yaml`, `sbx exec -i <sandbox> mm -acp -config <fichier d'agent>` — chacune avec ce qu'il faut lancer avant. |
4747 | `/sbxinit` | Écrit un `sbxenv.yaml` dans le projet pour faire tourner l'agent dans un Docker Sandbox — `mm sbxinit` pour ce projet. Accepte ses options (`--name`, `--workspace`, `--agent`) ; un `sbxenv.yaml` existant est gardé sauf si vous ajoutez `--overwrite`. Marche à tout moment ; une fois l'agent écrit, le sandbox porte son nom. |
48+| `/skillcreate` | Crée une skill pour l'agent du projet — `mm skillcreate` pour ce projet : voir [Créer une skill dans la même conversation](#créer-une-skill-dans-la-même-conversation). |
49+| `/cancel` | Abandonne la skill en cours ; les questions de l'agent reviennent. |
4850 | `/help` | Liste ces commandes. |
4951
52+## Créer une skill dans la même conversation
53+
54+Envoyez `/skillcreate`. Il pose les questions de `mm skillcreate` dans un terminal — le nom de la skill, une description sur une ligne, des mots-clés (`.` pour aucun) et le texte — et écrit `<skillsDir>/<nom>/SKILL.md` là où l'agent du projet lit ses skills : le `skillsDir` de son `agent.yaml` ou de son `.mm/agent.yaml`. Sans fichier d'agent, ou avec `skillsDir: no-skills`, la skill va dans `.mm/skills` quand le projet a un dossier `.mm` — ou quand l'agent en cours de création dans cette conversation en utilise un —, sinon dans `skills`, et mm dit quel `skillsDir` définir.
55+
56+- **Les options répondent à l'avance :** `/skillcreate --name "Vulcan Salute" --description "Greets the Vulcan way" --keywords "vulcan, greeting"`. Mettez entre guillemets une valeur qui contient des espaces.
57+- **Le texte** tient en un message : plusieurs lignes sont possibles. Avec un client qui affiche des formulaires, les autres questions arrivent dans un formulaire et le texte est quand même demandé en message.
58+- **Une skill existante** n'est remplacée que si vous répondez `yes` ; `no` redemande un nom.
59+- **Au milieu des questions de l'agent,** les réponses déjà données sont gardées : une fois la skill écrite — ou abandonnée avec `/cancel` — la question de l'agent en cours revient. Tant que son fichier n'est pas écrit, la skill va là où cet agent lira ses skills : `.mm/skills` dès que vous avez répondu `yes` à *Keep everything in a .mm folder?*, et mm vous rappelle de répondre `yes` à *Use skills?*.
60+- Après l'écriture, mm relit la skill (`✅ mm reads it: …`). Un agent déjà lancé la voit une fois redémarré (*Reload Agent*).
61+
5062 ## Variantes
5163
5264 - **Fixer des réponses à l'avance.** Les options de `mm init` marchent ici (`--provider`, `--base-url`, `--model`, `--usage`, `--project yes`…) : une question dont l'option est donnée n'est pas posée. Par exemple `"args": ["acp-create", "--provider", "llmman", "--project", "yes"]`.
@@ -45,8 +45,20 @@ Ce guide montre comment créer un agent.yaml sans terminal, depuis n'importe que
45 | `/dockerinit` | Écrit un `Dockerfile` et un `compose.yaml` dans le projet pour faire tourner l'agent dans un conteneur — `mm dockerinit` pour ce projet. Accepte ses options (`--name`, `--agent`) ; les fichiers existants sont gardés sauf si vous ajoutez `--overwrite`. |45 | `/dockerinit` | Écrit un `Dockerfile` et un `compose.yaml` dans le projet pour faire tourner l'agent dans un conteneur — `mm dockerinit` pour ce projet. Accepte ses options (`--name`, `--agent`) ; les fichiers existants sont gardés sauf si vous ajoutez `--overwrite`. |
46 | `/zedconfig` | Affiche les entrées `agent_servers` de Zed pour l'agent de ce projet, d'après ce que contient le projet : `mm acp <fichier d'agent>` toujours ; avec un `compose.yaml`, `docker exec -i <conteneur> mm -acp -config <fichier d'agent>` ; avec un `sbxenv.yaml`, `sbx exec -i <sandbox> mm -acp -config <fichier d'agent>` — chacune avec ce qu'il faut lancer avant. |46 | `/zedconfig` | Affiche les entrées `agent_servers` de Zed pour l'agent de ce projet, d'après ce que contient le projet : `mm acp <fichier d'agent>` toujours ; avec un `compose.yaml`, `docker exec -i <conteneur> mm -acp -config <fichier d'agent>` ; avec un `sbxenv.yaml`, `sbx exec -i <sandbox> mm -acp -config <fichier d'agent>` — chacune avec ce qu'il faut lancer avant. |
47 | `/sbxinit` | Écrit un `sbxenv.yaml` dans le projet pour faire tourner l'agent dans un Docker Sandbox — `mm sbxinit` pour ce projet. Accepte ses options (`--name`, `--workspace`, `--agent`) ; un `sbxenv.yaml` existant est gardé sauf si vous ajoutez `--overwrite`. Marche à tout moment ; une fois l'agent écrit, le sandbox porte son nom. |47 | `/sbxinit` | Écrit un `sbxenv.yaml` dans le projet pour faire tourner l'agent dans un Docker Sandbox — `mm sbxinit` pour ce projet. Accepte ses options (`--name`, `--workspace`, `--agent`) ; un `sbxenv.yaml` existant est gardé sauf si vous ajoutez `--overwrite`. Marche à tout moment ; une fois l'agent écrit, le sandbox porte son nom. |
48+| `/skillcreate` | Crée une skill pour l'agent du projet — `mm skillcreate` pour ce projet : voir [Créer une skill dans la même conversation](#créer-une-skill-dans-la-même-conversation). |
49+| `/cancel` | Abandonne la skill en cours ; les questions de l'agent reviennent. |
48 | `/help` | Liste ces commandes. |50 | `/help` | Liste ces commandes. |
49 51
52+## Créer une skill dans la même conversation
53+
54+Envoyez `/skillcreate`. Il pose les questions de `mm skillcreate` dans un terminal — le nom de la skill, une description sur une ligne, des mots-clés (`.` pour aucun) et le texte — et écrit `<skillsDir>/<nom>/SKILL.md` là où l'agent du projet lit ses skills : le `skillsDir` de son `agent.yaml` ou de son `.mm/agent.yaml`. Sans fichier d'agent, ou avec `skillsDir: no-skills`, la skill va dans `.mm/skills` quand le projet a un dossier `.mm` — ou quand l'agent en cours de création dans cette conversation en utilise un —, sinon dans `skills`, et mm dit quel `skillsDir` définir.
55+
56+- **Les options répondent à l'avance :** `/skillcreate --name "Vulcan Salute" --description "Greets the Vulcan way" --keywords "vulcan, greeting"`. Mettez entre guillemets une valeur qui contient des espaces.
57+- **Le texte** tient en un message : plusieurs lignes sont possibles. Avec un client qui affiche des formulaires, les autres questions arrivent dans un formulaire et le texte est quand même demandé en message.
58+- **Une skill existante** n'est remplacée que si vous répondez `yes` ; `no` redemande un nom.
59+- **Au milieu des questions de l'agent,** les réponses déjà données sont gardées : une fois la skill écrite — ou abandonnée avec `/cancel` — la question de l'agent en cours revient. Tant que son fichier n'est pas écrit, la skill va là où cet agent lira ses skills : `.mm/skills` dès que vous avez répondu `yes` à *Keep everything in a .mm folder?*, et mm vous rappelle de répondre `yes` à *Use skills?*.
60+- Après l'écriture, mm relit la skill (`✅ mm reads it: …`). Un agent déjà lancé la voit une fois redémarré (*Reload Agent*).
61+
50 ## Variantes62 ## Variantes
51 63
52 - **Fixer des réponses à l'avance.** Les options de `mm init` marchent ici (`--provider`, `--base-url`, `--model`, `--usage`, `--project yes`…) : une question dont l'option est donnée n'est pas posée. Par exemple `"args": ["acp-create", "--provider", "llmman", "--project", "yes"]`.64 - **Fixer des réponses à l'avance.** Les options de `mm init` marchent ici (`--provider`, `--base-url`, `--model`, `--usage`, `--project yes`…) : une question dont l'option est donnée n'est pas posée. Par exemple `"args": ["acp-create", "--provider", "llmman", "--project", "yes"]`.
modified docs/fr/reference/cli.md +1 -1
@@ -280,7 +280,7 @@ Un fichier d'agent hors du workspace ne donne qu'un avertissement : voir [quand
280280 | Sessions | Un assistant par `session/new`. Les fichiers relatifs sont écrits dans le `cwd` de la session. Après la réponse à `session/new`, un `agent_message_chunk` invite à envoyer `/start`. `/start` ou le premier prompt, quel que soit son texte, démarre l'assistant. |
281281 | Questions | Choisies d'après les seules capacités du client. `clientCapabilities.elicitation.form` présent : formulaires `elicitation/create` (liés à la session), autant de questions par formulaire que possible — un formulaire ne contient jamais une question avec celle dont ses choix ou sa valeur par défaut dépendent (modèle après serveur, famille après modèle, confirmation et nom après usage, skills et fichier après disposition, remplacement après fichier). Absent : un `agent_message_chunk` par question, choix numérotés ; le prompt suivant y répond — un numéro, une valeur, ou `.` / `default` / `/skip` pour la valeur par défaut. |
282282 | Issues d'un formulaire | Refusé ou annulé : rien n'est écrit, le prompt suivant redemande. Trois formulaires de suite sans réponse utilisable : l'assistant s'arrête. |
283-| Commandes (`available_commands_update`) | `start`, `preview`, `back`, `write`, `restart`, `dockerinit`, `zedconfig`, `sbxinit`, `help`. `zedconfig` : un bloc JSON `agent_servers` pour le fichier d'agent trouvé dans le `cwd` de la session (`agent.yaml`, puis `.mm/agent.yaml`) — une entrée `mm-<projet>` qui lance `mm acp <chemin absolu>` ; avec `compose.yaml`, `mm-<projet> (container)` qui lance `docker exec -i <container_name> mm -acp -config <chemin>` ; avec `sbxenv.yaml`, `mm-<projet> (sandbox)` qui lance `sbx exec -i <name> mm -acp -config <chemin>` — puis une ligne par entrée disant ce qu'il faut lancer avant. Sans fichier d'agent : un renvoi vers `/start`. `dockerinit` et `sbxinit` (indication de saisie : leurs options) lancent [`mm dockerinit`](#mm-dockerinit--les-fichiers-du-conteneur) et [`mm sbxinit`](#mm-sbxinit--le-fichier-de-la-sandbox) avec le `cwd` de la session comme répertoire courant, leur sortie dans un message ; `--overwrite` remplace les fichiers existants, gardés sinon (rien ne peut être demandé). |
283+| Commandes (`available_commands_update`) | `start`, `preview`, `back`, `write`, `restart`, `skillcreate`, `cancel`, `dockerinit`, `zedconfig`, `sbxinit`, `help`. `zedconfig` : un bloc JSON `agent_servers` pour le fichier d'agent trouvé dans le `cwd` de la session (`agent.yaml`, puis `.mm/agent.yaml`) — une entrée `mm-<projet>` qui lance `mm acp <chemin absolu>` ; avec `compose.yaml`, `mm-<projet> (container)` qui lance `docker exec -i <container_name> mm -acp -config <chemin>` ; avec `sbxenv.yaml`, `mm-<projet> (sandbox)` qui lance `sbx exec -i <name> mm -acp -config <chemin>` — puis une ligne par entrée disant ce qu'il faut lancer avant. Sans fichier d'agent : un renvoi vers `/start`. `skillcreate` (indication de saisie : `--name`, `--description`, `--keywords`, valeurs entre guillemets acceptées) pose les questions de [`mm skillcreate`](#mm-skillcreate--lassistant-de-skill) — formulaires pour les réponses d'une ligne quand le client sait les afficher, le texte toujours en message — et écrit la skill dans le `skillsDir` du fichier d'agent trouvé dans le `cwd` de la session (`agent.yaml`, puis `.mm/agent.yaml`, lu sans être chargé dans les réglages du processus), sinon — pas encore de fichier d'agent — le `skillsDir` qu'aura l'agent en cours de création dans la session (`.mm/skills` avec la disposition `.mm`), sinon `.mm/skills` ou `skills`, avec une note ; les réponses de l'agent sont gardées et sa question en cours revient après la skill, ou après `cancel`. `dockerinit` et `sbxinit` (indication de saisie : leurs options) lancent [`mm dockerinit`](#mm-dockerinit--les-fichiers-du-conteneur) et [`mm sbxinit`](#mm-sbxinit--le-fichier-de-la-sandbox) avec le `cwd` de la session comme répertoire courant, leur sortie dans un message ; `--overwrite` remplace les fichiers existants, gardés sinon (rien ne peut être demandé). |
284284 | Après l'écriture | Les messages de `mm init` (`✅ wrote <fichier>`, `✅ mm loads it`), puis `mm acp <chemin absolu>`, un renvoi vers `/zedconfig`, et un vers `/dockerinit` et `/sbxinit` ; pour un client dont `clientInfo.name` contient `zed`, une entrée `agent_servers` en plus. Les prompts suivants répondent que le fichier est écrit. |
285285 | Codes de sortie | 0 le client a fermé la connexion, ou `-h` ; 1 une valeur d'option refusée ou des presets illisibles ; 2 une option inconnue. |
286286
@@ -280,7 +280,7 @@ Un fichier d'agent hors du workspace ne donne qu'un avertissement : voir [quand
280 | Sessions | Un assistant par `session/new`. Les fichiers relatifs sont écrits dans le `cwd` de la session. Après la réponse à `session/new`, un `agent_message_chunk` invite à envoyer `/start`. `/start` ou le premier prompt, quel que soit son texte, démarre l'assistant. |280 | Sessions | Un assistant par `session/new`. Les fichiers relatifs sont écrits dans le `cwd` de la session. Après la réponse à `session/new`, un `agent_message_chunk` invite à envoyer `/start`. `/start` ou le premier prompt, quel que soit son texte, démarre l'assistant. |
281 | Questions | Choisies d'après les seules capacités du client. `clientCapabilities.elicitation.form` présent : formulaires `elicitation/create` (liés à la session), autant de questions par formulaire que possible — un formulaire ne contient jamais une question avec celle dont ses choix ou sa valeur par défaut dépendent (modèle après serveur, famille après modèle, confirmation et nom après usage, skills et fichier après disposition, remplacement après fichier). Absent : un `agent_message_chunk` par question, choix numérotés ; le prompt suivant y répond — un numéro, une valeur, ou `.` / `default` / `/skip` pour la valeur par défaut. |281 | Questions | Choisies d'après les seules capacités du client. `clientCapabilities.elicitation.form` présent : formulaires `elicitation/create` (liés à la session), autant de questions par formulaire que possible — un formulaire ne contient jamais une question avec celle dont ses choix ou sa valeur par défaut dépendent (modèle après serveur, famille après modèle, confirmation et nom après usage, skills et fichier après disposition, remplacement après fichier). Absent : un `agent_message_chunk` par question, choix numérotés ; le prompt suivant y répond — un numéro, une valeur, ou `.` / `default` / `/skip` pour la valeur par défaut. |
282 | Issues d'un formulaire | Refusé ou annulé : rien n'est écrit, le prompt suivant redemande. Trois formulaires de suite sans réponse utilisable : l'assistant s'arrête. |282 | Issues d'un formulaire | Refusé ou annulé : rien n'est écrit, le prompt suivant redemande. Trois formulaires de suite sans réponse utilisable : l'assistant s'arrête. |
283-| Commandes (`available_commands_update`) | `start`, `preview`, `back`, `write`, `restart`, `dockerinit`, `zedconfig`, `sbxinit`, `help`. `zedconfig` : un bloc JSON `agent_servers` pour le fichier d'agent trouvé dans le `cwd` de la session (`agent.yaml`, puis `.mm/agent.yaml`) — une entrée `mm-<projet>` qui lance `mm acp <chemin absolu>` ; avec `compose.yaml`, `mm-<projet> (container)` qui lance `docker exec -i <container_name> mm -acp -config <chemin>` ; avec `sbxenv.yaml`, `mm-<projet> (sandbox)` qui lance `sbx exec -i <name> mm -acp -config <chemin>` — puis une ligne par entrée disant ce qu'il faut lancer avant. Sans fichier d'agent : un renvoi vers `/start`. `dockerinit` et `sbxinit` (indication de saisie : leurs options) lancent [`mm dockerinit`](#mm-dockerinit--les-fichiers-du-conteneur) et [`mm sbxinit`](#mm-sbxinit--le-fichier-de-la-sandbox) avec le `cwd` de la session comme répertoire courant, leur sortie dans un message ; `--overwrite` remplace les fichiers existants, gardés sinon (rien ne peut être demandé). |283+| Commandes (`available_commands_update`) | `start`, `preview`, `back`, `write`, `restart`, `skillcreate`, `cancel`, `dockerinit`, `zedconfig`, `sbxinit`, `help`. `zedconfig` : un bloc JSON `agent_servers` pour le fichier d'agent trouvé dans le `cwd` de la session (`agent.yaml`, puis `.mm/agent.yaml`) — une entrée `mm-<projet>` qui lance `mm acp <chemin absolu>` ; avec `compose.yaml`, `mm-<projet> (container)` qui lance `docker exec -i <container_name> mm -acp -config <chemin>` ; avec `sbxenv.yaml`, `mm-<projet> (sandbox)` qui lance `sbx exec -i <name> mm -acp -config <chemin>` — puis une ligne par entrée disant ce qu'il faut lancer avant. Sans fichier d'agent : un renvoi vers `/start`. `skillcreate` (indication de saisie : `--name`, `--description`, `--keywords`, valeurs entre guillemets acceptées) pose les questions de [`mm skillcreate`](#mm-skillcreate--lassistant-de-skill) — formulaires pour les réponses d'une ligne quand le client sait les afficher, le texte toujours en message — et écrit la skill dans le `skillsDir` du fichier d'agent trouvé dans le `cwd` de la session (`agent.yaml`, puis `.mm/agent.yaml`, lu sans être chargé dans les réglages du processus), sinon — pas encore de fichier d'agent — le `skillsDir` qu'aura l'agent en cours de création dans la session (`.mm/skills` avec la disposition `.mm`), sinon `.mm/skills` ou `skills`, avec une note ; les réponses de l'agent sont gardées et sa question en cours revient après la skill, ou après `cancel`. `dockerinit` et `sbxinit` (indication de saisie : leurs options) lancent [`mm dockerinit`](#mm-dockerinit--les-fichiers-du-conteneur) et [`mm sbxinit`](#mm-sbxinit--le-fichier-de-la-sandbox) avec le `cwd` de la session comme répertoire courant, leur sortie dans un message ; `--overwrite` remplace les fichiers existants, gardés sinon (rien ne peut être demandé). |
284 | Après l'écriture | Les messages de `mm init` (`✅ wrote <fichier>`, `✅ mm loads it`), puis `mm acp <chemin absolu>`, un renvoi vers `/zedconfig`, et un vers `/dockerinit` et `/sbxinit` ; pour un client dont `clientInfo.name` contient `zed`, une entrée `agent_servers` en plus. Les prompts suivants répondent que le fichier est écrit. |284 | Après l'écriture | Les messages de `mm init` (`✅ wrote <fichier>`, `✅ mm loads it`), puis `mm acp <chemin absolu>`, un renvoi vers `/zedconfig`, et un vers `/dockerinit` et `/sbxinit` ; pour un client dont `clientInfo.name` contient `zed`, une entrée `agent_servers` en plus. Les prompts suivants répondent que le fichier est écrit. |
285 | Codes de sortie | 0 le client a fermé la connexion, ou `-h` ; 1 une valeur d'option refusée ou des presets illisibles ; 2 une option inconnue. |285 | Codes de sortie | 0 le client a fermé la connexion, ou `-h` ; 1 une valeur d'option refusée ou des presets illisibles ; 2 une option inconnue. |
286 286
modified internal/acpcreate/acpcreate.go +1 -1
@@ -149,7 +149,7 @@ func (ag *agent) newSession(cwd string) (*session, error) {
149149 return nil, err
150150 }
151151 flow.SetDir(cwd)
152- return &session{cwd: cwd, flow: flow}, nil
152+ return &session{cwd: cwd, flow: flow, agent: agentRun(flow)}, nil
153153 }
154154
155155 // Prompt is one message from the user: an answer, a command, or — the
@@ -149,7 +149,7 @@ func (ag *agent) newSession(cwd string) (*session, error) {
149 return nil, err149 return nil, err
150 }150 }
151 flow.SetDir(cwd)151 flow.SetDir(cwd)
152- return &session{cwd: cwd, flow: flow}, nil152+ return &session{cwd: cwd, flow: flow, agent: agentRun(flow)}, nil
153 }153 }
154 154
155 // Prompt is one message from the user: an answer, a command, or — the155 // Prompt is one message from the user: an answer, a command, or — the
modified internal/acpcreate/acpcreate_test.go +1 -1
@@ -563,7 +563,7 @@ func TestBatchStopsAtADependency(t *testing.T) {
563563 "file,overwrite": "file",
564564 "project,skills": "project",
565565 } {
566- if got := keys(batch(steps(strings.Split(in, ",")...))); got != want {
566+ if got := keys(batch(steps(strings.Split(in, ",")...), agentDeps)); got != want {
567567 t.Errorf("batch(%s) = %s, want %s", in, got, want)
568568 }
569569 }
@@ -563,7 +563,7 @@ func TestBatchStopsAtADependency(t *testing.T) {
563 "file,overwrite": "file",563 "file,overwrite": "file",
564 "project,skills": "project",564 "project,skills": "project",
565 } {565 } {
566- if got := keys(batch(steps(strings.Split(in, ",")...))); got != want {566+ if got := keys(batch(steps(strings.Split(in, ",")...), agentDeps)); got != want {
567 t.Errorf("batch(%s) = %s, want %s", in, got, want)567 t.Errorf("batch(%s) = %s, want %s", in, got, want)
568 }568 }
569 }569 }
modified internal/acpcreate/ask.go +40 -17
@@ -37,10 +37,18 @@ func question(st agentinit.Step, position string) string {
3737 b.WriteString("\n")
3838 }
3939 how := "Type your answer"
40- if len(st.Options) > 0 {
40+ switch {
41+ case len(st.Options) > 0:
4142 how = "Reply with a number or a value"
43+ case st.Multiline:
44+ how = "Type it in one message — several lines are fine"
4245 }
43- if st.Default != "" {
46+ switch {
47+ case st.Optional:
48+ // An optional answer's default stands for "none" (mm skillcreate's
49+ // keywords: "-"): say what it means, not what it is.
50+ how += " · `.` for none"
51+ case st.Default != "":
4452 how += fmt.Sprintf(" · `.` for the default (`%s`)", st.Default)
4553 }
4654 b.WriteString(how + ".")
@@ -99,7 +107,7 @@ func parseConfirm(q elicit.Question, text string) (string, error) {
99107 // dependsOn says which answers a question's options or default are built
100108 // from (agentinit.Flow.Step). A form never holds a question with the one it
101109 // depends on: its options would be stale by the time the form is answered.
102-var dependsOn = map[string][]string{
110+var agentDeps = map[string][]string{
103111 "endpoint": {"provider"},
104112 "keyenv": {"provider"},
105113 "model": {"provider", "endpoint", "keyenv"},
@@ -111,13 +119,23 @@ var dependsOn = map[string][]string{
111119 "overwrite": {"file"},
112120 }
113121
122+// skillDeps is the same for mm skillcreate's questions: replacing a skill
123+// is asked about the name just given.
124+var skillDeps = map[string][]string{
125+ "overwrite": {"name"},
126+}
127+
114128 // batch is the next form: the pending questions in order, up to the first
115-// one that depends on a question already in the form.
116-func batch(pending []agentinit.Step) []agentinit.Step {
129+// one that depends on a question already in the form (deps), and never a
130+// multi-line text — those are asked as a message (askByForms).
131+func batch(pending []agentinit.Step, deps map[string][]string) []agentinit.Step {
117132 in := map[string]bool{}
118133 var out []agentinit.Step
119134 for _, st := range pending {
120- for _, dep := range dependsOn[st.Key] {
135+ if st.Multiline {
136+ return out
137+ }
138+ for _, dep := range deps[st.Key] {
121139 if in[dep] {
122140 return out
123141 }
@@ -133,17 +151,23 @@ func batch(pending []agentinit.Step) []agentinit.Step {
133151 // not loop forever.
134152 const maxIdleForms = 3
135153
136-// askByForms asks the pending questions form after form, then writes.
137-func (t *turn) askByForms() {
138- s := t.s
154+// askByForms asks the pending questions form after form, then writes. A
155+// multi-line text (the skill's) is asked as a message even here: whether a
156+// client offers several lines in a form's text field is unknown, and the
157+// reply box of the chat always does.
158+func (t *turn) askByForms(r *run) {
139159 idle := 0
140160 for {
141- pending := s.flow.Pending()
161+ pending := r.w.Pending()
142162 if len(pending) == 0 {
143- t.write()
163+ r.finish(t)
164+ return
165+ }
166+ if pending[0].Multiline {
167+ t.ask(r, pending[0])
144168 return
145169 }
146- steps := batch(pending)
170+ steps := batch(pending, r.deps)
147171 answers, status := t.form(steps)
148172 switch status {
149173 case elicit.Declined:
@@ -153,7 +177,7 @@ func (t *turn) askByForms() {
153177 t.say("The form was closed: nothing written. Send any message to go on.")
154178 return
155179 }
156- if t.apply(steps, answers) == 0 {
180+ if t.apply(r, steps, answers) == 0 {
157181 idle++
158182 if idle >= maxIdleForms {
159183 t.say("No answer could be used: stopping here. Send any message to try again.")
@@ -167,8 +191,7 @@ func (t *turn) askByForms() {
167191
168192 // apply answers the questions of a form; it reports what was refused and
169193 // returns how many answers were taken.
170-func (t *turn) apply(steps []agentinit.Step, answers []elicit.Answer) int {
171- s := t.s
194+func (t *turn) apply(r *run, steps []agentinit.Step, answers []elicit.Answer) int {
172195 taken := 0
173196 var refused []string
174197 for i, st := range steps {
@@ -179,11 +202,11 @@ func (t *turn) apply(steps []agentinit.Step, answers []elicit.Answer) int {
179202 if strings.TrimSpace(value) == "" {
180203 value = st.Default
181204 }
182- if err := s.flow.Set(st.Key, value); err != nil {
205+ if err := r.w.Set(st.Key, value); err != nil {
183206 refused = append(refused, "⚠️ "+err.Error())
184207 continue
185208 }
186- s.answered = append(s.answered, st.Key)
209+ r.answered = append(r.answered, st.Key)
187210 taken++
188211 }
189212 if len(refused) > 0 {
@@ -37,10 +37,18 @@ func question(st agentinit.Step, position string) string {
37 b.WriteString("\n")37 b.WriteString("\n")
38 }38 }
39 how := "Type your answer"39 how := "Type your answer"
40- if len(st.Options) > 0 {40+ switch {
41+ case len(st.Options) > 0:
41 how = "Reply with a number or a value"42 how = "Reply with a number or a value"
43+ case st.Multiline:
44+ how = "Type it in one message — several lines are fine"
42 }45 }
43- if st.Default != "" {46+ switch {
47+ case st.Optional:
48+ // An optional answer's default stands for "none" (mm skillcreate's
49+ // keywords: "-"): say what it means, not what it is.
50+ how += " · `.` for none"
51+ case st.Default != "":
44 how += fmt.Sprintf(" · `.` for the default (`%s`)", st.Default)52 how += fmt.Sprintf(" · `.` for the default (`%s`)", st.Default)
45 }53 }
46 b.WriteString(how + ".")54 b.WriteString(how + ".")
@@ -99,7 +107,7 @@ func parseConfirm(q elicit.Question, text string) (string, error) {
99 // dependsOn says which answers a question's options or default are built107 // dependsOn says which answers a question's options or default are built
100 // from (agentinit.Flow.Step). A form never holds a question with the one it108 // from (agentinit.Flow.Step). A form never holds a question with the one it
101 // depends on: its options would be stale by the time the form is answered.109 // depends on: its options would be stale by the time the form is answered.
102-var dependsOn = map[string][]string{110+var agentDeps = map[string][]string{
103 "endpoint": {"provider"},111 "endpoint": {"provider"},
104 "keyenv": {"provider"},112 "keyenv": {"provider"},
105 "model": {"provider", "endpoint", "keyenv"},113 "model": {"provider", "endpoint", "keyenv"},
@@ -111,13 +119,23 @@ var dependsOn = map[string][]string{
111 "overwrite": {"file"},119 "overwrite": {"file"},
112 }120 }
113 121
122+// skillDeps is the same for mm skillcreate's questions: replacing a skill
123+// is asked about the name just given.
124+var skillDeps = map[string][]string{
125+ "overwrite": {"name"},
126+}
127+
114 // batch is the next form: the pending questions in order, up to the first128 // batch is the next form: the pending questions in order, up to the first
115-// one that depends on a question already in the form.129+// one that depends on a question already in the form (deps), and never a
116-func batch(pending []agentinit.Step) []agentinit.Step {130+// multi-line text — those are asked as a message (askByForms).
131+func batch(pending []agentinit.Step, deps map[string][]string) []agentinit.Step {
117 in := map[string]bool{}132 in := map[string]bool{}
118 var out []agentinit.Step133 var out []agentinit.Step
119 for _, st := range pending {134 for _, st := range pending {
120- for _, dep := range dependsOn[st.Key] {135+ if st.Multiline {
136+ return out
137+ }
138+ for _, dep := range deps[st.Key] {
121 if in[dep] {139 if in[dep] {
122 return out140 return out
123 }141 }
@@ -133,17 +151,23 @@ func batch(pending []agentinit.Step) []agentinit.Step {
133 // not loop forever.151 // not loop forever.
134 const maxIdleForms = 3152 const maxIdleForms = 3
135 153
136-// askByForms asks the pending questions form after form, then writes.154+// askByForms asks the pending questions form after form, then writes. A
137-func (t *turn) askByForms() {155+// multi-line text (the skill's) is asked as a message even here: whether a
138- s := t.s156+// client offers several lines in a form's text field is unknown, and the
157+// reply box of the chat always does.
158+func (t *turn) askByForms(r *run) {
139 idle := 0159 idle := 0
140 for {160 for {
141- pending := s.flow.Pending()161+ pending := r.w.Pending()
142 if len(pending) == 0 {162 if len(pending) == 0 {
143- t.write()163+ r.finish(t)
164+ return
165+ }
166+ if pending[0].Multiline {
167+ t.ask(r, pending[0])
144 return168 return
145 }169 }
146- steps := batch(pending)170+ steps := batch(pending, r.deps)
147 answers, status := t.form(steps)171 answers, status := t.form(steps)
148 switch status {172 switch status {
149 case elicit.Declined:173 case elicit.Declined:
@@ -153,7 +177,7 @@ func (t *turn) askByForms() {
153 t.say("The form was closed: nothing written. Send any message to go on.")177 t.say("The form was closed: nothing written. Send any message to go on.")
154 return178 return
155 }179 }
156- if t.apply(steps, answers) == 0 {180+ if t.apply(r, steps, answers) == 0 {
157 idle++181 idle++
158 if idle >= maxIdleForms {182 if idle >= maxIdleForms {
159 t.say("No answer could be used: stopping here. Send any message to try again.")183 t.say("No answer could be used: stopping here. Send any message to try again.")
@@ -167,8 +191,7 @@ func (t *turn) askByForms() {
167 191
168 // apply answers the questions of a form; it reports what was refused and192 // apply answers the questions of a form; it reports what was refused and
169 // returns how many answers were taken.193 // returns how many answers were taken.
170-func (t *turn) apply(steps []agentinit.Step, answers []elicit.Answer) int {194+func (t *turn) apply(r *run, steps []agentinit.Step, answers []elicit.Answer) int {
171- s := t.s
172 taken := 0195 taken := 0
173 var refused []string196 var refused []string
174 for i, st := range steps {197 for i, st := range steps {
@@ -179,11 +202,11 @@ func (t *turn) apply(steps []agentinit.Step, answers []elicit.Answer) int {
179 if strings.TrimSpace(value) == "" {202 if strings.TrimSpace(value) == "" {
180 value = st.Default203 value = st.Default
181 }204 }
182- if err := s.flow.Set(st.Key, value); err != nil {205+ if err := r.w.Set(st.Key, value); err != nil {
183 refused = append(refused, "⚠️ "+err.Error())206 refused = append(refused, "⚠️ "+err.Error())
184 continue207 continue
185 }208 }
186- s.answered = append(s.answered, st.Key)209+ r.answered = append(r.answered, st.Key)
187 taken++210 taken++
188 }211 }
189 if len(refused) > 0 {212 if len(refused) > 0 {
modified internal/acpcreate/session.go +87 -43
@@ -13,6 +13,7 @@ import (
1313 "mm/internal/agentinit"
1414 "mm/internal/dockerinit"
1515 "mm/internal/sbxinit"
16+ "mm/internal/skillcreate"
1617
1718 sdk "github.com/coder/acp-go-sdk"
1819 )
@@ -25,13 +26,15 @@ type session struct {
2526 flow *agentinit.Flow
2627
2728 started bool
28- // asked is the question shown in the chat and waiting for the next
29- // prompt (conversation mode); "" when none.
30- asked string
31- // answered is the keys answered in this session, in order — what /back
32- // takes back.
33- answered []string
34- // written is the file written, absolute; "" while not written.
29+ // agent is the run of mm init's questions on flow; skill the run of
30+ // /skillcreate's while one is being created (ticket 213), nil otherwise.
31+ agent run
32+ skill *run
33+ // skillFlow is the skill run's flow, skillArgs the flags it was given
34+ // (/restart starts it again with them).
35+ skillFlow *skillcreate.Flow
36+ skillArgs string
37+ // written is the agent file written, absolute; "" while not written.
3538 written string
3639
3740 cancelMu sync.Mutex
@@ -60,6 +63,8 @@ var commands = []struct{ name, description, hint string }{
6063 {"back", "Take back the last answer", ""},
6164 {"write", "Take the default for every question left, then write the file", ""},
6265 {"restart", "Forget every answer and start again", ""},
66+ {"skillcreate", "Create a skill for the project's agent (mm skillcreate); the agent's answers are kept", "[--name <name>] [--description <text>] [--keywords <words>]"},
67+ {"cancel", "Give up the skill being created; back to the agent's questions", ""},
6368 {"dockerinit", "Write a Dockerfile and a compose.yaml to run the agent in a container (mm dockerinit)", "[--name <name>] [--agent <file>] [--overwrite]"},
6469 {"zedconfig", "Show the Zed settings that start this project's agent: here, in its container, in its Docker Sandbox", ""},
6570 {"sbxinit", "Write a sbxenv.yaml to run the agent in a Docker Sandbox (mm sbxinit)", "[--name <name>] [--workspace <dir>] [--agent <file>] [--overwrite]"},
@@ -90,19 +95,27 @@ func (t *turn) say(markdown string) { t.ag.say(t.ctx, t.sid, markdown) }
9095
9196 // handle answers one prompt.
9297 func (t *turn) handle(text string) {
93- if name, args, ok := slashCommand(text); ok {
94- t.command(name, args)
98+ if name, args, rest, ok := slashCommand(text); ok {
99+ t.command(name, args, rest)
95100 return
96101 }
97102 s := t.s
103+ if s.skill != nil {
104+ // A skill is being created: the message answers its question.
105+ if s.skill.asked != "" && !t.answer(text) {
106+ return
107+ }
108+ t.next()
109+ return
110+ }
98111 switch {
99112 case s.written != "":
100- t.say(fmt.Sprintf("`%s` is written. `/restart` creates another one.", s.written))
113+ t.say(fmt.Sprintf("`%s` is written. `/restart` creates another one, `/skillcreate` a skill for it.", s.written))
101114 return
102115 case !s.started:
103116 t.start()
104117 return
105- case s.asked != "":
118+ case s.agent.asked != "":
106119 if !t.answer(text) {
107120 return
108121 }
@@ -110,28 +123,39 @@ func (t *turn) handle(text string) {
110123 t.next()
111124 }
112125
113-// slashCommand recognises one of this mode's commands, and the words
114-// typed after it.
115-func slashCommand(text string) (string, []string, bool) {
126+// slashCommand recognises one of this mode's commands, the words typed
127+// after it, and the same as typed (rest: /skillcreate reads quoted values
128+// from it).
129+func slashCommand(text string) (name string, args []string, rest string, ok bool) {
116130 text = strings.TrimSpace(text)
117131 if !strings.HasPrefix(text, "/") {
118- return "", nil, false
132+ return "", nil, "", false
119133 }
120134 words := strings.Fields(text[1:])
121135 if len(words) == 0 {
122- return "", nil, false
136+ return "", nil, "", false
123137 }
124138 for _, c := range commands {
125139 if c.name == words[0] {
126- return c.name, words[1:], true
140+ return c.name, words[1:], strings.TrimSpace(strings.TrimPrefix(text[1:], words[0])), true
127141 }
128142 }
129- return "", nil, false
143+ return "", nil, "", false
130144 }
131145
132-func (t *turn) command(name string, args []string) {
146+func (t *turn) command(name string, args []string, rest string) {
133147 s := t.s
134148 switch name {
149+ case "skillcreate":
150+ t.skillcreate(rest)
151+ case "cancel":
152+ if s.skill == nil {
153+ t.say("No skill is being created.")
154+ return
155+ }
156+ s.skill, s.skillFlow = nil, nil
157+ t.say("Skill given up: nothing written.")
158+ t.resumeAgent()
135159 case "sbxinit":
136160 t.sbxinit(args)
137161 case "dockerinit":
@@ -139,23 +163,36 @@ func (t *turn) command(name string, args []string) {
139163 case "zedconfig":
140164 t.say(zedConfig(s.cwd))
141165 case "start":
166+ if s.skill != nil {
167+ t.next() // the skill's current question, again
168+ return
169+ }
142170 t.start()
143171 case "help":
144172 t.say(help())
145173 case "preview":
146- t.say("```yaml\n" + s.flow.Preview("", "") + "```")
174+ t.say(s.cur().preview())
147175 case "restart":
176+ if s.skill != nil {
177+ t.skillcreate(s.skillArgs)
178+ return
179+ }
148180 fresh, err := t.ag.newSession(s.cwd)
149181 if err != nil {
150182 t.say("⚠️ " + err.Error())
151183 return
152184 }
153- s.flow, s.asked, s.answered, s.written, s.started = fresh.flow, "", nil, "", true
185+ s.flow, s.agent, s.written, s.started = fresh.flow, fresh.agent, "", true
154186 t.say("Starting again.")
155187 t.next()
156188 case "back":
157189 t.back()
158190 case "write":
191+ if s.skill != nil {
192+ t.takeDefaults()
193+ t.next()
194+ return
195+ }
159196 if s.written != "" {
160197 t.say(fmt.Sprintf("`%s` is already written.", s.written))
161198 return
@@ -253,23 +290,26 @@ func welcome(cwd string) string {
253290 // back takes back the last answer given here and asks it again.
254291 func (t *turn) back() {
255292 s := t.s
256- if len(s.answered) == 0 {
293+ r := s.cur()
294+ if len(r.answered) == 0 {
257295 t.say("Nothing to take back.")
258296 return
259297 }
260- key := s.answered[len(s.answered)-1]
261- s.answered = s.answered[:len(s.answered)-1]
262- s.flow.Unset(key)
263- s.written = ""
298+ key := r.answered[len(r.answered)-1]
299+ r.answered = r.answered[:len(r.answered)-1]
300+ r.w.Unset(key)
301+ if r == &s.agent {
302+ s.written = ""
303+ }
264304 t.next()
265305 }
266306
267307 // takeDefaults answers every pending question that has a default, as mm
268308 // init --yes does, but stops at an existing file: replacing it is asked.
269309 func (t *turn) takeDefaults() {
270- s := t.s
310+ r := t.s.cur()
271311 for {
272- pending := s.flow.Pending()
312+ pending := r.w.Pending()
273313 if len(pending) == 0 {
274314 return
275315 }
@@ -277,35 +317,39 @@ func (t *turn) takeDefaults() {
277317 if st.Key == "overwrite" || st.Default == "" {
278318 return
279319 }
280- if err := s.flow.Set(st.Key, st.Default); err != nil {
320+ if err := r.w.Set(st.Key, st.Default); err != nil {
281321 t.say("⚠️ " + err.Error())
282322 return
283323 }
284- s.answered = append(s.answered, st.Key)
324+ r.answered = append(r.answered, st.Key)
285325 }
286326 }
287327
288328 // next asks what is still to ask, or writes the file when nothing is.
289329 func (t *turn) next() {
290- s := t.s
291- s.asked = ""
330+ r := t.s.cur()
331+ r.asked = ""
292332 if t.ag.elicitForm {
293- t.askByForms()
333+ t.askByForms(r)
294334 return
295335 }
296- pending := s.flow.Pending()
336+ pending := r.w.Pending()
297337 if len(pending) == 0 {
298- t.write()
338+ r.finish(t)
299339 return
300340 }
301- st := pending[0]
302- s.asked = st.Key
341+ t.ask(r, pending[0])
342+}
343+
344+// ask shows one question in the chat; the next prompt answers it.
345+func (t *turn) ask(r *run, st agentinit.Step) {
346+ r.asked = st.Key
303347 t.say(question(st, t.position(st.Key)))
304348 }
305349
306350 // position is "n of N", the question's place among all of them.
307351 func (t *turn) position(key string) string {
308- steps := t.s.flow.Steps()
352+ steps := t.s.cur().w.Steps()
309353 for i, st := range steps {
310354 if st.Key == key {
311355 return fmt.Sprintf("%d of %d", i+1, len(steps))
@@ -317,17 +361,17 @@ func (t *turn) position(key string) string {
317361 // answer reads the prompt as the answer to the question asked; false when
318362 // it does not answer it (the question is shown again).
319363 func (t *turn) answer(text string) bool {
320- s := t.s
321- st := s.flow.Step(s.asked)
364+ r := t.s.cur()
365+ st := r.w.Step(r.asked)
322366 value, err := parseAnswer(st, text)
323367 if err == nil {
324- err = s.flow.Set(st.Key, value)
368+ err = r.w.Set(st.Key, value)
325369 }
326370 if err != nil {
327371 t.say("⚠️ " + err.Error() + "\n\n" + question(st, t.position(st.Key)))
328372 return false
329373 }
330- s.answered = append(s.answered, st.Key)
374+ r.answered = append(r.answered, st.Key)
331375 return true
332376 }
333377
@@ -345,7 +389,7 @@ func (t *turn) write() {
345389 return
346390 }
347391 s.written = s.flow.Path(output)
348- t.say(report.String() + "\n" + howToUse(s.written, t.ag.client) + "\n`/dockerinit` writes a Dockerfile and a compose.yaml to run this agent in a container, `/sbxinit` a sbxenv.yaml for a Docker Sandbox.")
392+ t.say(report.String() + "\n" + howToUse(s.written, t.ag.client) + "\n`/dockerinit` writes a Dockerfile and a compose.yaml to run this agent in a container, `/sbxinit` a sbxenv.yaml for a Docker Sandbox, `/skillcreate` a skill for it.")
349393 }
350394
351395 func intro(cwd string, forms bool) string {
@@ -13,6 +13,7 @@ import (
13 "mm/internal/agentinit"13 "mm/internal/agentinit"
14 "mm/internal/dockerinit"14 "mm/internal/dockerinit"
15 "mm/internal/sbxinit"15 "mm/internal/sbxinit"
16+ "mm/internal/skillcreate"
16 17
17 sdk "github.com/coder/acp-go-sdk"18 sdk "github.com/coder/acp-go-sdk"
18 )19 )
@@ -25,13 +26,15 @@ type session struct {
25 flow *agentinit.Flow26 flow *agentinit.Flow
26 27
27 started bool28 started bool
28- // asked is the question shown in the chat and waiting for the next29+ // agent is the run of mm init's questions on flow; skill the run of
29- // prompt (conversation mode); "" when none.30+ // /skillcreate's while one is being created (ticket 213), nil otherwise.
30- asked string31+ agent run
31- // answered is the keys answered in this session, in order — what /back32+ skill *run
32- // takes back.33+ // skillFlow is the skill run's flow, skillArgs the flags it was given
33- answered []string34+ // (/restart starts it again with them).
34- // written is the file written, absolute; "" while not written.35+ skillFlow *skillcreate.Flow
36+ skillArgs string
37+ // written is the agent file written, absolute; "" while not written.
35 written string38 written string
36 39
37 cancelMu sync.Mutex40 cancelMu sync.Mutex
@@ -60,6 +63,8 @@ var commands = []struct{ name, description, hint string }{
60 {"back", "Take back the last answer", ""},63 {"back", "Take back the last answer", ""},
61 {"write", "Take the default for every question left, then write the file", ""},64 {"write", "Take the default for every question left, then write the file", ""},
62 {"restart", "Forget every answer and start again", ""},65 {"restart", "Forget every answer and start again", ""},
66+ {"skillcreate", "Create a skill for the project's agent (mm skillcreate); the agent's answers are kept", "[--name <name>] [--description <text>] [--keywords <words>]"},
67+ {"cancel", "Give up the skill being created; back to the agent's questions", ""},
63 {"dockerinit", "Write a Dockerfile and a compose.yaml to run the agent in a container (mm dockerinit)", "[--name <name>] [--agent <file>] [--overwrite]"},68 {"dockerinit", "Write a Dockerfile and a compose.yaml to run the agent in a container (mm dockerinit)", "[--name <name>] [--agent <file>] [--overwrite]"},
64 {"zedconfig", "Show the Zed settings that start this project's agent: here, in its container, in its Docker Sandbox", ""},69 {"zedconfig", "Show the Zed settings that start this project's agent: here, in its container, in its Docker Sandbox", ""},
65 {"sbxinit", "Write a sbxenv.yaml to run the agent in a Docker Sandbox (mm sbxinit)", "[--name <name>] [--workspace <dir>] [--agent <file>] [--overwrite]"},70 {"sbxinit", "Write a sbxenv.yaml to run the agent in a Docker Sandbox (mm sbxinit)", "[--name <name>] [--workspace <dir>] [--agent <file>] [--overwrite]"},
@@ -90,19 +95,27 @@ func (t *turn) say(markdown string) { t.ag.say(t.ctx, t.sid, markdown) }
90 95
91 // handle answers one prompt.96 // handle answers one prompt.
92 func (t *turn) handle(text string) {97 func (t *turn) handle(text string) {
93- if name, args, ok := slashCommand(text); ok {98+ if name, args, rest, ok := slashCommand(text); ok {
94- t.command(name, args)99+ t.command(name, args, rest)
95 return100 return
96 }101 }
97 s := t.s102 s := t.s
103+ if s.skill != nil {
104+ // A skill is being created: the message answers its question.
105+ if s.skill.asked != "" && !t.answer(text) {
106+ return
107+ }
108+ t.next()
109+ return
110+ }
98 switch {111 switch {
99 case s.written != "":112 case s.written != "":
100- t.say(fmt.Sprintf("`%s` is written. `/restart` creates another one.", s.written))113+ t.say(fmt.Sprintf("`%s` is written. `/restart` creates another one, `/skillcreate` a skill for it.", s.written))
101 return114 return
102 case !s.started:115 case !s.started:
103 t.start()116 t.start()
104 return117 return
105- case s.asked != "":118+ case s.agent.asked != "":
106 if !t.answer(text) {119 if !t.answer(text) {
107 return120 return
108 }121 }
@@ -110,28 +123,39 @@ func (t *turn) handle(text string) {
110 t.next()123 t.next()
111 }124 }
112 125
113-// slashCommand recognises one of this mode's commands, and the words126+// slashCommand recognises one of this mode's commands, the words typed
114-// typed after it.127+// after it, and the same as typed (rest: /skillcreate reads quoted values
115-func slashCommand(text string) (string, []string, bool) {128+// from it).
129+func slashCommand(text string) (name string, args []string, rest string, ok bool) {
116 text = strings.TrimSpace(text)130 text = strings.TrimSpace(text)
117 if !strings.HasPrefix(text, "/") {131 if !strings.HasPrefix(text, "/") {
118- return "", nil, false132+ return "", nil, "", false
119 }133 }
120 words := strings.Fields(text[1:])134 words := strings.Fields(text[1:])
121 if len(words) == 0 {135 if len(words) == 0 {
122- return "", nil, false136+ return "", nil, "", false
123 }137 }
124 for _, c := range commands {138 for _, c := range commands {
125 if c.name == words[0] {139 if c.name == words[0] {
126- return c.name, words[1:], true140+ return c.name, words[1:], strings.TrimSpace(strings.TrimPrefix(text[1:], words[0])), true
127 }141 }
128 }142 }
129- return "", nil, false143+ return "", nil, "", false
130 }144 }
131 145
132-func (t *turn) command(name string, args []string) {146+func (t *turn) command(name string, args []string, rest string) {
133 s := t.s147 s := t.s
134 switch name {148 switch name {
149+ case "skillcreate":
150+ t.skillcreate(rest)
151+ case "cancel":
152+ if s.skill == nil {
153+ t.say("No skill is being created.")
154+ return
155+ }
156+ s.skill, s.skillFlow = nil, nil
157+ t.say("Skill given up: nothing written.")
158+ t.resumeAgent()
135 case "sbxinit":159 case "sbxinit":
136 t.sbxinit(args)160 t.sbxinit(args)
137 case "dockerinit":161 case "dockerinit":
@@ -139,23 +163,36 @@ func (t *turn) command(name string, args []string) {
139 case "zedconfig":163 case "zedconfig":
140 t.say(zedConfig(s.cwd))164 t.say(zedConfig(s.cwd))
141 case "start":165 case "start":
166+ if s.skill != nil {
167+ t.next() // the skill's current question, again
168+ return
169+ }
142 t.start()170 t.start()
143 case "help":171 case "help":
144 t.say(help())172 t.say(help())
145 case "preview":173 case "preview":
146- t.say("```yaml\n" + s.flow.Preview("", "") + "```")174+ t.say(s.cur().preview())
147 case "restart":175 case "restart":
176+ if s.skill != nil {
177+ t.skillcreate(s.skillArgs)
178+ return
179+ }
148 fresh, err := t.ag.newSession(s.cwd)180 fresh, err := t.ag.newSession(s.cwd)
149 if err != nil {181 if err != nil {
150 t.say("⚠️ " + err.Error())182 t.say("⚠️ " + err.Error())
151 return183 return
152 }184 }
153- s.flow, s.asked, s.answered, s.written, s.started = fresh.flow, "", nil, "", true185+ s.flow, s.agent, s.written, s.started = fresh.flow, fresh.agent, "", true
154 t.say("Starting again.")186 t.say("Starting again.")
155 t.next()187 t.next()
156 case "back":188 case "back":
157 t.back()189 t.back()
158 case "write":190 case "write":
191+ if s.skill != nil {
192+ t.takeDefaults()
193+ t.next()
194+ return
195+ }
159 if s.written != "" {196 if s.written != "" {
160 t.say(fmt.Sprintf("`%s` is already written.", s.written))197 t.say(fmt.Sprintf("`%s` is already written.", s.written))
161 return198 return
@@ -253,23 +290,26 @@ func welcome(cwd string) string {
253 // back takes back the last answer given here and asks it again.290 // back takes back the last answer given here and asks it again.
254 func (t *turn) back() {291 func (t *turn) back() {
255 s := t.s292 s := t.s
256- if len(s.answered) == 0 {293+ r := s.cur()
294+ if len(r.answered) == 0 {
257 t.say("Nothing to take back.")295 t.say("Nothing to take back.")
258 return296 return
259 }297 }
260- key := s.answered[len(s.answered)-1]298+ key := r.answered[len(r.answered)-1]
261- s.answered = s.answered[:len(s.answered)-1]299+ r.answered = r.answered[:len(r.answered)-1]
262- s.flow.Unset(key)300+ r.w.Unset(key)
263- s.written = ""301+ if r == &s.agent {
302+ s.written = ""
303+ }
264 t.next()304 t.next()
265 }305 }
266 306
267 // takeDefaults answers every pending question that has a default, as mm307 // takeDefaults answers every pending question that has a default, as mm
268 // init --yes does, but stops at an existing file: replacing it is asked.308 // init --yes does, but stops at an existing file: replacing it is asked.
269 func (t *turn) takeDefaults() {309 func (t *turn) takeDefaults() {
270- s := t.s310+ r := t.s.cur()
271 for {311 for {
272- pending := s.flow.Pending()312+ pending := r.w.Pending()
273 if len(pending) == 0 {313 if len(pending) == 0 {
274 return314 return
275 }315 }
@@ -277,35 +317,39 @@ func (t *turn) takeDefaults() {
277 if st.Key == "overwrite" || st.Default == "" {317 if st.Key == "overwrite" || st.Default == "" {
278 return318 return
279 }319 }
280- if err := s.flow.Set(st.Key, st.Default); err != nil {320+ if err := r.w.Set(st.Key, st.Default); err != nil {
281 t.say("⚠️ " + err.Error())321 t.say("⚠️ " + err.Error())
282 return322 return
283 }323 }
284- s.answered = append(s.answered, st.Key)324+ r.answered = append(r.answered, st.Key)
285 }325 }
286 }326 }
287 327
288 // next asks what is still to ask, or writes the file when nothing is.328 // next asks what is still to ask, or writes the file when nothing is.
289 func (t *turn) next() {329 func (t *turn) next() {
290- s := t.s330+ r := t.s.cur()
291- s.asked = ""331+ r.asked = ""
292 if t.ag.elicitForm {332 if t.ag.elicitForm {
293- t.askByForms()333+ t.askByForms(r)
294 return334 return
295 }335 }
296- pending := s.flow.Pending()336+ pending := r.w.Pending()
297 if len(pending) == 0 {337 if len(pending) == 0 {
298- t.write()338+ r.finish(t)
299 return339 return
300 }340 }
301- st := pending[0]341+ t.ask(r, pending[0])
302- s.asked = st.Key342+}
343+
344+// ask shows one question in the chat; the next prompt answers it.
345+func (t *turn) ask(r *run, st agentinit.Step) {
346+ r.asked = st.Key
303 t.say(question(st, t.position(st.Key)))347 t.say(question(st, t.position(st.Key)))
304 }348 }
305 349
306 // position is "n of N", the question's place among all of them.350 // position is "n of N", the question's place among all of them.
307 func (t *turn) position(key string) string {351 func (t *turn) position(key string) string {
308- steps := t.s.flow.Steps()352+ steps := t.s.cur().w.Steps()
309 for i, st := range steps {353 for i, st := range steps {
310 if st.Key == key {354 if st.Key == key {
311 return fmt.Sprintf("%d of %d", i+1, len(steps))355 return fmt.Sprintf("%d of %d", i+1, len(steps))
@@ -317,17 +361,17 @@ func (t *turn) position(key string) string {
317 // answer reads the prompt as the answer to the question asked; false when361 // answer reads the prompt as the answer to the question asked; false when
318 // it does not answer it (the question is shown again).362 // it does not answer it (the question is shown again).
319 func (t *turn) answer(text string) bool {363 func (t *turn) answer(text string) bool {
320- s := t.s364+ r := t.s.cur()
321- st := s.flow.Step(s.asked)365+ st := r.w.Step(r.asked)
322 value, err := parseAnswer(st, text)366 value, err := parseAnswer(st, text)
323 if err == nil {367 if err == nil {
324- err = s.flow.Set(st.Key, value)368+ err = r.w.Set(st.Key, value)
325 }369 }
326 if err != nil {370 if err != nil {
327 t.say("⚠️ " + err.Error() + "\n\n" + question(st, t.position(st.Key)))371 t.say("⚠️ " + err.Error() + "\n\n" + question(st, t.position(st.Key)))
328 return false372 return false
329 }373 }
330- s.answered = append(s.answered, st.Key)374+ r.answered = append(r.answered, st.Key)
331 return true375 return true
332 }376 }
333 377
@@ -345,7 +389,7 @@ func (t *turn) write() {
345 return389 return
346 }390 }
347 s.written = s.flow.Path(output)391 s.written = s.flow.Path(output)
348- t.say(report.String() + "\n" + howToUse(s.written, t.ag.client) + "\n`/dockerinit` writes a Dockerfile and a compose.yaml to run this agent in a container, `/sbxinit` a sbxenv.yaml for a Docker Sandbox.")392+ t.say(report.String() + "\n" + howToUse(s.written, t.ag.client) + "\n`/dockerinit` writes a Dockerfile and a compose.yaml to run this agent in a container, `/sbxinit` a sbxenv.yaml for a Docker Sandbox, `/skillcreate` a skill for it.")
349 }393 }
350 394
351 func intro(cwd string, forms bool) string {395 func intro(cwd string, forms bool) string {
added internal/acpcreate/skill.go +175 -0
new file mode 100644
@@ -0,0 +1,175 @@
1+package acpcreate
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "flag"
7+ "fmt"
8+ "io"
9+ "path/filepath"
10+ "strings"
11+
12+ "mm/internal/agentinit"
13+ "mm/internal/skillcreate"
14+)
15+
16+// /skillcreate (ticket 213): mm skillcreate for the session's project. The
17+// skill goes where the project's agent reads its skills (skillcreate.
18+// LocateIn: the session's cwd, never the process's settings). It runs beside
19+// the agent's questions, which keep their answers: once the skill is written
20+// — or given up with /cancel — the agent's current question comes back.
21+
22+var _ wizard = (*skillcreate.Flow)(nil)
23+
24+// skillUsage is said when /skillcreate's flags cannot be read.
25+const skillUsage = "`/skillcreate [--name <name>] [--description <text>] [--keywords <words>]` — quote a value with spaces: `--name \"Vulcan Salute\"`."
26+
27+// skillcreate starts the skill wizard with the flags typed after the command
28+// (rest) as given answers, never asked.
29+func (t *turn) skillcreate(rest string) {
30+ s := t.s
31+ spec, err := parseSkillFlags(rest)
32+ if err != nil {
33+ t.say("⚠️ " + err.Error() + "\n\n" + skillUsage)
34+ return
35+ }
36+ place, err := skillcreate.LocateIn(s.cwd)
37+ if err != nil {
38+ t.say("⚠️ " + err.Error())
39+ return
40+ }
41+ if place.Config == "" {
42+ // No agent file on disk yet: the agent being created here may
43+ // already have its layout — .mm/ only exists once its file is.
44+ if p, ok := pendingAgentPlace(s.cwd, s.flow.Choices()); ok {
45+ place = p
46+ }
47+ }
48+ flow := skillcreate.NewFlow(place, skillcreate.Spec{}, "")
49+ if err := flow.SetGiven(spec); err != nil {
50+ t.say("⚠️ " + err.Error() + "\n\n" + skillUsage)
51+ return
52+ }
53+ s.skillFlow, s.skillArgs = flow, rest
54+ s.skill = &run{
55+ w: flow,
56+ deps: skillDeps,
57+ finish: func(t *turn) { t.writeSkill() },
58+ preview: func() string { return "```markdown\n" + flow.Preview("", "") + "\n```" },
59+ }
60+ msg := fmt.Sprintf("**mm skillcreate** — a new skill in `%s`.", place.Dir)
61+ if place.Note != "" {
62+ msg += "\n\n⚠️ " + place.Note
63+ }
64+ msg += "\n\n`/preview` shows the SKILL.md as it stands, `/back` takes back an answer, `/cancel` gives the skill up."
65+ if s.started && s.written == "" {
66+ msg += " The agent's answers are kept: its questions come back once the skill is written."
67+ }
68+ t.say(msg)
69+ t.next()
70+}
71+
72+// pendingAgentPlace is where the agent being created will read its skills,
73+// from the answers given so far: .mm/skills with the .mm layout, skills
74+// otherwise (agentinit.Choices.SkillsDir) — and what is still missing for
75+// it to read them. ok is false while the layout question is unanswered:
76+// skillcreate.LocateIn's choice stands then.
77+func pendingAgentPlace(cwd string, c agentinit.Choices) (skillcreate.Place, bool) {
78+ if c.Project == nil {
79+ return skillcreate.Place{}, false
80+ }
81+ dir := c.SkillsDir()
82+ place := skillcreate.Place{Dir: filepath.Join(cwd, dir)}
83+ switch {
84+ case c.Skills == nil:
85+ place.Note = fmt.Sprintf("the agent being created reads its skills from %s if you answer yes to \"Use skills?\"", dir)
86+ case !*c.Skills:
87+ place.Note = fmt.Sprintf("the agent being created has no skills (skillsDir: no-skills): set skillsDir: %s in its file, or /back to \"Use skills?\" and answer yes", dir)
88+ }
89+ return place, true
90+}
91+
92+// writeSkill writes the answered skill, reads it back, and goes back to the
93+// agent's questions.
94+func (t *turn) writeSkill() {
95+ s := t.s
96+ var report bytes.Buffer
97+ if _, ok := skillcreate.WriteSkill(s.skillFlow, &report); !ok {
98+ t.say(report.String() + "\nNothing more was done: `/back` changes an answer, `/cancel` gives the skill up.")
99+ return
100+ }
101+ s.skill, s.skillFlow = nil, nil
102+ t.say(report.String() + "\nA running agent sees the new skill once it restarts (*Reload Agent*): its catalogue of skills is read at start-up.")
103+ t.resumeAgent()
104+}
105+
106+// resumeAgent brings the agent's questions back after a skill, when the
107+// wizard was half way through.
108+func (t *turn) resumeAgent() {
109+ s := t.s
110+ if !s.started || s.written != "" {
111+ return
112+ }
113+ t.say("Back to the agent.")
114+ t.next()
115+}
116+
117+// parseSkillFlags reads /skillcreate's flags: those of mm skillcreate that
118+// answer a question. --yes, --line, -config and -o have no use here: the
119+// agent file is the project's, the skill goes to its skills directory.
120+func parseSkillFlags(rest string) (skillcreate.Spec, error) {
121+ var spec skillcreate.Spec
122+ args, err := splitArgs(rest)
123+ if err != nil {
124+ return spec, err
125+ }
126+ fs := flag.NewFlagSet("/skillcreate", flag.ContinueOnError)
127+ fs.SetOutput(io.Discard)
128+ fs.StringVar(&spec.Name, "name", "", "")
129+ fs.StringVar(&spec.Description, "description", "", "")
130+ fs.StringVar(&spec.Keywords, "keywords", "", "")
131+ fs.StringVar(&spec.Body, "body", "", "")
132+ if err := fs.Parse(args); err != nil {
133+ return spec, err
134+ }
135+ if fs.NArg() > 0 {
136+ return spec, fmt.Errorf("unexpected %q", fs.Arg(0))
137+ }
138+ return spec, nil
139+}
140+
141+// splitArgs splits a command line on spaces, keeping what is between double
142+// or single quotes together: `--name "Vulcan Salute"` is two arguments.
143+func splitArgs(s string) ([]string, error) {
144+ var out []string
145+ var cur strings.Builder
146+ inWord, quote := false, rune(0)
147+ for _, r := range s {
148+ switch {
149+ case quote != 0:
150+ if r == quote {
151+ quote = 0
152+ continue
153+ }
154+ cur.WriteRune(r)
155+ case r == '"' || r == '\'':
156+ quote, inWord = r, true
157+ case r == ' ' || r == '\t' || r == '\n':
158+ if inWord {
159+ out = append(out, cur.String())
160+ cur.Reset()
161+ inWord = false
162+ }
163+ default:
164+ cur.WriteRune(r)
165+ inWord = true
166+ }
167+ }
168+ if quote != 0 {
169+ return nil, errors.New("a quote is not closed")
170+ }
171+ if inWord {
172+ out = append(out, cur.String())
173+ }
174+ return out, nil
175+}
new file mode 100644
@@ -0,0 +1,175 @@
1+package acpcreate
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "flag"
7+ "fmt"
8+ "io"
9+ "path/filepath"
10+ "strings"
11+
12+ "mm/internal/agentinit"
13+ "mm/internal/skillcreate"
14+)
15+
16+// /skillcreate (ticket 213): mm skillcreate for the session's project. The
17+// skill goes where the project's agent reads its skills (skillcreate.
18+// LocateIn: the session's cwd, never the process's settings). It runs beside
19+// the agent's questions, which keep their answers: once the skill is written
20+// — or given up with /cancel — the agent's current question comes back.
21+
22+var _ wizard = (*skillcreate.Flow)(nil)
23+
24+// skillUsage is said when /skillcreate's flags cannot be read.
25+const skillUsage = "`/skillcreate [--name <name>] [--description <text>] [--keywords <words>]` — quote a value with spaces: `--name \"Vulcan Salute\"`."
26+
27+// skillcreate starts the skill wizard with the flags typed after the command
28+// (rest) as given answers, never asked.
29+func (t *turn) skillcreate(rest string) {
30+ s := t.s
31+ spec, err := parseSkillFlags(rest)
32+ if err != nil {
33+ t.say("⚠️ " + err.Error() + "\n\n" + skillUsage)
34+ return
35+ }
36+ place, err := skillcreate.LocateIn(s.cwd)
37+ if err != nil {
38+ t.say("⚠️ " + err.Error())
39+ return
40+ }
41+ if place.Config == "" {
42+ // No agent file on disk yet: the agent being created here may
43+ // already have its layout — .mm/ only exists once its file is.
44+ if p, ok := pendingAgentPlace(s.cwd, s.flow.Choices()); ok {
45+ place = p
46+ }
47+ }
48+ flow := skillcreate.NewFlow(place, skillcreate.Spec{}, "")
49+ if err := flow.SetGiven(spec); err != nil {
50+ t.say("⚠️ " + err.Error() + "\n\n" + skillUsage)
51+ return
52+ }
53+ s.skillFlow, s.skillArgs = flow, rest
54+ s.skill = &run{
55+ w: flow,
56+ deps: skillDeps,
57+ finish: func(t *turn) { t.writeSkill() },
58+ preview: func() string { return "```markdown\n" + flow.Preview("", "") + "\n```" },
59+ }
60+ msg := fmt.Sprintf("**mm skillcreate** — a new skill in `%s`.", place.Dir)
61+ if place.Note != "" {
62+ msg += "\n\n⚠️ " + place.Note
63+ }
64+ msg += "\n\n`/preview` shows the SKILL.md as it stands, `/back` takes back an answer, `/cancel` gives the skill up."
65+ if s.started && s.written == "" {
66+ msg += " The agent's answers are kept: its questions come back once the skill is written."
67+ }
68+ t.say(msg)
69+ t.next()
70+}
71+
72+// pendingAgentPlace is where the agent being created will read its skills,
73+// from the answers given so far: .mm/skills with the .mm layout, skills
74+// otherwise (agentinit.Choices.SkillsDir) — and what is still missing for
75+// it to read them. ok is false while the layout question is unanswered:
76+// skillcreate.LocateIn's choice stands then.
77+func pendingAgentPlace(cwd string, c agentinit.Choices) (skillcreate.Place, bool) {
78+ if c.Project == nil {
79+ return skillcreate.Place{}, false
80+ }
81+ dir := c.SkillsDir()
82+ place := skillcreate.Place{Dir: filepath.Join(cwd, dir)}
83+ switch {
84+ case c.Skills == nil:
85+ place.Note = fmt.Sprintf("the agent being created reads its skills from %s if you answer yes to \"Use skills?\"", dir)
86+ case !*c.Skills:
87+ place.Note = fmt.Sprintf("the agent being created has no skills (skillsDir: no-skills): set skillsDir: %s in its file, or /back to \"Use skills?\" and answer yes", dir)
88+ }
89+ return place, true
90+}
91+
92+// writeSkill writes the answered skill, reads it back, and goes back to the
93+// agent's questions.
94+func (t *turn) writeSkill() {
95+ s := t.s
96+ var report bytes.Buffer
97+ if _, ok := skillcreate.WriteSkill(s.skillFlow, &report); !ok {
98+ t.say(report.String() + "\nNothing more was done: `/back` changes an answer, `/cancel` gives the skill up.")
99+ return
100+ }
101+ s.skill, s.skillFlow = nil, nil
102+ t.say(report.String() + "\nA running agent sees the new skill once it restarts (*Reload Agent*): its catalogue of skills is read at start-up.")
103+ t.resumeAgent()
104+}
105+
106+// resumeAgent brings the agent's questions back after a skill, when the
107+// wizard was half way through.
108+func (t *turn) resumeAgent() {
109+ s := t.s
110+ if !s.started || s.written != "" {
111+ return
112+ }
113+ t.say("Back to the agent.")
114+ t.next()
115+}
116+
117+// parseSkillFlags reads /skillcreate's flags: those of mm skillcreate that
118+// answer a question. --yes, --line, -config and -o have no use here: the
119+// agent file is the project's, the skill goes to its skills directory.
120+func parseSkillFlags(rest string) (skillcreate.Spec, error) {
121+ var spec skillcreate.Spec
122+ args, err := splitArgs(rest)
123+ if err != nil {
124+ return spec, err
125+ }
126+ fs := flag.NewFlagSet("/skillcreate", flag.ContinueOnError)
127+ fs.SetOutput(io.Discard)
128+ fs.StringVar(&spec.Name, "name", "", "")
129+ fs.StringVar(&spec.Description, "description", "", "")
130+ fs.StringVar(&spec.Keywords, "keywords", "", "")
131+ fs.StringVar(&spec.Body, "body", "", "")
132+ if err := fs.Parse(args); err != nil {
133+ return spec, err
134+ }
135+ if fs.NArg() > 0 {
136+ return spec, fmt.Errorf("unexpected %q", fs.Arg(0))
137+ }
138+ return spec, nil
139+}
140+
141+// splitArgs splits a command line on spaces, keeping what is between double
142+// or single quotes together: `--name "Vulcan Salute"` is two arguments.
143+func splitArgs(s string) ([]string, error) {
144+ var out []string
145+ var cur strings.Builder
146+ inWord, quote := false, rune(0)
147+ for _, r := range s {
148+ switch {
149+ case quote != 0:
150+ if r == quote {
151+ quote = 0
152+ continue
153+ }
154+ cur.WriteRune(r)
155+ case r == '"' || r == '\'':
156+ quote, inWord = r, true
157+ case r == ' ' || r == '\t' || r == '\n':
158+ if inWord {
159+ out = append(out, cur.String())
160+ cur.Reset()
161+ inWord = false
162+ }
163+ default:
164+ cur.WriteRune(r)
165+ inWord = true
166+ }
167+ }
168+ if quote != 0 {
169+ return nil, errors.New("a quote is not closed")
170+ }
171+ if inWord {
172+ out = append(out, cur.String())
173+ }
174+ return out, nil
175+}
added internal/acpcreate/skill_test.go +210 -0
new file mode 100644
@@ -0,0 +1,210 @@
1+package acpcreate
2+
3+import (
4+ "bytes"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+
10+ "mm/internal/agentinit"
11+ "mm/internal/config"
12+ "mm/internal/skillcreate"
13+)
14+
15+const skillBody = "Raise your right hand, fingers parted.\nSay: live long and prosper."
16+
17+// mmSkillcreate is the SKILL.md `mm skillcreate` writes with these answers,
18+// in a project laid out the same way (.mm/agent.yaml, skillsDir .mm/skills).
19+func mmSkillcreate(t *testing.T, args ...string) string {
20+ t.Helper()
21+ keepConfig(t)
22+ dir := t.TempDir()
23+ if err := os.MkdirAll(filepath.Join(dir, ".mm"), 0o755); err != nil {
24+ t.Fatal(err)
25+ }
26+ if err := os.WriteFile(filepath.Join(dir, ".mm", "agent.yaml"), []byte("skillsDir: .mm/skills\n"), 0o644); err != nil {
27+ t.Fatal(err)
28+ }
29+ t.Chdir(dir)
30+ var out bytes.Buffer
31+ if code := skillcreate.Main(append(args, "--yes", "--line"), strings.NewReader(""), &out); code != 0 {
32+ t.Fatalf("mm skillcreate: exit %d\n%s", code, out.String())
33+ }
34+ return read(t, filepath.Join(dir, ".mm", "skills", "vulcan-salute", "SKILL.md"))
35+}
36+
37+// /skillcreate in the middle of the agent's questions (ticket 213): the
38+// skill's questions, a multi-line text, the SKILL.md mm skillcreate writes,
39+// read back by mm — then the agent's next question, its answers kept. The
40+// global settings are left alone.
41+func TestSkillcreateConversation(t *testing.T) {
42+ want := mmSkillcreate(t, "--name", "Vulcan Salute", "--description", "Greets the Vulcan way", "--keywords", "vulcan, greeting", "--body", skillBody)
43+ client := &fakeClient{}
44+ h := start(t, []string{"--provider", "llmman", "--base-url", llmmanStub(t), "--project", "yes", "--skills", "yes"}, client, "zed", false)
45+ // The agent file first, so that the skill has a skills directory.
46+ h.say("/start")
47+ h.say("/write")
48+ agentFile := filepath.Join(h.cwd, ".mm", "agent.yaml")
49+ if _, err := os.Stat(agentFile); err != nil {
50+ t.Fatal("no agent file")
51+ }
52+
53+ before := config.Cfg.SkillsDir
54+ if got := h.say(`/skillcreate --name "Vulcan Salute"`); !strings.Contains(got, "Description (one line)?") {
55+ t.Fatalf("the name flag was not taken as given: %q\n%s", got, client.all())
56+ }
57+ if !strings.Contains(client.all(), filepath.Join(h.cwd, ".mm", "skills")) {
58+ t.Errorf("the skills directory is not named:\n%s", client.all())
59+ }
60+ h.say("Greets the Vulcan way")
61+ if got := h.say("vulcan, greeting"); !strings.Contains(got, "What to do") || !strings.Contains(got, "several lines are fine") {
62+ t.Fatalf("the text question: %q", got)
63+ }
64+ if got := h.say("/preview"); !strings.Contains(got, "```markdown") || !strings.Contains(got, "name: vulcan-salute") {
65+ t.Errorf("/preview during a skill: %q", got)
66+ }
67+ end := strings.Join([]string{h.say(skillBody), client.all()}, "\n")
68+ got := read(t, filepath.Join(h.cwd, ".mm", "skills", "vulcan-salute", "SKILL.md"))
69+ if got != want {
70+ t.Errorf("SKILL.md differs from mm skillcreate's:\n--- acp-create\n%s\n--- mm skillcreate\n%s", got, want)
71+ }
72+ if !strings.Contains(got, "fingers parted.\nSay:") {
73+ t.Errorf("the text lost its line break:\n%s", got)
74+ }
75+ for _, s := range []string{"✅ wrote", "✅ mm reads it: vulcan-salute", "Reload Agent"} {
76+ if !strings.Contains(end, s) {
77+ t.Errorf("closing messages lack %q:\n%s", s, end)
78+ }
79+ }
80+ if config.Cfg.SkillsDir != before {
81+ t.Errorf("the global settings changed: skillsDir %q → %q", before, config.Cfg.SkillsDir)
82+ }
83+}
84+
85+// Half way through the agent: the skill, then back to the agent's question,
86+// with the answers given before still there.
87+func TestSkillcreateComesBackToTheAgent(t *testing.T) {
88+ client := &fakeClient{}
89+ h := start(t, []string{"--provider", "llmman", "--base-url", llmmanStub(t)}, client, "x", false)
90+ h.say("/start")
91+ if got := h.say("yes"); !strings.Contains(got, "Model?") { // project: yes
92+ t.Fatalf("after the layout: %q", got)
93+ }
94+ h.say(`/skillcreate --name hello --description "Says hello" --keywords hi`)
95+ if got := h.say("Say hello."); !strings.Contains(got, "Model?") || !strings.Contains(client.all(), "Back to the agent") {
96+ t.Fatalf("the agent's question did not come back: %q", got)
97+ }
98+ // No agent file yet — nor .mm/, created with it —, but the agent being
99+ // created has the .mm layout: the skill goes to .mm/skills, where it
100+ // will read its skills, and the note says "Use skills?" is still to come.
101+ if _, err := os.Stat(filepath.Join(h.cwd, ".mm", "skills", "hello", "SKILL.md")); err != nil {
102+ t.Errorf("the skill is not in .mm/skills: %v", err)
103+ }
104+ if !strings.Contains(client.all(), `answer yes to "Use skills?"`) {
105+ t.Errorf("no note about the skills question:\n%s", client.all())
106+ }
107+ if got := h.say("/preview"); !strings.Contains(got, ".mm/skills") {
108+ t.Errorf("the agent's answer to the layout was lost: %q", got)
109+ }
110+}
111+
112+// /cancel gives the skill up: nothing written, the agent's question back.
113+// An existing skill asks before it is replaced; "no" asks the name again.
114+func TestSkillcreateCancelAndExisting(t *testing.T) {
115+ client := &fakeClient{}
116+ h := start(t, []string{"--provider", "llmman", "--base-url", llmmanStub(t)}, client, "x", false)
117+ h.say("/start")
118+ h.say(`/skillcreate --name hello`)
119+ if got := h.say("/cancel"); !strings.Contains(got, ".mm folder?") || !strings.Contains(client.all(), "nothing written") {
120+ t.Errorf("/cancel: %q", got)
121+ }
122+ if _, err := os.Stat(filepath.Join(h.cwd, "skills")); err == nil {
123+ t.Error("/cancel wrote something")
124+ }
125+ if got := h.say("/cancel"); !strings.Contains(got, "No skill") {
126+ t.Errorf("/cancel with no skill: %q", got)
127+ }
128+ h.say(`/skillcreate --name hello --description d --keywords k --body b`)
129+ if _, err := os.Stat(filepath.Join(h.cwd, "skills", "hello", "SKILL.md")); err != nil {
130+ t.Fatalf("the skill was not written in skills/: %v", err)
131+ }
132+ if got := h.say(`/skillcreate --name hello`); !strings.Contains(got, "Replace hello?") {
133+ t.Fatalf("an existing skill was not asked about: %q", got)
134+ }
135+ if got := h.say("no"); !strings.Contains(got, "Skill name?") {
136+ t.Errorf("no: the name is not asked again: %q", got)
137+ }
138+}
139+
140+// With forms: the name, the description and the keywords in one form (no
141+// skill of that name: no question about replacing it); the text is asked
142+// as a message even then.
143+func TestSkillcreateForms(t *testing.T) {
144+ given := map[string]any{"name": "Vulcan Salute", "description": "Greets the Vulcan way", "keywords": "vulcan, greeting"}
145+ client := &fakeClient{answer: func(key string, _ map[string]any) any { return given[key] }}
146+ h := start(t, []string{"--provider", "llmman", "--base-url", llmmanStub(t)}, client, "x", true)
147+ got := h.say("/skillcreate")
148+ if !strings.Contains(got, "What to do") {
149+ t.Fatalf("the text is not asked as a message: %q", got)
150+ }
151+ if len(client.forms) != 1 || strings.Join(client.forms[0], ",") != "description,keywords,name" {
152+ t.Errorf("forms = %v, want one with description, keywords and name", client.forms)
153+ }
154+ h.say(skillBody)
155+ text := read(t, filepath.Join(h.cwd, "skills", "vulcan-salute", "SKILL.md"))
156+ if !strings.Contains(text, "description: Greets the Vulcan way") || !strings.Contains(text, "fingers parted.\nSay:") {
157+ t.Errorf("SKILL.md:\n%s", text)
158+ }
159+}
160+
161+// The agent being created decides where a skill made before its file goes
162+// (pendingAgentPlace): its layout and its skills answer, once given.
163+func TestPendingAgentPlace(t *testing.T) {
164+ yes, no := true, false
165+ cases := []struct {
166+ name string
167+ c agentinit.Choices
168+ ok bool
169+ dir string
170+ wantNote string
171+ }{
172+ {"layout not answered: LocateIn decides", agentinit.Choices{}, false, "", ""},
173+ {".mm layout, skills on", agentinit.Choices{Project: &yes, Skills: &yes}, true, ".mm/skills", ""},
174+ {".mm layout, skills not asked yet", agentinit.Choices{Project: &yes}, true, ".mm/skills", "Use skills?"},
175+ {".mm layout, no skills", agentinit.Choices{Project: &yes, Skills: &no}, true, ".mm/skills", "no-skills"},
176+ {"one agent.yaml, skills on", agentinit.Choices{Project: &no, Skills: &yes}, true, "skills", ""},
177+ }
178+ for _, c := range cases {
179+ place, ok := pendingAgentPlace("/p", c.c)
180+ if ok != c.ok {
181+ t.Errorf("%s: ok = %v", c.name, ok)
182+ continue
183+ }
184+ if !ok {
185+ continue
186+ }
187+ if place.Dir != filepath.Join("/p", c.dir) || (c.wantNote == "") != (place.Note == "") || !strings.Contains(place.Note, c.wantNote) {
188+ t.Errorf("%s: %+v", c.name, place)
189+ }
190+ }
191+}
192+
193+func TestSplitArgs(t *testing.T) {
194+ for in, want := range map[string]string{
195+ `--name "Vulcan Salute" --keywords 'a, b'`: `--name|Vulcan Salute|--keywords|a, b`,
196+ ` --name hello `: `--name|hello`,
197+ ``: ``,
198+ } {
199+ got, err := splitArgs(in)
200+ if err != nil || strings.Join(got, "|") != want {
201+ t.Errorf("splitArgs(%q) = %q, %v; want %q", in, got, err, want)
202+ }
203+ }
204+ if _, err := splitArgs(`--name "open`); err == nil {
205+ t.Error("an unclosed quote is accepted")
206+ }
207+ if _, err := parseSkillFlags("--nope x"); err == nil {
208+ t.Error("an unknown flag is accepted")
209+ }
210+}
new file mode 100644
@@ -0,0 +1,210 @@
1+package acpcreate
2+
3+import (
4+ "bytes"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+
10+ "mm/internal/agentinit"
11+ "mm/internal/config"
12+ "mm/internal/skillcreate"
13+)
14+
15+const skillBody = "Raise your right hand, fingers parted.\nSay: live long and prosper."
16+
17+// mmSkillcreate is the SKILL.md `mm skillcreate` writes with these answers,
18+// in a project laid out the same way (.mm/agent.yaml, skillsDir .mm/skills).
19+func mmSkillcreate(t *testing.T, args ...string) string {
20+ t.Helper()
21+ keepConfig(t)
22+ dir := t.TempDir()
23+ if err := os.MkdirAll(filepath.Join(dir, ".mm"), 0o755); err != nil {
24+ t.Fatal(err)
25+ }
26+ if err := os.WriteFile(filepath.Join(dir, ".mm", "agent.yaml"), []byte("skillsDir: .mm/skills\n"), 0o644); err != nil {
27+ t.Fatal(err)
28+ }
29+ t.Chdir(dir)
30+ var out bytes.Buffer
31+ if code := skillcreate.Main(append(args, "--yes", "--line"), strings.NewReader(""), &out); code != 0 {
32+ t.Fatalf("mm skillcreate: exit %d\n%s", code, out.String())
33+ }
34+ return read(t, filepath.Join(dir, ".mm", "skills", "vulcan-salute", "SKILL.md"))
35+}
36+
37+// /skillcreate in the middle of the agent's questions (ticket 213): the
38+// skill's questions, a multi-line text, the SKILL.md mm skillcreate writes,
39+// read back by mm — then the agent's next question, its answers kept. The
40+// global settings are left alone.
41+func TestSkillcreateConversation(t *testing.T) {
42+ want := mmSkillcreate(t, "--name", "Vulcan Salute", "--description", "Greets the Vulcan way", "--keywords", "vulcan, greeting", "--body", skillBody)
43+ client := &fakeClient{}
44+ h := start(t, []string{"--provider", "llmman", "--base-url", llmmanStub(t), "--project", "yes", "--skills", "yes"}, client, "zed", false)
45+ // The agent file first, so that the skill has a skills directory.
46+ h.say("/start")
47+ h.say("/write")
48+ agentFile := filepath.Join(h.cwd, ".mm", "agent.yaml")
49+ if _, err := os.Stat(agentFile); err != nil {
50+ t.Fatal("no agent file")
51+ }
52+
53+ before := config.Cfg.SkillsDir
54+ if got := h.say(`/skillcreate --name "Vulcan Salute"`); !strings.Contains(got, "Description (one line)?") {
55+ t.Fatalf("the name flag was not taken as given: %q\n%s", got, client.all())
56+ }
57+ if !strings.Contains(client.all(), filepath.Join(h.cwd, ".mm", "skills")) {
58+ t.Errorf("the skills directory is not named:\n%s", client.all())
59+ }
60+ h.say("Greets the Vulcan way")
61+ if got := h.say("vulcan, greeting"); !strings.Contains(got, "What to do") || !strings.Contains(got, "several lines are fine") {
62+ t.Fatalf("the text question: %q", got)
63+ }
64+ if got := h.say("/preview"); !strings.Contains(got, "```markdown") || !strings.Contains(got, "name: vulcan-salute") {
65+ t.Errorf("/preview during a skill: %q", got)
66+ }
67+ end := strings.Join([]string{h.say(skillBody), client.all()}, "\n")
68+ got := read(t, filepath.Join(h.cwd, ".mm", "skills", "vulcan-salute", "SKILL.md"))
69+ if got != want {
70+ t.Errorf("SKILL.md differs from mm skillcreate's:\n--- acp-create\n%s\n--- mm skillcreate\n%s", got, want)
71+ }
72+ if !strings.Contains(got, "fingers parted.\nSay:") {
73+ t.Errorf("the text lost its line break:\n%s", got)
74+ }
75+ for _, s := range []string{"✅ wrote", "✅ mm reads it: vulcan-salute", "Reload Agent"} {
76+ if !strings.Contains(end, s) {
77+ t.Errorf("closing messages lack %q:\n%s", s, end)
78+ }
79+ }
80+ if config.Cfg.SkillsDir != before {
81+ t.Errorf("the global settings changed: skillsDir %q → %q", before, config.Cfg.SkillsDir)
82+ }
83+}
84+
85+// Half way through the agent: the skill, then back to the agent's question,
86+// with the answers given before still there.
87+func TestSkillcreateComesBackToTheAgent(t *testing.T) {
88+ client := &fakeClient{}
89+ h := start(t, []string{"--provider", "llmman", "--base-url", llmmanStub(t)}, client, "x", false)
90+ h.say("/start")
91+ if got := h.say("yes"); !strings.Contains(got, "Model?") { // project: yes
92+ t.Fatalf("after the layout: %q", got)
93+ }
94+ h.say(`/skillcreate --name hello --description "Says hello" --keywords hi`)
95+ if got := h.say("Say hello."); !strings.Contains(got, "Model?") || !strings.Contains(client.all(), "Back to the agent") {
96+ t.Fatalf("the agent's question did not come back: %q", got)
97+ }
98+ // No agent file yet — nor .mm/, created with it —, but the agent being
99+ // created has the .mm layout: the skill goes to .mm/skills, where it
100+ // will read its skills, and the note says "Use skills?" is still to come.
101+ if _, err := os.Stat(filepath.Join(h.cwd, ".mm", "skills", "hello", "SKILL.md")); err != nil {
102+ t.Errorf("the skill is not in .mm/skills: %v", err)
103+ }
104+ if !strings.Contains(client.all(), `answer yes to "Use skills?"`) {
105+ t.Errorf("no note about the skills question:\n%s", client.all())
106+ }
107+ if got := h.say("/preview"); !strings.Contains(got, ".mm/skills") {
108+ t.Errorf("the agent's answer to the layout was lost: %q", got)
109+ }
110+}
111+
112+// /cancel gives the skill up: nothing written, the agent's question back.
113+// An existing skill asks before it is replaced; "no" asks the name again.
114+func TestSkillcreateCancelAndExisting(t *testing.T) {
115+ client := &fakeClient{}
116+ h := start(t, []string{"--provider", "llmman", "--base-url", llmmanStub(t)}, client, "x", false)
117+ h.say("/start")
118+ h.say(`/skillcreate --name hello`)
119+ if got := h.say("/cancel"); !strings.Contains(got, ".mm folder?") || !strings.Contains(client.all(), "nothing written") {
120+ t.Errorf("/cancel: %q", got)
121+ }
122+ if _, err := os.Stat(filepath.Join(h.cwd, "skills")); err == nil {
123+ t.Error("/cancel wrote something")
124+ }
125+ if got := h.say("/cancel"); !strings.Contains(got, "No skill") {
126+ t.Errorf("/cancel with no skill: %q", got)
127+ }
128+ h.say(`/skillcreate --name hello --description d --keywords k --body b`)
129+ if _, err := os.Stat(filepath.Join(h.cwd, "skills", "hello", "SKILL.md")); err != nil {
130+ t.Fatalf("the skill was not written in skills/: %v", err)
131+ }
132+ if got := h.say(`/skillcreate --name hello`); !strings.Contains(got, "Replace hello?") {
133+ t.Fatalf("an existing skill was not asked about: %q", got)
134+ }
135+ if got := h.say("no"); !strings.Contains(got, "Skill name?") {
136+ t.Errorf("no: the name is not asked again: %q", got)
137+ }
138+}
139+
140+// With forms: the name, the description and the keywords in one form (no
141+// skill of that name: no question about replacing it); the text is asked
142+// as a message even then.
143+func TestSkillcreateForms(t *testing.T) {
144+ given := map[string]any{"name": "Vulcan Salute", "description": "Greets the Vulcan way", "keywords": "vulcan, greeting"}
145+ client := &fakeClient{answer: func(key string, _ map[string]any) any { return given[key] }}
146+ h := start(t, []string{"--provider", "llmman", "--base-url", llmmanStub(t)}, client, "x", true)
147+ got := h.say("/skillcreate")
148+ if !strings.Contains(got, "What to do") {
149+ t.Fatalf("the text is not asked as a message: %q", got)
150+ }
151+ if len(client.forms) != 1 || strings.Join(client.forms[0], ",") != "description,keywords,name" {
152+ t.Errorf("forms = %v, want one with description, keywords and name", client.forms)
153+ }
154+ h.say(skillBody)
155+ text := read(t, filepath.Join(h.cwd, "skills", "vulcan-salute", "SKILL.md"))
156+ if !strings.Contains(text, "description: Greets the Vulcan way") || !strings.Contains(text, "fingers parted.\nSay:") {
157+ t.Errorf("SKILL.md:\n%s", text)
158+ }
159+}
160+
161+// The agent being created decides where a skill made before its file goes
162+// (pendingAgentPlace): its layout and its skills answer, once given.
163+func TestPendingAgentPlace(t *testing.T) {
164+ yes, no := true, false
165+ cases := []struct {
166+ name string
167+ c agentinit.Choices
168+ ok bool
169+ dir string
170+ wantNote string
171+ }{
172+ {"layout not answered: LocateIn decides", agentinit.Choices{}, false, "", ""},
173+ {".mm layout, skills on", agentinit.Choices{Project: &yes, Skills: &yes}, true, ".mm/skills", ""},
174+ {".mm layout, skills not asked yet", agentinit.Choices{Project: &yes}, true, ".mm/skills", "Use skills?"},
175+ {".mm layout, no skills", agentinit.Choices{Project: &yes, Skills: &no}, true, ".mm/skills", "no-skills"},
176+ {"one agent.yaml, skills on", agentinit.Choices{Project: &no, Skills: &yes}, true, "skills", ""},
177+ }
178+ for _, c := range cases {
179+ place, ok := pendingAgentPlace("/p", c.c)
180+ if ok != c.ok {
181+ t.Errorf("%s: ok = %v", c.name, ok)
182+ continue
183+ }
184+ if !ok {
185+ continue
186+ }
187+ if place.Dir != filepath.Join("/p", c.dir) || (c.wantNote == "") != (place.Note == "") || !strings.Contains(place.Note, c.wantNote) {
188+ t.Errorf("%s: %+v", c.name, place)
189+ }
190+ }
191+}
192+
193+func TestSplitArgs(t *testing.T) {
194+ for in, want := range map[string]string{
195+ `--name "Vulcan Salute" --keywords 'a, b'`: `--name|Vulcan Salute|--keywords|a, b`,
196+ ` --name hello `: `--name|hello`,
197+ ``: ``,
198+ } {
199+ got, err := splitArgs(in)
200+ if err != nil || strings.Join(got, "|") != want {
201+ t.Errorf("splitArgs(%q) = %q, %v; want %q", in, got, err, want)
202+ }
203+ }
204+ if _, err := splitArgs(`--name "open`); err == nil {
205+ t.Error("an unclosed quote is accepted")
206+ }
207+ if _, err := parseSkillFlags("--nope x"); err == nil {
208+ t.Error("an unknown flag is accepted")
209+ }
210+}
added internal/acpcreate/wizard.go +58 -0
new file mode 100644
@@ -0,0 +1,58 @@
1+package acpcreate
2+
3+import (
4+ "mm/internal/agentinit"
5+)
6+
7+// wizard is the questions a session asks: mm init's (agentinit.Flow) or mm
8+// skillcreate's (skillcreate.Flow, ticket 213). Both expose them the same
9+// way, as agentinit.Steps, so that the conversation, the forms, /back and
10+// /write work for either.
11+type wizard interface {
12+ Steps() []agentinit.Step
13+ Pending() []agentinit.Step
14+ Step(key string) agentinit.Step
15+ Set(key, value string) error
16+ Unset(key string)
17+}
18+
19+var (
20+ _ wizard = (*agentinit.Flow)(nil)
21+)
22+
23+// run is one wizard being asked in a session: its questions, where the
24+// conversation stands, and what to do once nothing is left to ask.
25+type run struct {
26+ w wizard
27+ // asked is the question shown in the chat and waiting for the next
28+ // prompt (conversation mode, or a multi-line text in form mode); ""
29+ // when none.
30+ asked string
31+ // answered is the keys answered here, in order — what /back takes back.
32+ answered []string
33+ // deps says which answers each question is built from (batch).
34+ deps map[string][]string
35+ // finish writes the file once every question is answered.
36+ finish func(t *turn)
37+ // preview is the file as it stands, ready to show (/preview).
38+ preview func() string
39+}
40+
41+// agentRun is the run of mm init's questions on flow.
42+func agentRun(flow *agentinit.Flow) run {
43+ return run{
44+ w: flow,
45+ deps: agentDeps,
46+ finish: func(t *turn) { t.write() },
47+ preview: func() string { return "```yaml\n" + flow.Preview("", "") + "```" },
48+ }
49+}
50+
51+// cur is the run the session asks now: the skill being created, if any,
52+// else the agent's.
53+func (s *session) cur() *run {
54+ if s.skill != nil {
55+ return s.skill
56+ }
57+ return &s.agent
58+}
new file mode 100644
@@ -0,0 +1,58 @@
1+package acpcreate
2+
3+import (
4+ "mm/internal/agentinit"
5+)
6+
7+// wizard is the questions a session asks: mm init's (agentinit.Flow) or mm
8+// skillcreate's (skillcreate.Flow, ticket 213). Both expose them the same
9+// way, as agentinit.Steps, so that the conversation, the forms, /back and
10+// /write work for either.
11+type wizard interface {
12+ Steps() []agentinit.Step
13+ Pending() []agentinit.Step
14+ Step(key string) agentinit.Step
15+ Set(key, value string) error
16+ Unset(key string)
17+}
18+
19+var (
20+ _ wizard = (*agentinit.Flow)(nil)
21+)
22+
23+// run is one wizard being asked in a session: its questions, where the
24+// conversation stands, and what to do once nothing is left to ask.
25+type run struct {
26+ w wizard
27+ // asked is the question shown in the chat and waiting for the next
28+ // prompt (conversation mode, or a multi-line text in form mode); ""
29+ // when none.
30+ asked string
31+ // answered is the keys answered here, in order — what /back takes back.
32+ answered []string
33+ // deps says which answers each question is built from (batch).
34+ deps map[string][]string
35+ // finish writes the file once every question is answered.
36+ finish func(t *turn)
37+ // preview is the file as it stands, ready to show (/preview).
38+ preview func() string
39+}
40+
41+// agentRun is the run of mm init's questions on flow.
42+func agentRun(flow *agentinit.Flow) run {
43+ return run{
44+ w: flow,
45+ deps: agentDeps,
46+ finish: func(t *turn) { t.write() },
47+ preview: func() string { return "```yaml\n" + flow.Preview("", "") + "```" },
48+ }
49+}
50+
51+// cur is the run the session asks now: the skill being created, if any,
52+// else the agent's.
53+func (s *session) cur() *run {
54+ if s.skill != nil {
55+ return s.skill
56+ }
57+ return &s.agent
58+}
modified internal/agentinit/flow.go +5 -0
@@ -572,6 +572,11 @@ func (f *Flow) Preview(key, candidate string) string {
572572 return Render(Decide(f.presets, c, f.modelFacts()))
573573 }
574574
575+// Choices is the answers so far, as they stand — unlike Result, it asks
576+// the server nothing. mm acp-create's /skillcreate reads the layout and the
577+// skills answer of an agent not written yet from it (ticket 213).
578+func (f *Flow) Choices() Choices { return f.c }
579+
575580 // Result is the answers, the facts and the file to write, once every
576581 // question is answered.
577582 func (f *Flow) Result() (Choices, Facts, string) {
@@ -572,6 +572,11 @@ func (f *Flow) Preview(key, candidate string) string {
572 return Render(Decide(f.presets, c, f.modelFacts()))572 return Render(Decide(f.presets, c, f.modelFacts()))
573 }573 }
574 574
575+// Choices is the answers so far, as they stand — unlike Result, it asks
576+// the server nothing. mm acp-create's /skillcreate reads the layout and the
577+// skills answer of an agent not written yet from it (ticket 213).
578+func (f *Flow) Choices() Choices { return f.c }
579+
575 // Result is the answers, the facts and the file to write, once every580 // Result is the answers, the facts and the file to write, once every
576 // question is answered.581 // question is answered.
577 func (f *Flow) Result() (Choices, Facts, string) {582 func (f *Flow) Result() (Choices, Facts, string) {
added internal/skillcreate/locatein_test.go +78 -0
new file mode 100644
@@ -0,0 +1,78 @@
1+package skillcreate
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+
9+ "mm/internal/config"
10+)
11+
12+// LocateIn (ticket 213) finds the skills directory of the project in dir as
13+// mm reads it there — agent.yaml, then .mm/agent.yaml, its skillsDir from
14+// the project root — and never loads the file into the global settings.
15+func TestLocateIn(t *testing.T) {
16+ write := func(t *testing.T, dir, path, text string) {
17+ t.Helper()
18+ full := filepath.Join(dir, path)
19+ if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
20+ t.Fatal(err)
21+ }
22+ if err := os.WriteFile(full, []byte(text), 0o644); err != nil {
23+ t.Fatal(err)
24+ }
25+ }
26+ cases := []struct {
27+ name string
28+ files map[string]string
29+ mkdir string
30+ wantDir string
31+ wantNote string
32+ }{
33+ {"a .mm project with skills", map[string]string{".mm/agent.yaml": "skillsDir: .mm/skills\n"}, "", ".mm/skills", ""},
34+ {"agent.yaml wins over .mm/agent.yaml", map[string]string{"agent.yaml": "skillsDir: mine\n", ".mm/agent.yaml": "skillsDir: .mm/skills\n"}, "", "mine", ""},
35+ {"no skillsDir key: mm's default", map[string]string{"agent.yaml": "model: m\n"}, "", "skills", ""},
36+ {"skillsDir: no-skills in a .mm project", map[string]string{".mm/agent.yaml": "skillsDir: no-skills\n"}, "", ".mm/skills", "set skillsDir: .mm/skills"},
37+ {"no agent file, a .mm folder", nil, ".mm", ".mm/skills", "no agent file in this project"},
38+ {"no agent file, nothing", nil, "", "skills", "no agent file in this project"},
39+ }
40+ for _, c := range cases {
41+ t.Run(c.name, func(t *testing.T) {
42+ before := config.Cfg.SkillsDir
43+ dir := t.TempDir()
44+ for p, text := range c.files {
45+ write(t, dir, p, text)
46+ }
47+ if c.mkdir != "" {
48+ if err := os.MkdirAll(filepath.Join(dir, c.mkdir), 0o755); err != nil {
49+ t.Fatal(err)
50+ }
51+ }
52+ place, err := LocateIn(dir)
53+ if err != nil {
54+ t.Fatal(err)
55+ }
56+ if place.Dir != filepath.Join(dir, c.wantDir) {
57+ t.Errorf("Dir = %s, want %s", place.Dir, filepath.Join(dir, c.wantDir))
58+ }
59+ if (c.wantNote == "") != (place.Note == "") || !strings.Contains(place.Note, c.wantNote) {
60+ t.Errorf("Note = %q, want one with %q", place.Note, c.wantNote)
61+ }
62+ if config.Cfg.SkillsDir != before {
63+ t.Errorf("the global settings changed: skillsDir %q → %q", before, config.Cfg.SkillsDir)
64+ }
65+ })
66+ }
67+}
68+
69+// configDefaultSkillsDir is read when the test binary starts, before any
70+// test loads a file into config.Cfg: it is the package default.
71+var configDefaultSkillsDir = config.Cfg.SkillsDir
72+
73+// LocateIn's default is mm's: the package default of internal/config.
74+func TestDefaultSkillsDirIsConfigs(t *testing.T) {
75+ if configDefaultSkillsDir != defaultSkillsDir {
76+ t.Errorf("config's default skillsDir is %q, LocateIn's %q", configDefaultSkillsDir, defaultSkillsDir)
77+ }
78+}
new file mode 100644
@@ -0,0 +1,78 @@
1+package skillcreate
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+
9+ "mm/internal/config"
10+)
11+
12+// LocateIn (ticket 213) finds the skills directory of the project in dir as
13+// mm reads it there — agent.yaml, then .mm/agent.yaml, its skillsDir from
14+// the project root — and never loads the file into the global settings.
15+func TestLocateIn(t *testing.T) {
16+ write := func(t *testing.T, dir, path, text string) {
17+ t.Helper()
18+ full := filepath.Join(dir, path)
19+ if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
20+ t.Fatal(err)
21+ }
22+ if err := os.WriteFile(full, []byte(text), 0o644); err != nil {
23+ t.Fatal(err)
24+ }
25+ }
26+ cases := []struct {
27+ name string
28+ files map[string]string
29+ mkdir string
30+ wantDir string
31+ wantNote string
32+ }{
33+ {"a .mm project with skills", map[string]string{".mm/agent.yaml": "skillsDir: .mm/skills\n"}, "", ".mm/skills", ""},
34+ {"agent.yaml wins over .mm/agent.yaml", map[string]string{"agent.yaml": "skillsDir: mine\n", ".mm/agent.yaml": "skillsDir: .mm/skills\n"}, "", "mine", ""},
35+ {"no skillsDir key: mm's default", map[string]string{"agent.yaml": "model: m\n"}, "", "skills", ""},
36+ {"skillsDir: no-skills in a .mm project", map[string]string{".mm/agent.yaml": "skillsDir: no-skills\n"}, "", ".mm/skills", "set skillsDir: .mm/skills"},
37+ {"no agent file, a .mm folder", nil, ".mm", ".mm/skills", "no agent file in this project"},
38+ {"no agent file, nothing", nil, "", "skills", "no agent file in this project"},
39+ }
40+ for _, c := range cases {
41+ t.Run(c.name, func(t *testing.T) {
42+ before := config.Cfg.SkillsDir
43+ dir := t.TempDir()
44+ for p, text := range c.files {
45+ write(t, dir, p, text)
46+ }
47+ if c.mkdir != "" {
48+ if err := os.MkdirAll(filepath.Join(dir, c.mkdir), 0o755); err != nil {
49+ t.Fatal(err)
50+ }
51+ }
52+ place, err := LocateIn(dir)
53+ if err != nil {
54+ t.Fatal(err)
55+ }
56+ if place.Dir != filepath.Join(dir, c.wantDir) {
57+ t.Errorf("Dir = %s, want %s", place.Dir, filepath.Join(dir, c.wantDir))
58+ }
59+ if (c.wantNote == "") != (place.Note == "") || !strings.Contains(place.Note, c.wantNote) {
60+ t.Errorf("Note = %q, want one with %q", place.Note, c.wantNote)
61+ }
62+ if config.Cfg.SkillsDir != before {
63+ t.Errorf("the global settings changed: skillsDir %q → %q", before, config.Cfg.SkillsDir)
64+ }
65+ })
66+ }
67+}
68+
69+// configDefaultSkillsDir is read when the test binary starts, before any
70+// test loads a file into config.Cfg: it is the package default.
71+var configDefaultSkillsDir = config.Cfg.SkillsDir
72+
73+// LocateIn's default is mm's: the package default of internal/config.
74+func TestDefaultSkillsDirIsConfigs(t *testing.T) {
75+ if configDefaultSkillsDir != defaultSkillsDir {
76+ t.Errorf("config's default skillsDir is %q, LocateIn's %q", configDefaultSkillsDir, defaultSkillsDir)
77+ }
78+}
modified internal/skillcreate/main.go +32 -17
@@ -73,7 +73,7 @@ func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int
7373 return 1
7474 }
7575 flow := NewFlow(place, Spec{}, output)
76- if err := flow.setGiven(spec); err != nil {
76+ if err := flow.SetGiven(spec); err != nil {
7777 fmt.Fprintln(stdout, "⚠️ "+err.Error())
7878 return 1
7979 }
@@ -103,9 +103,9 @@ func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int
103103 return finish(flow, stdout)
104104 }
105105
106-// setGiven records the flags' answers, refusing a value no question would
107-// accept.
108-func (f *Flow) setGiven(s Spec) error {
106+// SetGiven records the flags' answers — never asked —, refusing a value no
107+// question would accept. mm acp-create's /skillcreate uses it too.
108+func (f *Flow) SetGiven(s Spec) error {
109109 for _, kv := range [][2]string{{"name", s.Name}, {"description", s.Description}, {"keywords", s.Keywords}, {"body", s.Body}} {
110110 if kv[1] == "" {
111111 continue
@@ -138,33 +138,48 @@ func (f *Flow) takeDefaults() error {
138138
139139 // finish writes the skill the flow describes and reads it back.
140140 func finish(flow *Flow, stdout io.Writer) int {
141+ if _, ok := WriteSkill(flow, stdout); !ok {
142+ return 1
143+ }
144+ _, place, output := flow.Result()
145+ if output == "-" || place.Note != "" {
146+ return 0
147+ }
148+ fmt.Fprintln(stdout, "Next: start mm from this directory — the banner counts it under skills: N")
149+ return 0
150+}
151+
152+// WriteSkill writes the skill the answered flow describes — or prints it,
153+// for "-" —, reads it back with mm's own loader, and says the place's note,
154+// reporting each step on out. It returns the file written and whether
155+// everything went well. mm skillcreate and mm acp-create's /skillcreate
156+// both end with it.
157+func WriteSkill(flow *Flow, out io.Writer) (string, bool) {
141158 spec, place, output := flow.Result()
142159 if output == "-" {
143- fmt.Fprint(stdout, Render(spec))
144- return 0
160+ fmt.Fprint(out, Render(spec))
161+ return output, true
145162 }
146163 path, err := write(flow, spec, place, output)
147164 if err != nil {
148- fmt.Fprintln(stdout, "⚠️ "+err.Error())
149- return 1
165+ fmt.Fprintln(out, "⚠️ "+err.Error())
166+ return "", false
150167 }
151- fmt.Fprintf(stdout, "✅ wrote %s\n", path)
168+ fmt.Fprintf(out, "✅ wrote %s\n", path)
152169 if n := len(KeywordList(spec.Keywords)); n > skills.MaxKeywords {
153- fmt.Fprintf(stdout, "⚠️ %d keywords: only the first %d reach the catalogue — put the most distinctive first\n", n, skills.MaxKeywords)
170+ fmt.Fprintf(out, "⚠️ %d keywords: only the first %d reach the catalogue — put the most distinctive first\n", n, skills.MaxKeywords)
154171 }
155172 if path == File(place.Dir, spec.Name) {
156173 if err := Verify(place.Dir, spec); err != nil {
157- fmt.Fprintln(stdout, "⚠️ "+err.Error())
158- return 1
174+ fmt.Fprintln(out, "⚠️ "+err.Error())
175+ return path, false
159176 }
160- fmt.Fprintf(stdout, "✅ mm reads it: %s — %s\n", Normalize(spec.Name), oneLine(spec.Description))
177+ fmt.Fprintf(out, "✅ mm reads it: %s — %s\n", Normalize(spec.Name), oneLine(spec.Description))
161178 }
162179 if place.Note != "" {
163- fmt.Fprintln(stdout, "⚠️ "+place.Note)
164- return 0
180+ fmt.Fprintln(out, "⚠️ "+place.Note)
165181 }
166- fmt.Fprintln(stdout, "Next: start mm from this directory — the banner counts it under skills: N")
167- return 0
182+ return path, true
168183 }
169184
170185 // write writes the skill where Output says: the skills directory, or the -o
@@ -73,7 +73,7 @@ func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int
73 return 173 return 1
74 }74 }
75 flow := NewFlow(place, Spec{}, output)75 flow := NewFlow(place, Spec{}, output)
76- if err := flow.setGiven(spec); err != nil {76+ if err := flow.SetGiven(spec); err != nil {
77 fmt.Fprintln(stdout, "⚠️ "+err.Error())77 fmt.Fprintln(stdout, "⚠️ "+err.Error())
78 return 178 return 1
79 }79 }
@@ -103,9 +103,9 @@ func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int
103 return finish(flow, stdout)103 return finish(flow, stdout)
104 }104 }
105 105
106-// setGiven records the flags' answers, refusing a value no question would106+// SetGiven records the flags' answers — never asked —, refusing a value no
107-// accept.107+// question would accept. mm acp-create's /skillcreate uses it too.
108-func (f *Flow) setGiven(s Spec) error {108+func (f *Flow) SetGiven(s Spec) error {
109 for _, kv := range [][2]string{{"name", s.Name}, {"description", s.Description}, {"keywords", s.Keywords}, {"body", s.Body}} {109 for _, kv := range [][2]string{{"name", s.Name}, {"description", s.Description}, {"keywords", s.Keywords}, {"body", s.Body}} {
110 if kv[1] == "" {110 if kv[1] == "" {
111 continue111 continue
@@ -138,33 +138,48 @@ func (f *Flow) takeDefaults() error {
138 138
139 // finish writes the skill the flow describes and reads it back.139 // finish writes the skill the flow describes and reads it back.
140 func finish(flow *Flow, stdout io.Writer) int {140 func finish(flow *Flow, stdout io.Writer) int {
141+ if _, ok := WriteSkill(flow, stdout); !ok {
142+ return 1
143+ }
144+ _, place, output := flow.Result()
145+ if output == "-" || place.Note != "" {
146+ return 0
147+ }
148+ fmt.Fprintln(stdout, "Next: start mm from this directory — the banner counts it under skills: N")
149+ return 0
150+}
151+
152+// WriteSkill writes the skill the answered flow describes — or prints it,
153+// for "-" —, reads it back with mm's own loader, and says the place's note,
154+// reporting each step on out. It returns the file written and whether
155+// everything went well. mm skillcreate and mm acp-create's /skillcreate
156+// both end with it.
157+func WriteSkill(flow *Flow, out io.Writer) (string, bool) {
141 spec, place, output := flow.Result()158 spec, place, output := flow.Result()
142 if output == "-" {159 if output == "-" {
143- fmt.Fprint(stdout, Render(spec))160+ fmt.Fprint(out, Render(spec))
144- return 0161+ return output, true
145 }162 }
146 path, err := write(flow, spec, place, output)163 path, err := write(flow, spec, place, output)
147 if err != nil {164 if err != nil {
148- fmt.Fprintln(stdout, "⚠️ "+err.Error())165+ fmt.Fprintln(out, "⚠️ "+err.Error())
149- return 1166+ return "", false
150 }167 }
151- fmt.Fprintf(stdout, "✅ wrote %s\n", path)168+ fmt.Fprintf(out, "✅ wrote %s\n", path)
152 if n := len(KeywordList(spec.Keywords)); n > skills.MaxKeywords {169 if n := len(KeywordList(spec.Keywords)); n > skills.MaxKeywords {
153- fmt.Fprintf(stdout, "⚠️ %d keywords: only the first %d reach the catalogue — put the most distinctive first\n", n, skills.MaxKeywords)170+ fmt.Fprintf(out, "⚠️ %d keywords: only the first %d reach the catalogue — put the most distinctive first\n", n, skills.MaxKeywords)
154 }171 }
155 if path == File(place.Dir, spec.Name) {172 if path == File(place.Dir, spec.Name) {
156 if err := Verify(place.Dir, spec); err != nil {173 if err := Verify(place.Dir, spec); err != nil {
157- fmt.Fprintln(stdout, "⚠️ "+err.Error())174+ fmt.Fprintln(out, "⚠️ "+err.Error())
158- return 1175+ return path, false
159 }176 }
160- fmt.Fprintf(stdout, "✅ mm reads it: %s — %s\n", Normalize(spec.Name), oneLine(spec.Description))177+ fmt.Fprintf(out, "✅ mm reads it: %s — %s\n", Normalize(spec.Name), oneLine(spec.Description))
161 }178 }
162 if place.Note != "" {179 if place.Note != "" {
163- fmt.Fprintln(stdout, "⚠️ "+place.Note)180+ fmt.Fprintln(out, "⚠️ "+place.Note)
164- return 0
165 }181 }
166- fmt.Fprintln(stdout, "Next: start mm from this directory — the banner counts it under skills: N")182+ return path, true
167- return 0
168 }183 }
169 184
170 // write writes the skill where Output says: the skills directory, or the -o185 // write writes the skill where Output says: the skills directory, or the -o
modified internal/skillcreate/place.go +63 -0
@@ -10,6 +10,8 @@ import (
1010
1111 "mm/internal/config"
1212 "mm/internal/skills"
13+
14+ "gopkg.in/yaml.v3"
1315 )
1416
1517 // noSkills is the skillsDir `mm init` writes for an agent without skills.
@@ -54,6 +56,67 @@ func Locate(configPath string) (Place, error) {
5456 return p, nil
5557 }
5658
59+// defaultSkillsDir is mm's skillsDir when an agent file names none — the
60+// default of internal/config (a test keeps them equal). Not read from
61+// config.Cfg: LocateIn must not depend on whatever config a process loaded.
62+const defaultSkillsDir = "skills"
63+
64+// LocateIn is Locate for the project in dir, without loading the agent file
65+// into the global settings and without the process's working directory or
66+// AGENT_CONFIG — what mm acp-create needs (ticket 213): its project is the
67+// session's cwd, and several sessions may run in one process. The agent
68+// file is found as mm finds it in a project, agent.yaml then
69+// .mm/agent.yaml; its skillsDir is read from the project root (the parent
70+// of .mm/ for .mm/agent.yaml, as mm reads it there). Place.Dir and
71+// Place.Config are absolute; Note names the folders as seen from dir.
72+//
73+// place, err := skillcreate.LocateIn("/home/me/project")
74+// // place.Dir == "/home/me/project/.mm/skills" for a project made by mm init --project yes --skills yes
75+func LocateIn(dir string) (Place, error) {
76+ rel, dirName := "", ""
77+ for _, name := range []string{config.DefaultPath, filepath.Join(config.ProjectDir, config.DefaultPath)} {
78+ if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
79+ rel = name
80+ break
81+ }
82+ }
83+ if rel != "" {
84+ data, err := os.ReadFile(filepath.Join(dir, rel))
85+ if err != nil {
86+ return Place{}, err
87+ }
88+ var file struct {
89+ SkillsDir *string `yaml:"skillsDir"`
90+ }
91+ if err := yaml.Unmarshal(data, &file); err != nil {
92+ return Place{}, fmt.Errorf("%s: %v", rel, err)
93+ }
94+ dirName = defaultSkillsDir
95+ if file.SkillsDir != nil {
96+ dirName = *file.SkillsDir
97+ }
98+ }
99+ if rel != "" && dirName != "" && dirName != noSkills {
100+ abs := dirName
101+ if !filepath.IsAbs(abs) {
102+ abs = filepath.Join(dir, abs)
103+ }
104+ return Place{Dir: abs, Config: filepath.Join(dir, rel)}, nil
105+ }
106+ fallback := "skills"
107+ if info, err := os.Stat(filepath.Join(dir, config.ProjectDir)); err == nil && info.IsDir() {
108+ fallback = filepath.Join(config.ProjectDir, "skills")
109+ }
110+ p := Place{Dir: filepath.Join(dir, fallback)}
111+ if rel == "" {
112+ p.Note = fmt.Sprintf("no agent file in this project: an agent sees this skill with skillsDir: %s", fallback)
113+ } else {
114+ p.Config = filepath.Join(dir, rel)
115+ p.Note = fmt.Sprintf("%s has no skills (skillsDir: %s): set skillsDir: %s for the agent to see this skill", rel, dirName, fallback)
116+ }
117+ return p, nil
118+}
119+
57120 // File is the skill's file in dir: one folder per skill.
58121 //
59122 // File(".mm/skills", "Vulcan Salute") // ".mm/skills/vulcan-salute/SKILL.md"
@@ -10,6 +10,8 @@ import (
10 10
11 "mm/internal/config"11 "mm/internal/config"
12 "mm/internal/skills"12 "mm/internal/skills"
13+
14+ "gopkg.in/yaml.v3"
13 )15 )
14 16
15 // noSkills is the skillsDir `mm init` writes for an agent without skills.17 // noSkills is the skillsDir `mm init` writes for an agent without skills.
@@ -54,6 +56,67 @@ func Locate(configPath string) (Place, error) {
54 return p, nil56 return p, nil
55 }57 }
56 58
59+// defaultSkillsDir is mm's skillsDir when an agent file names none — the
60+// default of internal/config (a test keeps them equal). Not read from
61+// config.Cfg: LocateIn must not depend on whatever config a process loaded.
62+const defaultSkillsDir = "skills"
63+
64+// LocateIn is Locate for the project in dir, without loading the agent file
65+// into the global settings and without the process's working directory or
66+// AGENT_CONFIG — what mm acp-create needs (ticket 213): its project is the
67+// session's cwd, and several sessions may run in one process. The agent
68+// file is found as mm finds it in a project, agent.yaml then
69+// .mm/agent.yaml; its skillsDir is read from the project root (the parent
70+// of .mm/ for .mm/agent.yaml, as mm reads it there). Place.Dir and
71+// Place.Config are absolute; Note names the folders as seen from dir.
72+//
73+// place, err := skillcreate.LocateIn("/home/me/project")
74+// // place.Dir == "/home/me/project/.mm/skills" for a project made by mm init --project yes --skills yes
75+func LocateIn(dir string) (Place, error) {
76+ rel, dirName := "", ""
77+ for _, name := range []string{config.DefaultPath, filepath.Join(config.ProjectDir, config.DefaultPath)} {
78+ if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
79+ rel = name
80+ break
81+ }
82+ }
83+ if rel != "" {
84+ data, err := os.ReadFile(filepath.Join(dir, rel))
85+ if err != nil {
86+ return Place{}, err
87+ }
88+ var file struct {
89+ SkillsDir *string `yaml:"skillsDir"`
90+ }
91+ if err := yaml.Unmarshal(data, &file); err != nil {
92+ return Place{}, fmt.Errorf("%s: %v", rel, err)
93+ }
94+ dirName = defaultSkillsDir
95+ if file.SkillsDir != nil {
96+ dirName = *file.SkillsDir
97+ }
98+ }
99+ if rel != "" && dirName != "" && dirName != noSkills {
100+ abs := dirName
101+ if !filepath.IsAbs(abs) {
102+ abs = filepath.Join(dir, abs)
103+ }
104+ return Place{Dir: abs, Config: filepath.Join(dir, rel)}, nil
105+ }
106+ fallback := "skills"
107+ if info, err := os.Stat(filepath.Join(dir, config.ProjectDir)); err == nil && info.IsDir() {
108+ fallback = filepath.Join(config.ProjectDir, "skills")
109+ }
110+ p := Place{Dir: filepath.Join(dir, fallback)}
111+ if rel == "" {
112+ p.Note = fmt.Sprintf("no agent file in this project: an agent sees this skill with skillsDir: %s", fallback)
113+ } else {
114+ p.Config = filepath.Join(dir, rel)
115+ p.Note = fmt.Sprintf("%s has no skills (skillsDir: %s): set skillsDir: %s for the agent to see this skill", rel, dirName, fallback)
116+ }
117+ return p, nil
118+}
119+
57 // File is the skill's file in dir: one folder per skill.120 // File is the skill's file in dir: one folder per skill.
58 //121 //
59 // File(".mm/skills", "Vulcan Salute") // ".mm/skills/vulcan-salute/SKILL.md"122 // File(".mm/skills", "Vulcan Salute") // ".mm/skills/vulcan-salute/SKILL.md"