✨ 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>
e72e039 parent: f81c3b6 modified
.tickets/issues/0213-mm-acp-create-a-skillcreate-command-mm-skillcrea.yaml +89 -9 | @@ -1,12 +1,12 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 213 |
| 3 | 3 | title: "mm acp-create: a /skillcreate command (mm skillcreate from the editor)" |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-11-14T07:25:21.000Z |
| 9 | -updatedAt: 2026-11-14T07:25:21.000Z | |
| 9 | +updatedAt: 2026-11-15T03:49:13.000Z | |
| 10 | 10 | labels: |
| 11 | 11 | - enhancement |
| 12 | 12 | - kind::feature |
| @@ -91,50 +91,130 @@ body: | | ||
| 91 | 91 | tasks: |
| 92 | 92 | - id: 1 |
| 93 | 93 | title: Locate the skills directory for a given project directory, without loading the config into the global settings |
| 94 | - state: open | |
| 94 | + state: closed | |
| 95 | 95 | priority: high |
| 96 | 96 | author: |
| 97 | 97 | name: k33g |
| 98 | 98 | email: ph.charriere@gmail.com |
| 99 | 99 | createdAt: 2026-11-14T07:25:21.000Z |
| 100 | + updatedAt: 2026-11-15T03:02:04.000Z | |
| 101 | + closedAt: 2026-11-15T03:02:04.000Z | |
| 100 | 102 | - id: 2 |
| 101 | 103 | 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 | |
| 103 | 105 | priority: high |
| 104 | 106 | author: |
| 105 | 107 | name: k33g |
| 106 | 108 | email: ph.charriere@gmail.com |
| 107 | 109 | createdAt: 2026-11-14T07:25:21.000Z |
| 110 | + updatedAt: 2026-11-15T03:02:04.000Z | |
| 111 | + closedAt: 2026-11-15T03:02:04.000Z | |
| 108 | 112 | - id: 3 |
| 109 | 113 | title: /skillcreate command - flags as given answers, conversation mode and forms, multi-line text |
| 110 | - state: open | |
| 114 | + state: closed | |
| 111 | 115 | priority: medium |
| 112 | 116 | author: |
| 113 | 117 | name: k33g |
| 114 | 118 | email: ph.charriere@gmail.com |
| 115 | 119 | createdAt: 2026-11-14T07:25:21.000Z |
| 120 | + updatedAt: 2026-11-15T03:02:04.000Z | |
| 121 | + closedAt: 2026-11-15T03:02:04.000Z | |
| 116 | 122 | - id: 4 |
| 117 | 123 | 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 | |
| 119 | 125 | priority: medium |
| 120 | 126 | author: |
| 121 | 127 | name: k33g |
| 122 | 128 | email: ph.charriere@gmail.com |
| 123 | 129 | createdAt: 2026-11-14T07:25:21.000Z |
| 130 | + updatedAt: 2026-11-15T03:02:04.000Z | |
| 131 | + closedAt: 2026-11-15T03:02:04.000Z | |
| 124 | 132 | - id: 5 |
| 125 | 133 | title: Tests with the fake client - same SKILL.md as mm skillcreate with the same answers, both modes |
| 126 | - state: open | |
| 134 | + state: closed | |
| 127 | 135 | priority: medium |
| 128 | 136 | author: |
| 129 | 137 | name: k33g |
| 130 | 138 | email: ph.charriere@gmail.com |
| 131 | 139 | createdAt: 2026-11-14T07:25:21.000Z |
| 140 | + updatedAt: 2026-11-15T03:02:04.000Z | |
| 141 | + closedAt: 2026-11-15T03:02:04.000Z | |
| 132 | 142 | - id: 6 |
| 133 | 143 | title: Docs EN+FR - how-to create-an-agent-from-an-editor, cli.md acp-create section |
| 134 | - state: open | |
| 144 | + state: closed | |
| 135 | 145 | priority: medium |
| 136 | 146 | author: |
| 137 | 147 | name: k33g |
| 138 | 148 | email: ph.charriere@gmail.com |
| 139 | 149 | 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: 213 | 2 | 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: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-11-14T07:25:21.000Z | 8 | createdAt: 2026-11-14T07:25:21.000Z |
| 9 | -updatedAt: 2026-11-14T07:25:21.000Z | 9 | +updatedAt: 2026-11-15T03:49:13.000Z |
| 10 | labels: | 10 | labels: |
| 11 | - enhancement | 11 | - enhancement |
| 12 | - kind::feature | 12 | - kind::feature |
| @@ -91,50 +91,130 @@ body: | | |||
| 91 | tasks: | 91 | tasks: |
| 92 | - id: 1 | 92 | - id: 1 |
| 93 | title: Locate the skills directory for a given project directory, without loading the config into the global settings | 93 | title: Locate the skills directory for a given project directory, without loading the config into the global settings |
| 94 | - state: open | 94 | + state: closed |
| 95 | priority: high | 95 | priority: high |
| 96 | author: | 96 | author: |
| 97 | name: k33g | 97 | name: k33g |
| 98 | email: ph.charriere@gmail.com | 98 | email: ph.charriere@gmail.com |
| 99 | createdAt: 2026-11-14T07:25:21.000Z | 99 | 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: 2 | 102 | - id: 2 |
| 101 | title: Let the acp-create session drive either flow (agent or skill) - one interface over agentinit.Flow and skillcreate.Flow | 103 | 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 |
| 103 | priority: high | 105 | priority: high |
| 104 | author: | 106 | author: |
| 105 | name: k33g | 107 | name: k33g |
| 106 | email: ph.charriere@gmail.com | 108 | email: ph.charriere@gmail.com |
| 107 | createdAt: 2026-11-14T07:25:21.000Z | 109 | 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: 3 | 112 | - id: 3 |
| 109 | title: /skillcreate command - flags as given answers, conversation mode and forms, multi-line text | 113 | title: /skillcreate command - flags as given answers, conversation mode and forms, multi-line text |
| 110 | - state: open | 114 | + state: closed |
| 111 | priority: medium | 115 | priority: medium |
| 112 | author: | 116 | author: |
| 113 | name: k33g | 117 | name: k33g |
| 114 | email: ph.charriere@gmail.com | 118 | email: ph.charriere@gmail.com |
| 115 | createdAt: 2026-11-14T07:25:21.000Z | 119 | 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: 4 | 122 | - id: 4 |
| 117 | title: End message - written path, mm reads it back, the skillsDir note, reload the agent to see the skill | 123 | 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 |
| 119 | priority: medium | 125 | priority: medium |
| 120 | author: | 126 | author: |
| 121 | name: k33g | 127 | name: k33g |
| 122 | email: ph.charriere@gmail.com | 128 | email: ph.charriere@gmail.com |
| 123 | createdAt: 2026-11-14T07:25:21.000Z | 129 | 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: 5 | 132 | - id: 5 |
| 125 | title: Tests with the fake client - same SKILL.md as mm skillcreate with the same answers, both modes | 133 | title: Tests with the fake client - same SKILL.md as mm skillcreate with the same answers, both modes |
| 126 | - state: open | 134 | + state: closed |
| 127 | priority: medium | 135 | priority: medium |
| 128 | author: | 136 | author: |
| 129 | name: k33g | 137 | name: k33g |
| 130 | email: ph.charriere@gmail.com | 138 | email: ph.charriere@gmail.com |
| 131 | createdAt: 2026-11-14T07:25:21.000Z | 139 | 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: 6 | 142 | - id: 6 |
| 133 | title: Docs EN+FR - how-to create-an-agent-from-an-editor, cli.md acp-create section | 143 | title: Docs EN+FR - how-to create-an-agent-from-an-editor, cli.md acp-create section |
| 134 | - state: open | 144 | + state: closed |
| 135 | priority: medium | 145 | priority: medium |
| 136 | author: | 146 | author: |
| 137 | name: k33g | 147 | name: k33g |
| 138 | email: ph.charriere@gmail.com | 148 | email: ph.charriere@gmail.com |
| 139 | createdAt: 2026-11-14T07:25:21.000Z | 149 | 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 | ||
| 45 | 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 | 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 | 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 | 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 | 62 | ## Variations |
| 51 | 63 | |
| 52 | 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"]`. |
| @@ -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 | ## Variations | 62 | ## 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 | ||
| 280 | 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 | 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 | 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 | 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 | 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 | |
| @@ -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 | ||
| 45 | 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 | 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 | 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 | 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 | 62 | ## Variantes |
| 51 | 63 | |
| 52 | 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"]`. |
| @@ -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 | ## Variantes | 62 | ## 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 | ||
| 280 | 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 | 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 | 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 | 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 | 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 | |
| @@ -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) { | ||
| 149 | 149 | return nil, err |
| 150 | 150 | } |
| 151 | 151 | flow.SetDir(cwd) |
| 152 | - return &session{cwd: cwd, flow: flow}, nil | |
| 152 | + return &session{cwd: cwd, flow: flow, agent: agentRun(flow)}, nil | |
| 153 | 153 | } |
| 154 | 154 | |
| 155 | 155 | // 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, err | 149 | return nil, err |
| 150 | } | 150 | } |
| 151 | flow.SetDir(cwd) | 151 | flow.SetDir(cwd) |
| 152 | - return &session{cwd: cwd, flow: flow}, nil | 152 | + 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 — the | 155 | // 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) { | ||
| 563 | 563 | "file,overwrite": "file", |
| 564 | 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 | 567 | t.Errorf("batch(%s) = %s, want %s", in, got, want) |
| 568 | 568 | } |
| 569 | 569 | } |
| @@ -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 { | ||
| 37 | 37 | b.WriteString("\n") |
| 38 | 38 | } |
| 39 | 39 | how := "Type your answer" |
| 40 | - if len(st.Options) > 0 { | |
| 40 | + switch { | |
| 41 | + case len(st.Options) > 0: | |
| 41 | 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 | 52 | how += fmt.Sprintf(" · `.` for the default (`%s`)", st.Default) |
| 45 | 53 | } |
| 46 | 54 | b.WriteString(how + ".") |
| @@ -99,7 +107,7 @@ func parseConfirm(q elicit.Question, text string) (string, error) { | ||
| 99 | 107 | // dependsOn says which answers a question's options or default are built |
| 100 | 108 | // from (agentinit.Flow.Step). A form never holds a question with the one it |
| 101 | 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 | 111 | "endpoint": {"provider"}, |
| 104 | 112 | "keyenv": {"provider"}, |
| 105 | 113 | "model": {"provider", "endpoint", "keyenv"}, |
| @@ -111,13 +119,23 @@ var dependsOn = map[string][]string{ | ||
| 111 | 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 | 128 | // 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 { | |
| 117 | 132 | in := map[string]bool{} |
| 118 | 133 | var out []agentinit.Step |
| 119 | 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 | 139 | if in[dep] { |
| 122 | 140 | return out |
| 123 | 141 | } |
| @@ -133,17 +151,23 @@ func batch(pending []agentinit.Step) []agentinit.Step { | ||
| 133 | 151 | // not loop forever. |
| 134 | 152 | const maxIdleForms = 3 |
| 135 | 153 | |
| 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) { | |
| 139 | 159 | idle := 0 |
| 140 | 160 | for { |
| 141 | - pending := s.flow.Pending() | |
| 161 | + pending := r.w.Pending() | |
| 142 | 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 | 168 | return |
| 145 | 169 | } |
| 146 | - steps := batch(pending) | |
| 170 | + steps := batch(pending, r.deps) | |
| 147 | 171 | answers, status := t.form(steps) |
| 148 | 172 | switch status { |
| 149 | 173 | case elicit.Declined: |
| @@ -153,7 +177,7 @@ func (t *turn) askByForms() { | ||
| 153 | 177 | t.say("The form was closed: nothing written. Send any message to go on.") |
| 154 | 178 | return |
| 155 | 179 | } |
| 156 | - if t.apply(steps, answers) == 0 { | |
| 180 | + if t.apply(r, steps, answers) == 0 { | |
| 157 | 181 | idle++ |
| 158 | 182 | if idle >= maxIdleForms { |
| 159 | 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 | 192 | // apply answers the questions of a form; it reports what was refused and |
| 169 | 193 | // 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 { | |
| 172 | 195 | taken := 0 |
| 173 | 196 | var refused []string |
| 174 | 197 | for i, st := range steps { |
| @@ -179,11 +202,11 @@ func (t *turn) apply(steps []agentinit.Step, answers []elicit.Answer) int { | ||
| 179 | 202 | if strings.TrimSpace(value) == "" { |
| 180 | 203 | 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 | 206 | refused = append(refused, "⚠️ "+err.Error()) |
| 184 | 207 | continue |
| 185 | 208 | } |
| 186 | - s.answered = append(s.answered, st.Key) | |
| 209 | + r.answered = append(r.answered, st.Key) | |
| 187 | 210 | taken++ |
| 188 | 211 | } |
| 189 | 212 | 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 built | 107 | // 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 it | 108 | // 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 first | 128 | // 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.Step | 133 | 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 out | 140 | 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 = 3 | 152 | 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.s | 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) { | ||
| 139 | idle := 0 | 159 | 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 | return | 168 | 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 | return | 178 | 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 and | 192 | // 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 := 0 | 195 | taken := 0 |
| 173 | var refused []string | 196 | 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.Default | 203 | 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 | continue | 207 | 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 ( | ||
| 13 | 13 | "mm/internal/agentinit" |
| 14 | 14 | "mm/internal/dockerinit" |
| 15 | 15 | "mm/internal/sbxinit" |
| 16 | + "mm/internal/skillcreate" | |
| 16 | 17 | |
| 17 | 18 | sdk "github.com/coder/acp-go-sdk" |
| 18 | 19 | ) |
| @@ -25,13 +26,15 @@ type session struct { | ||
| 25 | 26 | flow *agentinit.Flow |
| 26 | 27 | |
| 27 | 28 | 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. | |
| 35 | 38 | written string |
| 36 | 39 | |
| 37 | 40 | cancelMu sync.Mutex |
| @@ -60,6 +63,8 @@ var commands = []struct{ name, description, hint string }{ | ||
| 60 | 63 | {"back", "Take back the last answer", ""}, |
| 61 | 64 | {"write", "Take the default for every question left, then write the file", ""}, |
| 62 | 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 | 68 | {"dockerinit", "Write a Dockerfile and a compose.yaml to run the agent in a container (mm dockerinit)", "[--name <name>] [--agent <file>] [--overwrite]"}, |
| 64 | 69 | {"zedconfig", "Show the Zed settings that start this project's agent: here, in its container, in its Docker Sandbox", ""}, |
| 65 | 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 | 96 | // handle answers one prompt. |
| 92 | 97 | 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) | |
| 95 | 100 | return |
| 96 | 101 | } |
| 97 | 102 | 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 | 111 | switch { |
| 99 | 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 | 114 | return |
| 102 | 115 | case !s.started: |
| 103 | 116 | t.start() |
| 104 | 117 | return |
| 105 | - case s.asked != "": | |
| 118 | + case s.agent.asked != "": | |
| 106 | 119 | if !t.answer(text) { |
| 107 | 120 | return |
| 108 | 121 | } |
| @@ -110,28 +123,39 @@ func (t *turn) handle(text string) { | ||
| 110 | 123 | t.next() |
| 111 | 124 | } |
| 112 | 125 | |
| 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) { | |
| 116 | 130 | text = strings.TrimSpace(text) |
| 117 | 131 | if !strings.HasPrefix(text, "/") { |
| 118 | - return "", nil, false | |
| 132 | + return "", nil, "", false | |
| 119 | 133 | } |
| 120 | 134 | words := strings.Fields(text[1:]) |
| 121 | 135 | if len(words) == 0 { |
| 122 | - return "", nil, false | |
| 136 | + return "", nil, "", false | |
| 123 | 137 | } |
| 124 | 138 | for _, c := range commands { |
| 125 | 139 | 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 | |
| 127 | 141 | } |
| 128 | 142 | } |
| 129 | - return "", nil, false | |
| 143 | + 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 | 147 | s := t.s |
| 134 | 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 | 159 | case "sbxinit": |
| 136 | 160 | t.sbxinit(args) |
| 137 | 161 | case "dockerinit": |
| @@ -139,23 +163,36 @@ func (t *turn) command(name string, args []string) { | ||
| 139 | 163 | case "zedconfig": |
| 140 | 164 | t.say(zedConfig(s.cwd)) |
| 141 | 165 | case "start": |
| 166 | + if s.skill != nil { | |
| 167 | + t.next() // the skill's current question, again | |
| 168 | + return | |
| 169 | + } | |
| 142 | 170 | t.start() |
| 143 | 171 | case "help": |
| 144 | 172 | t.say(help()) |
| 145 | 173 | case "preview": |
| 146 | - t.say("```yaml\n" + s.flow.Preview("", "") + "```") | |
| 174 | + t.say(s.cur().preview()) | |
| 147 | 175 | case "restart": |
| 176 | + if s.skill != nil { | |
| 177 | + t.skillcreate(s.skillArgs) | |
| 178 | + return | |
| 179 | + } | |
| 148 | 180 | fresh, err := t.ag.newSession(s.cwd) |
| 149 | 181 | if err != nil { |
| 150 | 182 | t.say("⚠️ " + err.Error()) |
| 151 | 183 | return |
| 152 | 184 | } |
| 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 | |
| 154 | 186 | t.say("Starting again.") |
| 155 | 187 | t.next() |
| 156 | 188 | case "back": |
| 157 | 189 | t.back() |
| 158 | 190 | case "write": |
| 191 | + if s.skill != nil { | |
| 192 | + t.takeDefaults() | |
| 193 | + t.next() | |
| 194 | + return | |
| 195 | + } | |
| 159 | 196 | if s.written != "" { |
| 160 | 197 | t.say(fmt.Sprintf("`%s` is already written.", s.written)) |
| 161 | 198 | return |
| @@ -253,23 +290,26 @@ func welcome(cwd string) string { | ||
| 253 | 290 | // back takes back the last answer given here and asks it again. |
| 254 | 291 | func (t *turn) back() { |
| 255 | 292 | s := t.s |
| 256 | - if len(s.answered) == 0 { | |
| 293 | + r := s.cur() | |
| 294 | + if len(r.answered) == 0 { | |
| 257 | 295 | t.say("Nothing to take back.") |
| 258 | 296 | return |
| 259 | 297 | } |
| 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 | + } | |
| 264 | 304 | t.next() |
| 265 | 305 | } |
| 266 | 306 | |
| 267 | 307 | // takeDefaults answers every pending question that has a default, as mm |
| 268 | 308 | // init --yes does, but stops at an existing file: replacing it is asked. |
| 269 | 309 | func (t *turn) takeDefaults() { |
| 270 | - s := t.s | |
| 310 | + r := t.s.cur() | |
| 271 | 311 | for { |
| 272 | - pending := s.flow.Pending() | |
| 312 | + pending := r.w.Pending() | |
| 273 | 313 | if len(pending) == 0 { |
| 274 | 314 | return |
| 275 | 315 | } |
| @@ -277,35 +317,39 @@ func (t *turn) takeDefaults() { | ||
| 277 | 317 | if st.Key == "overwrite" || st.Default == "" { |
| 278 | 318 | 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 | 321 | t.say("⚠️ " + err.Error()) |
| 282 | 322 | 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 | 328 | // next asks what is still to ask, or writes the file when nothing is. |
| 289 | 329 | func (t *turn) next() { |
| 290 | - s := t.s | |
| 291 | - s.asked = "" | |
| 330 | + r := t.s.cur() | |
| 331 | + r.asked = "" | |
| 292 | 332 | if t.ag.elicitForm { |
| 293 | - t.askByForms() | |
| 333 | + t.askByForms(r) | |
| 294 | 334 | return |
| 295 | 335 | } |
| 296 | - pending := s.flow.Pending() | |
| 336 | + pending := r.w.Pending() | |
| 297 | 337 | if len(pending) == 0 { |
| 298 | - t.write() | |
| 338 | + r.finish(t) | |
| 299 | 339 | return |
| 300 | 340 | } |
| 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 | |
| 303 | 347 | t.say(question(st, t.position(st.Key))) |
| 304 | 348 | } |
| 305 | 349 | |
| 306 | 350 | // position is "n of N", the question's place among all of them. |
| 307 | 351 | func (t *turn) position(key string) string { |
| 308 | - steps := t.s.flow.Steps() | |
| 352 | + steps := t.s.cur().w.Steps() | |
| 309 | 353 | for i, st := range steps { |
| 310 | 354 | if st.Key == key { |
| 311 | 355 | return fmt.Sprintf("%d of %d", i+1, len(steps)) |
| @@ -317,17 +361,17 @@ func (t *turn) position(key string) string { | ||
| 317 | 361 | // answer reads the prompt as the answer to the question asked; false when |
| 318 | 362 | // it does not answer it (the question is shown again). |
| 319 | 363 | 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) | |
| 322 | 366 | value, err := parseAnswer(st, text) |
| 323 | 367 | if err == nil { |
| 324 | - err = s.flow.Set(st.Key, value) | |
| 368 | + err = r.w.Set(st.Key, value) | |
| 325 | 369 | } |
| 326 | 370 | if err != nil { |
| 327 | 371 | t.say("⚠️ " + err.Error() + "\n\n" + question(st, t.position(st.Key))) |
| 328 | 372 | return false |
| 329 | 373 | } |
| 330 | - s.answered = append(s.answered, st.Key) | |
| 374 | + r.answered = append(r.answered, st.Key) | |
| 331 | 375 | return true |
| 332 | 376 | } |
| 333 | 377 | |
| @@ -345,7 +389,7 @@ func (t *turn) write() { | ||
| 345 | 389 | return |
| 346 | 390 | } |
| 347 | 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 | 395 | 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.Flow | 26 | flow *agentinit.Flow |
| 26 | 27 | ||
| 27 | started bool | 28 | started bool |
| 28 | - // asked is the question shown in the chat and waiting for the next | 29 | + // 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 string | 31 | + agent run |
| 31 | - // answered is the keys answered in this session, in order — what /back | 32 | + skill *run |
| 32 | - // takes back. | 33 | + // skillFlow is the skill run's flow, skillArgs the flags it was given |
| 33 | - answered []string | 34 | + // (/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 string | 38 | written string |
| 36 | 39 | ||
| 37 | cancelMu sync.Mutex | 40 | 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 | return | 100 | return |
| 96 | } | 101 | } |
| 97 | s := t.s | 102 | 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 | return | 114 | return |
| 102 | case !s.started: | 115 | case !s.started: |
| 103 | t.start() | 116 | t.start() |
| 104 | return | 117 | return |
| 105 | - case s.asked != "": | 118 | + case s.agent.asked != "": |
| 106 | if !t.answer(text) { | 119 | if !t.answer(text) { |
| 107 | return | 120 | 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 words | 126 | +// 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, false | 132 | + 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, false | 136 | + 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:], true | 140 | + return c.name, words[1:], strings.TrimSpace(strings.TrimPrefix(text[1:], words[0])), true |
| 127 | } | 141 | } |
| 128 | } | 142 | } |
| 129 | - return "", nil, false | 143 | + 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.s | 147 | 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 | return | 183 | return |
| 152 | } | 184 | } |
| 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 |
| 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 | return | 198 | 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.s | 292 | 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 | return | 296 | 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 mm | 307 | // 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.s | 310 | + 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 | return | 314 | 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 | return | 318 | 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 | return | 322 | 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.s | 330 | + 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 | return | 334 | 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 | return | 339 | return |
| 300 | } | 340 | } |
| 301 | - st := pending[0] | 341 | + t.ask(r, pending[0]) |
| 302 | - s.asked = st.Key | 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 | ||
| 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 when | 361 | // 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.s | 364 | + 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 false | 372 | return false |
| 329 | } | 373 | } |
| 330 | - s.answered = append(s.answered, st.Key) | 374 | + r.answered = append(r.answered, st.Key) |
| 331 | return true | 375 | return true |
| 332 | } | 376 | } |
| 333 | 377 | ||
| @@ -345,7 +389,7 @@ func (t *turn) write() { | |||
| 345 | return | 389 | 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 { | ||
| 572 | 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 | 580 | // Result is the answers, the facts and the file to write, once every |
| 576 | 581 | // question is answered. |
| 577 | 582 | 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 every | 580 | // 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 | ||
| 73 | 73 | return 1 |
| 74 | 74 | } |
| 75 | 75 | flow := NewFlow(place, Spec{}, output) |
| 76 | - if err := flow.setGiven(spec); err != nil { | |
| 76 | + if err := flow.SetGiven(spec); err != nil { | |
| 77 | 77 | fmt.Fprintln(stdout, "⚠️ "+err.Error()) |
| 78 | 78 | return 1 |
| 79 | 79 | } |
| @@ -103,9 +103,9 @@ func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int | ||
| 103 | 103 | return finish(flow, stdout) |
| 104 | 104 | } |
| 105 | 105 | |
| 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 { | |
| 109 | 109 | for _, kv := range [][2]string{{"name", s.Name}, {"description", s.Description}, {"keywords", s.Keywords}, {"body", s.Body}} { |
| 110 | 110 | if kv[1] == "" { |
| 111 | 111 | continue |
| @@ -138,33 +138,48 @@ func (f *Flow) takeDefaults() error { | ||
| 138 | 138 | |
| 139 | 139 | // finish writes the skill the flow describes and reads it back. |
| 140 | 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 | 158 | spec, place, output := flow.Result() |
| 142 | 159 | if output == "-" { |
| 143 | - fmt.Fprint(stdout, Render(spec)) | |
| 144 | - return 0 | |
| 160 | + fmt.Fprint(out, Render(spec)) | |
| 161 | + return output, true | |
| 145 | 162 | } |
| 146 | 163 | path, err := write(flow, spec, place, output) |
| 147 | 164 | if err != nil { |
| 148 | - fmt.Fprintln(stdout, "⚠️ "+err.Error()) | |
| 149 | - return 1 | |
| 165 | + fmt.Fprintln(out, "⚠️ "+err.Error()) | |
| 166 | + return "", false | |
| 150 | 167 | } |
| 151 | - fmt.Fprintf(stdout, "✅ wrote %s\n", path) | |
| 168 | + fmt.Fprintf(out, "✅ wrote %s\n", path) | |
| 152 | 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 | 172 | if path == File(place.Dir, spec.Name) { |
| 156 | 173 | 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 | |
| 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 | 179 | if place.Note != "" { |
| 163 | - fmt.Fprintln(stdout, "⚠️ "+place.Note) | |
| 164 | - return 0 | |
| 180 | + fmt.Fprintln(out, "⚠️ "+place.Note) | |
| 165 | 181 | } |
| 166 | - fmt.Fprintln(stdout, "Next: start mm from this directory — the banner counts it under skills: N") | |
| 167 | - return 0 | |
| 182 | + return path, true | |
| 168 | 183 | } |
| 169 | 184 | |
| 170 | 185 | // 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 1 | 73 | 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 1 | 78 | 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 would | 106 | +// 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 | continue | 111 | 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 0 | 161 | + 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 1 | 166 | + 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 1 | 175 | + 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 -o | 185 | // 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 ( | ||
| 10 | 10 | |
| 11 | 11 | "mm/internal/config" |
| 12 | 12 | "mm/internal/skills" |
| 13 | + | |
| 14 | + "gopkg.in/yaml.v3" | |
| 13 | 15 | ) |
| 14 | 16 | |
| 15 | 17 | // noSkills is the skillsDir `mm init` writes for an agent without skills. |
| @@ -54,6 +56,67 @@ func Locate(configPath string) (Place, error) { | ||
| 54 | 56 | 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 | 120 | // File is the skill's file in dir: one folder per skill. |
| 58 | 121 | // |
| 59 | 122 | // 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, nil | 56 | 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" |