✨ mm acp-create: mm init over ACP (tickets 210, 212)
- mm acp-create / mm --acp-create (internal/acpcreate): mm init's wizard served over the Agent Client Protocol, for Zed or any ACP client. Forms (elicitation/create) when the client advertises them, batched by dependency; one question per message otherwise. Files written in the session's cwd. Commands: /start, /preview, /back, /write, /restart, /dockerinit, /zedconfig, /sbxinit, /help. Reload starts the wizard over. - agentinit: ParseArgs, Args.Flow, WriteFile, Flow.SetDir/Path, reused by the ACP front. - internal/acpwire: the after-session/new hook and the elicitation scope lifting, moved out of internal/acp and shared. - mm acp: loadSession and session/resume always advertised; without saved sessions a reload starts the conversation over and says so. - mm dockerinit knows the .mm layout (ticket 212): agent.yaml then .mm/agent.yaml, .mm/ mounted read-only, sessions/trace writable again (created when missing), mm tui with no file. Shared lookup and .mm description in internal/scaffold/mmfolder.go; dockerinit.In and sbxinit.In/InWith run them for another directory; scaffold.File.Replace. - mm sbxinit prints "Run the sandbox <name>: sbx env run --force". - Docs EN+FR: how-to create-an-agent-from-an-editor, cli/acp/configuration references, container/sandbox/editor how-tos, explanations, README. - Tickets: 210, 212, milestones v1.0.9 and v1.1.0 (ticket 211 moved). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4d19a27 parent: 64b7d7a modified
.gitignore +1 -0 | @@ -26,3 +26,4 @@ bob | ||
| 26 | 26 | bob-the-bot |
| 27 | 27 | bob-the-smoll |
| 28 | 28 | bob-coder |
| 29 | + | |
| @@ -26,3 +26,4 @@ bob | |||
| 26 | bob-the-bot | 26 | bob-the-bot |
| 27 | bob-the-smoll | 27 | bob-the-smoll |
| 28 | bob-coder | 28 | bob-coder |
| 29 | + | ||
modified
.tickets/issues/0210-acp-create-mode-mm-acp-create-mm-init-from-an-acp.yaml +148 -7 | @@ -6,7 +6,7 @@ author: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-11-14T05:41:20.000Z |
| 9 | -updatedAt: 2026-11-14T05:46:20.000Z | |
| 9 | +updatedAt: 2026-11-14T07:05:01.000Z | |
| 10 | 10 | labels: |
| 11 | 11 | - enhancement |
| 12 | 12 | - kind::feature |
| @@ -74,10 +74,21 @@ body: | | ||
| 74 | 74 | |
| 75 | 75 | ## Commands (`available_commands_update`) |
| 76 | 76 | |
| 77 | + - `/start` — start the wizard (any message does too); once started, | |
| 78 | + asks the current question again | |
| 77 | 79 | - `/preview` — show the agent.yaml that would be written (`Flow.Preview`) |
| 78 | 80 | - `/back` — go back one step |
| 79 | 81 | - `/write` — write the file (asks before overwriting, `overwrite` step) |
| 80 | 82 | - `/restart` — start the flow again |
| 83 | + - `/zedconfig` — Zed `agent_servers` entries for the project's agent: | |
| 84 | + mm here always, `docker exec` with a compose.yaml, `sbx exec` with a | |
| 85 | + sbxenv.yaml (added 2026-11-14) | |
| 86 | + - `/dockerinit [--name] [--agent] [--overwrite]` — `mm dockerinit` for | |
| 87 | + the session's project (added 2026-11-14, see #212) | |
| 88 | + - `/sbxinit [--name] [--workspace] [--agent] [--overwrite]` — `mm sbxinit` | |
| 89 | + for the session's project: writes `sbxenv.yaml` in its cwd (added | |
| 90 | + 2026-11-14 at the user's request); an existing file is kept unless | |
| 91 | + `--overwrite`, since nothing can be asked | |
| 81 | 92 | |
| 82 | 93 | ## At the end |
| 83 | 94 | |
| @@ -116,42 +127,172 @@ tasks: | ||
| 116 | 127 | createdAt: 2026-11-14T05:41:20.000Z |
| 117 | 128 | - id: 2 |
| 118 | 129 | title: Entry point mm --acp-create / mm acp-create, before config load |
| 119 | - state: open | |
| 130 | + state: closed | |
| 120 | 131 | priority: medium |
| 121 | 132 | author: |
| 122 | 133 | name: k33g |
| 123 | 134 | email: ph.charriere@gmail.com |
| 124 | 135 | createdAt: 2026-11-14T05:41:20.000Z |
| 136 | + updatedAt: 2026-11-14T05:56:48.000Z | |
| 137 | + closedAt: 2026-11-14T05:56:48.000Z | |
| 125 | 138 | - id: 3 |
| 126 | 139 | title: ACP front driving agentinit.Flow (conversation mode + elicitation form) |
| 127 | - state: open | |
| 140 | + state: closed | |
| 128 | 141 | priority: medium |
| 129 | 142 | author: |
| 130 | 143 | name: k33g |
| 131 | 144 | email: ph.charriere@gmail.com |
| 132 | 145 | createdAt: 2026-11-14T05:41:20.000Z |
| 146 | + updatedAt: 2026-11-14T05:56:48.000Z | |
| 147 | + closedAt: 2026-11-14T05:56:48.000Z | |
| 133 | 148 | - id: 4 |
| 134 | 149 | title: Slash commands /preview /back /write /restart |
| 135 | - state: open | |
| 150 | + state: closed | |
| 136 | 151 | priority: low |
| 137 | 152 | author: |
| 138 | 153 | name: k33g |
| 139 | 154 | email: ph.charriere@gmail.com |
| 140 | 155 | createdAt: 2026-11-14T05:41:20.000Z |
| 156 | + updatedAt: 2026-11-14T05:56:48.000Z | |
| 157 | + closedAt: 2026-11-14T05:56:48.000Z | |
| 141 | 158 | - id: 5 |
| 142 | 159 | title: Write the file in the session cwd, end message with the command line (+ snippet for known clients) |
| 143 | - state: open | |
| 160 | + state: closed | |
| 144 | 161 | priority: medium |
| 145 | 162 | author: |
| 146 | 163 | name: k33g |
| 147 | 164 | email: ph.charriere@gmail.com |
| 148 | 165 | createdAt: 2026-11-14T05:41:20.000Z |
| 166 | + updatedAt: 2026-11-14T05:56:48.000Z | |
| 167 | + closedAt: 2026-11-14T05:56:48.000Z | |
| 149 | 168 | - id: 6 |
| 150 | 169 | title: Tests (fake ACP client, golden vs mm init) and docs EN+FR |
| 151 | - state: open | |
| 170 | + state: closed | |
| 152 | 171 | priority: medium |
| 153 | 172 | author: |
| 154 | 173 | name: k33g |
| 155 | 174 | email: ph.charriere@gmail.com |
| 156 | 175 | createdAt: 2026-11-14T05:41:20.000Z |
| 157 | -comments: [] | |
| 176 | +comments: | |
| 177 | + - id: 1 | |
| 178 | + author: | |
| 179 | + name: k33g | |
| 180 | + email: ph.charriere@gmail.com | |
| 181 | + createdAt: 2026-11-14T05:56:48.000Z | |
| 182 | + body: | | |
| 183 | + Implemented on `feature/acp-create` (uncommitted): `internal/acpcreate` | |
| 184 | + (agent, conversation mode, forms batched by dependency), the shared | |
| 185 | + writer moved to `internal/acpwire`, `agentinit.ParseArgs` / | |
| 186 | + `Args.Flow` / `WriteFile` / `Flow.SetDir`. Tests with a fake ACP | |
| 187 | + client: both modes write byte for byte what `mm init --line` writes. | |
| 188 | + Tried end to end with the real binary over stdio (no config, process | |
| 189 | + started in `/`, file written in the session cwd). | |
| 190 | + | |
| 191 | + Not done: task 1 (what real clients show — needs Zed and another | |
| 192 | + client by hand), the acceptance "tried in two real clients", | |
| 193 | + permission buttons for choices in conversation mode, and session | |
| 194 | + config options. qlty not run (not installed in this sandbox). | |
| 195 | + updatedAt: 2026-11-14T05:56:48.000Z | |
| 196 | + closedAt: 2026-11-14T05:56:48.000Z | |
| 197 | + - id: 2 | |
| 198 | + author: | |
| 199 | + name: k33g | |
| 200 | + email: ph.charriere@gmail.com | |
| 201 | + createdAt: 2026-11-14T06:01:15.000Z | |
| 202 | + body: | | |
| 203 | + ## Trying it in Zed (task 1) | |
| 204 | + | |
| 205 | + The `mm` on the `PATH` must be built from this branch first: | |
| 206 | + | |
| 207 | + ```bash | |
| 208 | + git checkout feature/acp-create && ./install.sh | |
| 209 | + ``` | |
| 210 | + | |
| 211 | + Add to Zed's `settings.json` — no config file and no `AGENT_CONFIG`: | |
| 212 | + this mode reads none. | |
| 213 | + | |
| 214 | + ```json | |
| 215 | + { | |
| 216 | + "agent_servers": { | |
| 217 | + "mm-create": { | |
| 218 | + "type": "custom", | |
| 219 | + "command": "mm", | |
| 220 | + "args": ["acp-create"] | |
| 221 | + } | |
| 222 | + } | |
| 223 | + } | |
| 224 | + ``` | |
| 225 | + | |
| 226 | + Variant with answers fixed in advance (those questions are not asked): | |
| 227 | + | |
| 228 | + ```json | |
| 229 | + "mm-create": { | |
| 230 | + "type": "custom", | |
| 231 | + "command": "mm", | |
| 232 | + "args": ["acp-create", "--provider", "llmman", "--project", "yes"] | |
| 233 | + } | |
| 234 | + ``` | |
| 235 | + | |
| 236 | + ### Use | |
| 237 | + | |
| 238 | + 1. Open the project in Zed. | |
| 239 | + 2. Start a new thread with `mm-create`, send `/start` (or any | |
| 240 | + message): the wizard starts. A welcome message right after the | |
| 241 | + session opens says so, if the client shows messages outside a turn. | |
| 242 | + 3. Answer with a number or a value; `.` takes the default. | |
| 243 | + Commands: `/start`, `/zedconfig`, `/dockerinit`, `/sbxinit`, `/preview`, `/back`, `/write`, `/restart`, `/help`. | |
| 244 | + | |
| 245 | + At the end the wizard shows the entry that starts the created agent, | |
| 246 | + with the real path (named after the project folder; the path ends in | |
| 247 | + `.mm/agent.yaml` with the `.mm` layout), e.g.: | |
| 248 | + | |
| 249 | + ```json | |
| 250 | + "agent_servers": { | |
| 251 | + "mm-my-project": { | |
| 252 | + "type": "custom", | |
| 253 | + "command": "mm", | |
| 254 | + "args": ["acp", "/absolute/path/to/my-project/agent.yaml"] | |
| 255 | + } | |
| 256 | + } | |
| 257 | + ``` | |
| 258 | + | |
| 259 | + Add it next to `mm-create`; that entry is the agent you talk to. | |
| 260 | + | |
| 261 | + ### What to check | |
| 262 | + | |
| 263 | + - Logs: **dev: open acp logs** in Zed. The line | |
| 264 | + `[acp-create] initialize: client "…" elicitation(form)=…` says | |
| 265 | + whether Zed advertises forms — the open question of task 1. | |
| 266 | + - Then the same with a second ACP client (JetBrains, a Neovim plugin…), | |
| 267 | + one with form elicitation if any supports it. | |
| 268 | + - id: 3 | |
| 269 | + author: | |
| 270 | + name: k33g | |
| 271 | + email: ph.charriere@gmail.com | |
| 272 | + createdAt: 2026-11-14T06:44:14.000Z | |
| 273 | + body: | | |
| 274 | + Reload in Zed failed: "Loading or resuming sessions is not supported | |
| 275 | + by this agent" (same cause as #8 for `mm acp`). Fixed by advertising | |
| 276 | + `loadSession` and `session/resume`: both register a **fresh** wizard | |
| 277 | + under the requested id and say it starts over (user's choice — start | |
| 278 | + from zero; rejected: persisting the answers in the user cache and | |
| 279 | + replaying them). `internal/acpcreate/restart.go`, test | |
| 280 | + `TestReloadStartsOver`, docs EN+FR. | |
| 281 | + - id: 4 | |
| 282 | + author: | |
| 283 | + name: k33g | |
| 284 | + email: ph.charriere@gmail.com | |
| 285 | + createdAt: 2026-11-14T06:56:56.000Z | |
| 286 | + body: | | |
| 287 | + Same reload failure for agents run with `mm acp` when | |
| 288 | + `sessions.enabled` is off (the default of `mm init`): `loadSession` | |
| 289 | + was advertised only with saved sessions (decision of #8, "honest | |
| 290 | + capabilities"). Changed at the user's request: `loadSession` and | |
| 291 | + `session/resume` are now always advertised; without saved sessions | |
| 292 | + the requested id becomes a new, empty session and one message says the | |
| 293 | + conversation starts over and how to keep it (`sessions.enabled`). | |
| 294 | + `session/list` still exists only with saved sessions. With saved | |
| 295 | + sessions nothing changed (an unknown id is still invalid params). | |
| 296 | + `internal/acp/persist.go` (`startOver`), tests | |
| 297 | + `TestReloadWithoutSessionsStartsOver`, docs `reference/acp.md` and | |
| 298 | + `how-to/use-from-an-editor.md` EN+FR. | |
| @@ -6,7 +6,7 @@ author: | |||
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-11-14T05:41:20.000Z | 8 | createdAt: 2026-11-14T05:41:20.000Z |
| 9 | -updatedAt: 2026-11-14T05:46:20.000Z | 9 | +updatedAt: 2026-11-14T07:05:01.000Z |
| 10 | labels: | 10 | labels: |
| 11 | - enhancement | 11 | - enhancement |
| 12 | - kind::feature | 12 | - kind::feature |
| @@ -74,10 +74,21 @@ body: | | |||
| 74 | 74 | ||
| 75 | ## Commands (`available_commands_update`) | 75 | ## Commands (`available_commands_update`) |
| 76 | 76 | ||
| 77 | + - `/start` — start the wizard (any message does too); once started, | ||
| 78 | + asks the current question again | ||
| 77 | - `/preview` — show the agent.yaml that would be written (`Flow.Preview`) | 79 | - `/preview` — show the agent.yaml that would be written (`Flow.Preview`) |
| 78 | - `/back` — go back one step | 80 | - `/back` — go back one step |
| 79 | - `/write` — write the file (asks before overwriting, `overwrite` step) | 81 | - `/write` — write the file (asks before overwriting, `overwrite` step) |
| 80 | - `/restart` — start the flow again | 82 | - `/restart` — start the flow again |
| 83 | + - `/zedconfig` — Zed `agent_servers` entries for the project's agent: | ||
| 84 | + mm here always, `docker exec` with a compose.yaml, `sbx exec` with a | ||
| 85 | + sbxenv.yaml (added 2026-11-14) | ||
| 86 | + - `/dockerinit [--name] [--agent] [--overwrite]` — `mm dockerinit` for | ||
| 87 | + the session's project (added 2026-11-14, see #212) | ||
| 88 | + - `/sbxinit [--name] [--workspace] [--agent] [--overwrite]` — `mm sbxinit` | ||
| 89 | + for the session's project: writes `sbxenv.yaml` in its cwd (added | ||
| 90 | + 2026-11-14 at the user's request); an existing file is kept unless | ||
| 91 | + `--overwrite`, since nothing can be asked | ||
| 81 | 92 | ||
| 82 | ## At the end | 93 | ## At the end |
| 83 | 94 | ||
| @@ -116,42 +127,172 @@ tasks: | |||
| 116 | createdAt: 2026-11-14T05:41:20.000Z | 127 | createdAt: 2026-11-14T05:41:20.000Z |
| 117 | - id: 2 | 128 | - id: 2 |
| 118 | title: Entry point mm --acp-create / mm acp-create, before config load | 129 | title: Entry point mm --acp-create / mm acp-create, before config load |
| 119 | - state: open | 130 | + state: closed |
| 120 | priority: medium | 131 | priority: medium |
| 121 | author: | 132 | author: |
| 122 | name: k33g | 133 | name: k33g |
| 123 | email: ph.charriere@gmail.com | 134 | email: ph.charriere@gmail.com |
| 124 | createdAt: 2026-11-14T05:41:20.000Z | 135 | createdAt: 2026-11-14T05:41:20.000Z |
| 136 | + updatedAt: 2026-11-14T05:56:48.000Z | ||
| 137 | + closedAt: 2026-11-14T05:56:48.000Z | ||
| 125 | - id: 3 | 138 | - id: 3 |
| 126 | title: ACP front driving agentinit.Flow (conversation mode + elicitation form) | 139 | title: ACP front driving agentinit.Flow (conversation mode + elicitation form) |
| 127 | - state: open | 140 | + state: closed |
| 128 | priority: medium | 141 | priority: medium |
| 129 | author: | 142 | author: |
| 130 | name: k33g | 143 | name: k33g |
| 131 | email: ph.charriere@gmail.com | 144 | email: ph.charriere@gmail.com |
| 132 | createdAt: 2026-11-14T05:41:20.000Z | 145 | createdAt: 2026-11-14T05:41:20.000Z |
| 146 | + updatedAt: 2026-11-14T05:56:48.000Z | ||
| 147 | + closedAt: 2026-11-14T05:56:48.000Z | ||
| 133 | - id: 4 | 148 | - id: 4 |
| 134 | title: Slash commands /preview /back /write /restart | 149 | title: Slash commands /preview /back /write /restart |
| 135 | - state: open | 150 | + state: closed |
| 136 | priority: low | 151 | priority: low |
| 137 | author: | 152 | author: |
| 138 | name: k33g | 153 | name: k33g |
| 139 | email: ph.charriere@gmail.com | 154 | email: ph.charriere@gmail.com |
| 140 | createdAt: 2026-11-14T05:41:20.000Z | 155 | createdAt: 2026-11-14T05:41:20.000Z |
| 156 | + updatedAt: 2026-11-14T05:56:48.000Z | ||
| 157 | + closedAt: 2026-11-14T05:56:48.000Z | ||
| 141 | - id: 5 | 158 | - id: 5 |
| 142 | title: Write the file in the session cwd, end message with the command line (+ snippet for known clients) | 159 | title: Write the file in the session cwd, end message with the command line (+ snippet for known clients) |
| 143 | - state: open | 160 | + state: closed |
| 144 | priority: medium | 161 | priority: medium |
| 145 | author: | 162 | author: |
| 146 | name: k33g | 163 | name: k33g |
| 147 | email: ph.charriere@gmail.com | 164 | email: ph.charriere@gmail.com |
| 148 | createdAt: 2026-11-14T05:41:20.000Z | 165 | createdAt: 2026-11-14T05:41:20.000Z |
| 166 | + updatedAt: 2026-11-14T05:56:48.000Z | ||
| 167 | + closedAt: 2026-11-14T05:56:48.000Z | ||
| 149 | - id: 6 | 168 | - id: 6 |
| 150 | title: Tests (fake ACP client, golden vs mm init) and docs EN+FR | 169 | title: Tests (fake ACP client, golden vs mm init) and docs EN+FR |
| 151 | - state: open | 170 | + state: closed |
| 152 | priority: medium | 171 | priority: medium |
| 153 | author: | 172 | author: |
| 154 | name: k33g | 173 | name: k33g |
| 155 | email: ph.charriere@gmail.com | 174 | email: ph.charriere@gmail.com |
| 156 | createdAt: 2026-11-14T05:41:20.000Z | 175 | createdAt: 2026-11-14T05:41:20.000Z |
| 157 | -comments: [] | 176 | +comments: |
| 177 | + - id: 1 | ||
| 178 | + author: | ||
| 179 | + name: k33g | ||
| 180 | + email: ph.charriere@gmail.com | ||
| 181 | + createdAt: 2026-11-14T05:56:48.000Z | ||
| 182 | + body: | | ||
| 183 | + Implemented on `feature/acp-create` (uncommitted): `internal/acpcreate` | ||
| 184 | + (agent, conversation mode, forms batched by dependency), the shared | ||
| 185 | + writer moved to `internal/acpwire`, `agentinit.ParseArgs` / | ||
| 186 | + `Args.Flow` / `WriteFile` / `Flow.SetDir`. Tests with a fake ACP | ||
| 187 | + client: both modes write byte for byte what `mm init --line` writes. | ||
| 188 | + Tried end to end with the real binary over stdio (no config, process | ||
| 189 | + started in `/`, file written in the session cwd). | ||
| 190 | + | ||
| 191 | + Not done: task 1 (what real clients show — needs Zed and another | ||
| 192 | + client by hand), the acceptance "tried in two real clients", | ||
| 193 | + permission buttons for choices in conversation mode, and session | ||
| 194 | + config options. qlty not run (not installed in this sandbox). | ||
| 195 | + updatedAt: 2026-11-14T05:56:48.000Z | ||
| 196 | + closedAt: 2026-11-14T05:56:48.000Z | ||
| 197 | + - id: 2 | ||
| 198 | + author: | ||
| 199 | + name: k33g | ||
| 200 | + email: ph.charriere@gmail.com | ||
| 201 | + createdAt: 2026-11-14T06:01:15.000Z | ||
| 202 | + body: | | ||
| 203 | + ## Trying it in Zed (task 1) | ||
| 204 | + | ||
| 205 | + The `mm` on the `PATH` must be built from this branch first: | ||
| 206 | + | ||
| 207 | + ```bash | ||
| 208 | + git checkout feature/acp-create && ./install.sh | ||
| 209 | + ``` | ||
| 210 | + | ||
| 211 | + Add to Zed's `settings.json` — no config file and no `AGENT_CONFIG`: | ||
| 212 | + this mode reads none. | ||
| 213 | + | ||
| 214 | + ```json | ||
| 215 | + { | ||
| 216 | + "agent_servers": { | ||
| 217 | + "mm-create": { | ||
| 218 | + "type": "custom", | ||
| 219 | + "command": "mm", | ||
| 220 | + "args": ["acp-create"] | ||
| 221 | + } | ||
| 222 | + } | ||
| 223 | + } | ||
| 224 | + ``` | ||
| 225 | + | ||
| 226 | + Variant with answers fixed in advance (those questions are not asked): | ||
| 227 | + | ||
| 228 | + ```json | ||
| 229 | + "mm-create": { | ||
| 230 | + "type": "custom", | ||
| 231 | + "command": "mm", | ||
| 232 | + "args": ["acp-create", "--provider", "llmman", "--project", "yes"] | ||
| 233 | + } | ||
| 234 | + ``` | ||
| 235 | + | ||
| 236 | + ### Use | ||
| 237 | + | ||
| 238 | + 1. Open the project in Zed. | ||
| 239 | + 2. Start a new thread with `mm-create`, send `/start` (or any | ||
| 240 | + message): the wizard starts. A welcome message right after the | ||
| 241 | + session opens says so, if the client shows messages outside a turn. | ||
| 242 | + 3. Answer with a number or a value; `.` takes the default. | ||
| 243 | + Commands: `/start`, `/zedconfig`, `/dockerinit`, `/sbxinit`, `/preview`, `/back`, `/write`, `/restart`, `/help`. | ||
| 244 | + | ||
| 245 | + At the end the wizard shows the entry that starts the created agent, | ||
| 246 | + with the real path (named after the project folder; the path ends in | ||
| 247 | + `.mm/agent.yaml` with the `.mm` layout), e.g.: | ||
| 248 | + | ||
| 249 | + ```json | ||
| 250 | + "agent_servers": { | ||
| 251 | + "mm-my-project": { | ||
| 252 | + "type": "custom", | ||
| 253 | + "command": "mm", | ||
| 254 | + "args": ["acp", "/absolute/path/to/my-project/agent.yaml"] | ||
| 255 | + } | ||
| 256 | + } | ||
| 257 | + ``` | ||
| 258 | + | ||
| 259 | + Add it next to `mm-create`; that entry is the agent you talk to. | ||
| 260 | + | ||
| 261 | + ### What to check | ||
| 262 | + | ||
| 263 | + - Logs: **dev: open acp logs** in Zed. The line | ||
| 264 | + `[acp-create] initialize: client "…" elicitation(form)=…` says | ||
| 265 | + whether Zed advertises forms — the open question of task 1. | ||
| 266 | + - Then the same with a second ACP client (JetBrains, a Neovim plugin…), | ||
| 267 | + one with form elicitation if any supports it. | ||
| 268 | + - id: 3 | ||
| 269 | + author: | ||
| 270 | + name: k33g | ||
| 271 | + email: ph.charriere@gmail.com | ||
| 272 | + createdAt: 2026-11-14T06:44:14.000Z | ||
| 273 | + body: | | ||
| 274 | + Reload in Zed failed: "Loading or resuming sessions is not supported | ||
| 275 | + by this agent" (same cause as #8 for `mm acp`). Fixed by advertising | ||
| 276 | + `loadSession` and `session/resume`: both register a **fresh** wizard | ||
| 277 | + under the requested id and say it starts over (user's choice — start | ||
| 278 | + from zero; rejected: persisting the answers in the user cache and | ||
| 279 | + replaying them). `internal/acpcreate/restart.go`, test | ||
| 280 | + `TestReloadStartsOver`, docs EN+FR. | ||
| 281 | + - id: 4 | ||
| 282 | + author: | ||
| 283 | + name: k33g | ||
| 284 | + email: ph.charriere@gmail.com | ||
| 285 | + createdAt: 2026-11-14T06:56:56.000Z | ||
| 286 | + body: | | ||
| 287 | + Same reload failure for agents run with `mm acp` when | ||
| 288 | + `sessions.enabled` is off (the default of `mm init`): `loadSession` | ||
| 289 | + was advertised only with saved sessions (decision of #8, "honest | ||
| 290 | + capabilities"). Changed at the user's request: `loadSession` and | ||
| 291 | + `session/resume` are now always advertised; without saved sessions | ||
| 292 | + the requested id becomes a new, empty session and one message says the | ||
| 293 | + conversation starts over and how to keep it (`sessions.enabled`). | ||
| 294 | + `session/list` still exists only with saved sessions. With saved | ||
| 295 | + sessions nothing changed (an unknown id is still invalid params). | ||
| 296 | + `internal/acp/persist.go` (`startOver`), tests | ||
| 297 | + `TestReloadWithoutSessionsStartsOver`, docs `reference/acp.md` and | ||
| 298 | + `how-to/use-from-an-editor.md` EN+FR. | ||
added
.tickets/issues/0211-check-if-llamacpp-1-0-0-is-working.yaml +15 -0 | new file mode 100644 | ||
| @@ -0,0 +1,15 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 211 | |
| 3 | +title: Check if llamacpp 1.0.0 is working | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-11-14T05:56:02.153Z | |
| 9 | +updatedAt: 2026-11-14T06:35:10.000Z | |
| 10 | +labels: [] | |
| 11 | +milestone: v1.1.0 | |
| 12 | +epic: Providers (LLM Engine) | |
| 13 | +body: "" | |
| 14 | +tasks: [] | |
| 15 | +comments: [] | |
| new file mode 100644 | |||
| @@ -0,0 +1,15 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 211 | ||
| 3 | +title: Check if llamacpp 1.0.0 is working | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-11-14T05:56:02.153Z | ||
| 9 | +updatedAt: 2026-11-14T06:35:10.000Z | ||
| 10 | +labels: [] | ||
| 11 | +milestone: v1.1.0 | ||
| 12 | +epic: Providers (LLM Engine) | ||
| 13 | +body: "" | ||
| 14 | +tasks: [] | ||
| 15 | +comments: [] | ||
added
.tickets/issues/0212-mm-dockerinit-know-the-mm-folder-project-layout.yaml +163 -0 | new file mode 100644 | ||
| @@ -0,0 +1,163 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 212 | |
| 3 | +title: "mm dockerinit: know the .mm folder (Project layout), as mm sbxinit does" | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-11-14T06:29:41.000Z | |
| 9 | +updatedAt: 2026-11-14T06:37:57.000Z | |
| 10 | +labels: | |
| 11 | + - enhancement | |
| 12 | + - kind::feature | |
| 13 | +milestone: v1.0.9 | |
| 14 | +epic: Scaffolding | |
| 15 | +related: | |
| 16 | + - 208 | |
| 17 | +body: | | |
| 18 | + `mm sbxinit` knows whether the project uses the `.mm` folder (the Project | |
| 19 | + layout of `mm init --project yes`, ticket 205) since | |
| 20 | + [#208 — mm sbxinit: know the .mm folder](.tickets/issues/0208-mm-sbxinit-know-the-mm-folder.yaml). | |
| 21 | + `mm dockerinit` ([internal/dockerinit](internal/dockerinit/main.go)) does | |
| 22 | + not: it should behave the same way. | |
| 23 | + | |
| 24 | + ## Today (read 2026-11-14) | |
| 25 | + | |
| 26 | + - `--agent` defaults to `agent.yaml` and nothing else is looked for: in a | |
| 27 | + `.mm` project the service is named `mm` instead of the agent's | |
| 28 | + `acp.name`, and the closing message says | |
| 29 | + `No agent.yaml here yet: write one with mm init.` | |
| 30 | + - [compose.yaml](internal/dockerinit/files.go) comments and the closing | |
| 31 | + message (`printNext`) start `mm tui agent.yaml` — wrong file for a | |
| 32 | + `.mm` project. | |
| 33 | + - The project is mounted read-write as a whole (`.:${PWD:-/workspace}`): | |
| 34 | + the agent can change its own settings, skills and hooks in `.mm/`. | |
| 35 | + | |
| 36 | + ## Wanted — the same rules as mm sbxinit | |
| 37 | + | |
| 38 | + - **Agent lookup**: without `--agent`, `agent.yaml`, then `.mm/agent.yaml` | |
| 39 | + (the order mm follows, ticket 204); with `--agent`, that file only. | |
| 40 | + The service is named after the `acp.name` of the file found. | |
| 41 | + - **Plain project** (`agent.yaml`): output unchanged, byte for byte. | |
| 42 | + - **`.mm` project**: | |
| 43 | + - `.mm/` mounted **read-only** over the project | |
| 44 | + (`- ./.mm:${PWD:-/workspace}/.mm:ro`), and the folders mm writes in | |
| 45 | + — `sessions.dir`, `trace.dir`, when on and under `.mm/` — mounted | |
| 46 | + writable again, as `MMFolder` computes them for sbxinit | |
| 47 | + ([internal/sbxinit/mmfolder.go](internal/sbxinit/mmfolder.go)). | |
| 48 | + Relative paths, so `compose.yaml` can be committed. | |
| 49 | + - commands without a file: `mm tui` / `mm acp` — the image always holds | |
| 50 | + the latest release (≥ 1.0.7), which finds `.mm/agent.yaml` from the | |
| 51 | + working directory. In the compose comments and in the closing message. | |
| 52 | + - a comment in `compose.yaml` saying why `.mm/` is read-only, and that | |
| 53 | + anything else written under `.mm/` (a hook's log) needs its own line. | |
| 54 | + - **Shared code**: the agent-file lookup (`findAgentFile`) and the `.mm` | |
| 55 | + description (`mmFolderOf`, `Writable`, `rel`) move where both commands | |
| 56 | + use them (e.g. `internal/scaffold`), instead of a second copy. | |
| 57 | + | |
| 58 | + ## To check | |
| 59 | + | |
| 60 | + - Nested bind mounts in compose: `.mm:ro` inside the project volume, and | |
| 61 | + `.mm/sessions` writable inside `.mm:ro` — verify with Docker (Desktop | |
| 62 | + and Linux) that the most specific mount wins and the files belong to | |
| 63 | + `MM_UID`. | |
| 64 | + - A `.mm/sessions` folder that does not exist yet: Docker creates the | |
| 65 | + mount point as root — create it before (`mm dockerinit` could), or say | |
| 66 | + so. | |
| 67 | + | |
| 68 | + ## Out of scope / follow-up | |
| 69 | + | |
| 70 | + - A `/dockerinit` command in `mm acp-create`, like `/sbxinit` | |
| 71 | + ([#210](.tickets/issues/0210-acp-create-mode-mm-acp-create-mm-init-from-an-acp.yaml)): | |
| 72 | + easy once `dockerinit` takes a base directory as `sbxinit.In` does. | |
| 73 | + | |
| 74 | + ## Acceptance criteria | |
| 75 | + | |
| 76 | + - In a project made with `mm init --project yes`, `mm dockerinit` with no | |
| 77 | + flag names the service after the agent, writes the read-only `.mm` | |
| 78 | + mount and the writable folders, and says `mm tui` with no argument. | |
| 79 | + - In a plain project, the files are the same as today. | |
| 80 | + - Tests for both; docs EN+FR. | |
| 81 | +tasks: | |
| 82 | + - id: 1 | |
| 83 | + title: Share the agent-file lookup and the .mm description between sbxinit and dockerinit | |
| 84 | + state: closed | |
| 85 | + priority: medium | |
| 86 | + author: | |
| 87 | + name: k33g | |
| 88 | + email: ph.charriere@gmail.com | |
| 89 | + createdAt: 2026-11-14T06:29:41.000Z | |
| 90 | + updatedAt: 2026-11-14T06:37:57.000Z | |
| 91 | + closedAt: 2026-11-14T06:37:57.000Z | |
| 92 | + - id: 2 | |
| 93 | + title: Find the agent as mm does - agent.yaml, then .mm/agent.yaml - and name the service from it | |
| 94 | + state: closed | |
| 95 | + priority: medium | |
| 96 | + author: | |
| 97 | + name: k33g | |
| 98 | + email: ph.charriere@gmail.com | |
| 99 | + createdAt: 2026-11-14T06:29:41.000Z | |
| 100 | + updatedAt: 2026-11-14T06:37:57.000Z | |
| 101 | + closedAt: 2026-11-14T06:37:57.000Z | |
| 102 | + - id: 3 | |
| 103 | + title: compose.yaml for a .mm project - .mm/ read-only, sessions.dir / trace.dir writable again | |
| 104 | + state: closed | |
| 105 | + priority: high | |
| 106 | + author: | |
| 107 | + name: k33g | |
| 108 | + email: ph.charriere@gmail.com | |
| 109 | + createdAt: 2026-11-14T06:29:41.000Z | |
| 110 | + updatedAt: 2026-11-14T06:37:57.000Z | |
| 111 | + closedAt: 2026-11-14T06:37:57.000Z | |
| 112 | + - id: 4 | |
| 113 | + title: Commands without a file argument for a .mm project (compose comments and closing message) | |
| 114 | + state: closed | |
| 115 | + priority: medium | |
| 116 | + author: | |
| 117 | + name: k33g | |
| 118 | + email: ph.charriere@gmail.com | |
| 119 | + createdAt: 2026-11-14T06:29:41.000Z | |
| 120 | + updatedAt: 2026-11-14T06:37:57.000Z | |
| 121 | + closedAt: 2026-11-14T06:37:57.000Z | |
| 122 | + - id: 5 | |
| 123 | + title: Tests - .mm project, plain project unchanged (same output as today) | |
| 124 | + state: closed | |
| 125 | + priority: medium | |
| 126 | + author: | |
| 127 | + name: k33g | |
| 128 | + email: ph.charriere@gmail.com | |
| 129 | + createdAt: 2026-11-14T06:29:41.000Z | |
| 130 | + updatedAt: 2026-11-14T06:37:57.000Z | |
| 131 | + closedAt: 2026-11-14T06:37:57.000Z | |
| 132 | + - id: 6 | |
| 133 | + title: Docs EN+FR - run-in-a-container.md (a .mm section), cli.md (--agent row) | |
| 134 | + state: closed | |
| 135 | + priority: medium | |
| 136 | + author: | |
| 137 | + name: k33g | |
| 138 | + email: ph.charriere@gmail.com | |
| 139 | + createdAt: 2026-11-14T06:29:41.000Z | |
| 140 | +comments: | |
| 141 | + - id: 1 | |
| 142 | + author: | |
| 143 | + name: k33g | |
| 144 | + email: ph.charriere@gmail.com | |
| 145 | + createdAt: 2026-11-14T06:37:57.000Z | |
| 146 | + body: | | |
| 147 | + Implemented on `feature/acp-create` (uncommitted). Shared code in | |
| 148 | + `internal/scaffold/mmfolder.go` (`MMFolder`, `MMFolderOf`, `Rel`, | |
| 149 | + `FindAgentFile`); sbxinit keeps aliases. `dockerinit.In(dir, …)` for | |
| 150 | + `/dockerinit` in mm acp-create (#210). Missing writable folders are | |
| 151 | + created by `mm dockerinit`. Plain project: `compose.yaml` byte for byte | |
| 152 | + as before (test). | |
| 153 | + | |
| 154 | + Checked with Docker 29.9.3 / Compose v5.7.1 on Linux, real | |
| 155 | + `compose.yaml`, image swapped for alpine: mountinfo shows the project | |
| 156 | + `rw`, `.mm` `ro`, `.mm/sessions` `rw`; writing in `.mm/` fails, | |
| 157 | + in `.mm/sessions` works. Not checked on Docker Desktop (macOS). | |
| 158 | + | |
| 159 | + Also asked in the same session: `mm sbxinit` now prints | |
| 160 | + `Run the sandbox <name>: sbx env run --force` (and the line in the | |
| 161 | + sbxenv.yaml comments). | |
| 162 | + updatedAt: 2026-11-14T06:37:57.000Z | |
| 163 | + closedAt: 2026-11-14T06:37:57.000Z | |
| new file mode 100644 | |||
| @@ -0,0 +1,163 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 212 | ||
| 3 | +title: "mm dockerinit: know the .mm folder (Project layout), as mm sbxinit does" | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-11-14T06:29:41.000Z | ||
| 9 | +updatedAt: 2026-11-14T06:37:57.000Z | ||
| 10 | +labels: | ||
| 11 | + - enhancement | ||
| 12 | + - kind::feature | ||
| 13 | +milestone: v1.0.9 | ||
| 14 | +epic: Scaffolding | ||
| 15 | +related: | ||
| 16 | + - 208 | ||
| 17 | +body: | | ||
| 18 | + `mm sbxinit` knows whether the project uses the `.mm` folder (the Project | ||
| 19 | + layout of `mm init --project yes`, ticket 205) since | ||
| 20 | + [#208 — mm sbxinit: know the .mm folder](.tickets/issues/0208-mm-sbxinit-know-the-mm-folder.yaml). | ||
| 21 | + `mm dockerinit` ([internal/dockerinit](internal/dockerinit/main.go)) does | ||
| 22 | + not: it should behave the same way. | ||
| 23 | + | ||
| 24 | + ## Today (read 2026-11-14) | ||
| 25 | + | ||
| 26 | + - `--agent` defaults to `agent.yaml` and nothing else is looked for: in a | ||
| 27 | + `.mm` project the service is named `mm` instead of the agent's | ||
| 28 | + `acp.name`, and the closing message says | ||
| 29 | + `No agent.yaml here yet: write one with mm init.` | ||
| 30 | + - [compose.yaml](internal/dockerinit/files.go) comments and the closing | ||
| 31 | + message (`printNext`) start `mm tui agent.yaml` — wrong file for a | ||
| 32 | + `.mm` project. | ||
| 33 | + - The project is mounted read-write as a whole (`.:${PWD:-/workspace}`): | ||
| 34 | + the agent can change its own settings, skills and hooks in `.mm/`. | ||
| 35 | + | ||
| 36 | + ## Wanted — the same rules as mm sbxinit | ||
| 37 | + | ||
| 38 | + - **Agent lookup**: without `--agent`, `agent.yaml`, then `.mm/agent.yaml` | ||
| 39 | + (the order mm follows, ticket 204); with `--agent`, that file only. | ||
| 40 | + The service is named after the `acp.name` of the file found. | ||
| 41 | + - **Plain project** (`agent.yaml`): output unchanged, byte for byte. | ||
| 42 | + - **`.mm` project**: | ||
| 43 | + - `.mm/` mounted **read-only** over the project | ||
| 44 | + (`- ./.mm:${PWD:-/workspace}/.mm:ro`), and the folders mm writes in | ||
| 45 | + — `sessions.dir`, `trace.dir`, when on and under `.mm/` — mounted | ||
| 46 | + writable again, as `MMFolder` computes them for sbxinit | ||
| 47 | + ([internal/sbxinit/mmfolder.go](internal/sbxinit/mmfolder.go)). | ||
| 48 | + Relative paths, so `compose.yaml` can be committed. | ||
| 49 | + - commands without a file: `mm tui` / `mm acp` — the image always holds | ||
| 50 | + the latest release (≥ 1.0.7), which finds `.mm/agent.yaml` from the | ||
| 51 | + working directory. In the compose comments and in the closing message. | ||
| 52 | + - a comment in `compose.yaml` saying why `.mm/` is read-only, and that | ||
| 53 | + anything else written under `.mm/` (a hook's log) needs its own line. | ||
| 54 | + - **Shared code**: the agent-file lookup (`findAgentFile`) and the `.mm` | ||
| 55 | + description (`mmFolderOf`, `Writable`, `rel`) move where both commands | ||
| 56 | + use them (e.g. `internal/scaffold`), instead of a second copy. | ||
| 57 | + | ||
| 58 | + ## To check | ||
| 59 | + | ||
| 60 | + - Nested bind mounts in compose: `.mm:ro` inside the project volume, and | ||
| 61 | + `.mm/sessions` writable inside `.mm:ro` — verify with Docker (Desktop | ||
| 62 | + and Linux) that the most specific mount wins and the files belong to | ||
| 63 | + `MM_UID`. | ||
| 64 | + - A `.mm/sessions` folder that does not exist yet: Docker creates the | ||
| 65 | + mount point as root — create it before (`mm dockerinit` could), or say | ||
| 66 | + so. | ||
| 67 | + | ||
| 68 | + ## Out of scope / follow-up | ||
| 69 | + | ||
| 70 | + - A `/dockerinit` command in `mm acp-create`, like `/sbxinit` | ||
| 71 | + ([#210](.tickets/issues/0210-acp-create-mode-mm-acp-create-mm-init-from-an-acp.yaml)): | ||
| 72 | + easy once `dockerinit` takes a base directory as `sbxinit.In` does. | ||
| 73 | + | ||
| 74 | + ## Acceptance criteria | ||
| 75 | + | ||
| 76 | + - In a project made with `mm init --project yes`, `mm dockerinit` with no | ||
| 77 | + flag names the service after the agent, writes the read-only `.mm` | ||
| 78 | + mount and the writable folders, and says `mm tui` with no argument. | ||
| 79 | + - In a plain project, the files are the same as today. | ||
| 80 | + - Tests for both; docs EN+FR. | ||
| 81 | +tasks: | ||
| 82 | + - id: 1 | ||
| 83 | + title: Share the agent-file lookup and the .mm description between sbxinit and dockerinit | ||
| 84 | + state: closed | ||
| 85 | + priority: medium | ||
| 86 | + author: | ||
| 87 | + name: k33g | ||
| 88 | + email: ph.charriere@gmail.com | ||
| 89 | + createdAt: 2026-11-14T06:29:41.000Z | ||
| 90 | + updatedAt: 2026-11-14T06:37:57.000Z | ||
| 91 | + closedAt: 2026-11-14T06:37:57.000Z | ||
| 92 | + - id: 2 | ||
| 93 | + title: Find the agent as mm does - agent.yaml, then .mm/agent.yaml - and name the service from it | ||
| 94 | + state: closed | ||
| 95 | + priority: medium | ||
| 96 | + author: | ||
| 97 | + name: k33g | ||
| 98 | + email: ph.charriere@gmail.com | ||
| 99 | + createdAt: 2026-11-14T06:29:41.000Z | ||
| 100 | + updatedAt: 2026-11-14T06:37:57.000Z | ||
| 101 | + closedAt: 2026-11-14T06:37:57.000Z | ||
| 102 | + - id: 3 | ||
| 103 | + title: compose.yaml for a .mm project - .mm/ read-only, sessions.dir / trace.dir writable again | ||
| 104 | + state: closed | ||
| 105 | + priority: high | ||
| 106 | + author: | ||
| 107 | + name: k33g | ||
| 108 | + email: ph.charriere@gmail.com | ||
| 109 | + createdAt: 2026-11-14T06:29:41.000Z | ||
| 110 | + updatedAt: 2026-11-14T06:37:57.000Z | ||
| 111 | + closedAt: 2026-11-14T06:37:57.000Z | ||
| 112 | + - id: 4 | ||
| 113 | + title: Commands without a file argument for a .mm project (compose comments and closing message) | ||
| 114 | + state: closed | ||
| 115 | + priority: medium | ||
| 116 | + author: | ||
| 117 | + name: k33g | ||
| 118 | + email: ph.charriere@gmail.com | ||
| 119 | + createdAt: 2026-11-14T06:29:41.000Z | ||
| 120 | + updatedAt: 2026-11-14T06:37:57.000Z | ||
| 121 | + closedAt: 2026-11-14T06:37:57.000Z | ||
| 122 | + - id: 5 | ||
| 123 | + title: Tests - .mm project, plain project unchanged (same output as today) | ||
| 124 | + state: closed | ||
| 125 | + priority: medium | ||
| 126 | + author: | ||
| 127 | + name: k33g | ||
| 128 | + email: ph.charriere@gmail.com | ||
| 129 | + createdAt: 2026-11-14T06:29:41.000Z | ||
| 130 | + updatedAt: 2026-11-14T06:37:57.000Z | ||
| 131 | + closedAt: 2026-11-14T06:37:57.000Z | ||
| 132 | + - id: 6 | ||
| 133 | + title: Docs EN+FR - run-in-a-container.md (a .mm section), cli.md (--agent row) | ||
| 134 | + state: closed | ||
| 135 | + priority: medium | ||
| 136 | + author: | ||
| 137 | + name: k33g | ||
| 138 | + email: ph.charriere@gmail.com | ||
| 139 | + createdAt: 2026-11-14T06:29:41.000Z | ||
| 140 | +comments: | ||
| 141 | + - id: 1 | ||
| 142 | + author: | ||
| 143 | + name: k33g | ||
| 144 | + email: ph.charriere@gmail.com | ||
| 145 | + createdAt: 2026-11-14T06:37:57.000Z | ||
| 146 | + body: | | ||
| 147 | + Implemented on `feature/acp-create` (uncommitted). Shared code in | ||
| 148 | + `internal/scaffold/mmfolder.go` (`MMFolder`, `MMFolderOf`, `Rel`, | ||
| 149 | + `FindAgentFile`); sbxinit keeps aliases. `dockerinit.In(dir, …)` for | ||
| 150 | + `/dockerinit` in mm acp-create (#210). Missing writable folders are | ||
| 151 | + created by `mm dockerinit`. Plain project: `compose.yaml` byte for byte | ||
| 152 | + as before (test). | ||
| 153 | + | ||
| 154 | + Checked with Docker 29.9.3 / Compose v5.7.1 on Linux, real | ||
| 155 | + `compose.yaml`, image swapped for alpine: mountinfo shows the project | ||
| 156 | + `rw`, `.mm` `ro`, `.mm/sessions` `rw`; writing in `.mm/` fails, | ||
| 157 | + in `.mm/sessions` works. Not checked on Docker Desktop (macOS). | ||
| 158 | + | ||
| 159 | + Also asked in the same session: `mm sbxinit` now prints | ||
| 160 | + `Run the sandbox <name>: sbx env run --force` (and the line in the | ||
| 161 | + sbxenv.yaml comments). | ||
| 162 | + updatedAt: 2026-11-14T06:37:57.000Z | ||
| 163 | + closedAt: 2026-11-14T06:37:57.000Z | ||
modified
.tickets/milestones.yaml +5 -0 | @@ -64,3 +64,8 @@ milestones: | ||
| 64 | 64 | state: closed |
| 65 | 65 | - title: v1.0.9 |
| 66 | 66 | state: open |
| 67 | + dueOn: 2026-11-18 | |
| 68 | + - title: v1.1.0 | |
| 69 | + state: open | |
| 70 | + description: Between v1.0.9 and v2.0.0 | |
| 71 | + dueOn: 2026-11-25 | |
| @@ -64,3 +64,8 @@ milestones: | |||
| 64 | state: closed | 64 | state: closed |
| 65 | - title: v1.0.9 | 65 | - title: v1.0.9 |
| 66 | state: open | 66 | state: open |
| 67 | + dueOn: 2026-11-18 | ||
| 68 | + - title: v1.1.0 | ||
| 69 | + state: open | ||
| 70 | + description: Between v1.0.9 and v2.0.0 | ||
| 71 | + dueOn: 2026-11-25 | ||
modified
README.md +2 -2 | @@ -107,7 +107,7 @@ Anything that speaks ACP can host `mm -acp` the same way: **JetBrains IDEs** (In | ||
| 107 | 107 | |
| 108 | 108 | ## More features |
| 109 | 109 | |
| 110 | -- **Create an agent**: `mm init` (or `mm --init`) asks which server, which model (from the server's list), what the agent is for and which machine runs it, then writes a commented agent.yaml — the model's facts read from the server (window, the GGUF's sampling, the reasoning switch), a family table sourced from model cards and the measured demos, every value with its reason; `--check` proves the model answers, flags make it scriptable. For an OpenAI-compatible service (`openai`) it also asks the endpoint and the API key's variable, and lists the service's models when the key is set. See [Create an agent with `mm init`](docs/en/how-to/create-an-agent.md) and `demos/open-ai-compliant/`. | |
| 110 | +- **Create an agent**: `mm init` (or `mm --init`) asks which server, which model (from the server's list), what the agent is for and which machine runs it, then writes a commented agent.yaml — the model's facts read from the server (window, the GGUF's sampling, the reasoning switch), a family table sourced from model cards and the measured demos, every value with its reason; `--check` proves the model answers, flags make it scriptable. For an OpenAI-compatible service (`openai`) it also asks the endpoint and the API key's variable, and lists the service's models when the key is set. See [Create an agent with `mm init`](docs/en/how-to/create-an-agent.md) and `demos/open-ai-compliant/`. From an editor, `mm acp-create` asks the same questions over ACP — forms when the client shows them, a conversation otherwise — and its `/dockerinit`, `/sbxinit` and `/zedconfig` commands prepare the container, the sandbox and the editor's settings: see [Create an agent from your editor](docs/en/how-to/create-an-agent-from-an-editor.md). | |
| 111 | 111 | - **One prompt and exit**: `mm -p "…"` — for scripts. See [Run a single prompt](docs/en/how-to/run-a-single-prompt.md). |
| 112 | 112 | - **Other agents**: list other mm agents under `mmConfigs`; you ask them with `/ask-<name>`, your agent calls them itself with its `ask_agent` tool, a script with `mm --ask <name>`. See [Let your agent call other agents](docs/en/how-to/let-the-agent-call-other-agents.md). |
| 113 | 113 | - **Questions to you**: the agent asks with its `ask_user` tool — pick one, pick several, type a text, yes or no — as numbered choices in the terminal, an arrow-key box in the window, a form or a dialog in an editor; a called agent's questions come up to you too. `askUserTool: false` turns it off. See [Let the agent ask you questions](docs/en/how-to/let-the-agent-ask-you.md). |
| @@ -131,7 +131,7 @@ Anything that speaks ACP can host `mm -acp` the same way: **JetBrains IDEs** (In | ||
| 131 | 131 | - **Models that take turns in memory**: `unloadModels: true` on an `mmConfigs` entry unloads the asking agent's model before the hand-off and the asked agent's model after it, so two models are never loaded together. See [Free a model's memory](docs/en/how-to/unload-a-model.md) and `demos/coding-helpers-unload-option/`. |
| 132 | 132 | - **Use mini-me from another program**: drive `mm -acp` over ACP — `demo.acp.sdk/nodejs/minime.js` gives Node.js `complete`, `stream`, cancellation and a permission callback, with no dependency. See [`demo.acp.sdk/`](demo.acp.sdk/README.en.md). |
| 133 | 133 | - **Test a model**: `testing/` holds two benches run by mm itself — a coordinator agent gives small tasks to a candidate agent whose model is under test, checks each result with a command, and writes a Markdown report: the model as a working agent (`testing/model-check/`) and function calling alone, with tools declared in the agent file (`testing/function-calling/`). See [`testing/`](testing/README.en.md). |
| 134 | -- **Containers**: `mm dockerinit` writes a `Dockerfile` (the latest release, for amd64 and arm64, not root) and a `compose.yaml` that mounts your project. See [run mini-me in a container](docs/en/how-to/run-in-a-container.md). | |
| 134 | +- **Containers**: `mm dockerinit` writes a `Dockerfile` (the latest release, for amd64 and arm64, not root) and a `compose.yaml` that mounts your project — with a `.mm` project, `.mm/` read-only and the sessions and trace folders writable. See [run mini-me in a container](docs/en/how-to/run-in-a-container.md). | |
| 135 | 135 | - **Docker Sandboxes**: the sandbox kit lives in its own repository, [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit); `demos/use-with-sbx/` explains how to use it (`sbx run …`), with two mixins (`go-toolchain`, `cli-tools`) and ready-made configurations — see `demos/use-with-sbx/README.md` and [Run mini-me in a Docker Sandbox](docs/en/how-to/run-in-a-docker-sandbox.md). `mm sbxinit` writes a `sbxenv.yaml` with the latest kit. |
| 136 | 136 | |
| 137 | 137 | ## Demos |
| @@ -107,7 +107,7 @@ Anything that speaks ACP can host `mm -acp` the same way: **JetBrains IDEs** (In | |||
| 107 | 107 | ||
| 108 | ## More features | 108 | ## More features |
| 109 | 109 | ||
| 110 | -- **Create an agent**: `mm init` (or `mm --init`) asks which server, which model (from the server's list), what the agent is for and which machine runs it, then writes a commented agent.yaml — the model's facts read from the server (window, the GGUF's sampling, the reasoning switch), a family table sourced from model cards and the measured demos, every value with its reason; `--check` proves the model answers, flags make it scriptable. For an OpenAI-compatible service (`openai`) it also asks the endpoint and the API key's variable, and lists the service's models when the key is set. See [Create an agent with `mm init`](docs/en/how-to/create-an-agent.md) and `demos/open-ai-compliant/`. | 110 | +- **Create an agent**: `mm init` (or `mm --init`) asks which server, which model (from the server's list), what the agent is for and which machine runs it, then writes a commented agent.yaml — the model's facts read from the server (window, the GGUF's sampling, the reasoning switch), a family table sourced from model cards and the measured demos, every value with its reason; `--check` proves the model answers, flags make it scriptable. For an OpenAI-compatible service (`openai`) it also asks the endpoint and the API key's variable, and lists the service's models when the key is set. See [Create an agent with `mm init`](docs/en/how-to/create-an-agent.md) and `demos/open-ai-compliant/`. From an editor, `mm acp-create` asks the same questions over ACP — forms when the client shows them, a conversation otherwise — and its `/dockerinit`, `/sbxinit` and `/zedconfig` commands prepare the container, the sandbox and the editor's settings: see [Create an agent from your editor](docs/en/how-to/create-an-agent-from-an-editor.md). |
| 111 | - **One prompt and exit**: `mm -p "…"` — for scripts. See [Run a single prompt](docs/en/how-to/run-a-single-prompt.md). | 111 | - **One prompt and exit**: `mm -p "…"` — for scripts. See [Run a single prompt](docs/en/how-to/run-a-single-prompt.md). |
| 112 | - **Other agents**: list other mm agents under `mmConfigs`; you ask them with `/ask-<name>`, your agent calls them itself with its `ask_agent` tool, a script with `mm --ask <name>`. See [Let your agent call other agents](docs/en/how-to/let-the-agent-call-other-agents.md). | 112 | - **Other agents**: list other mm agents under `mmConfigs`; you ask them with `/ask-<name>`, your agent calls them itself with its `ask_agent` tool, a script with `mm --ask <name>`. See [Let your agent call other agents](docs/en/how-to/let-the-agent-call-other-agents.md). |
| 113 | - **Questions to you**: the agent asks with its `ask_user` tool — pick one, pick several, type a text, yes or no — as numbered choices in the terminal, an arrow-key box in the window, a form or a dialog in an editor; a called agent's questions come up to you too. `askUserTool: false` turns it off. See [Let the agent ask you questions](docs/en/how-to/let-the-agent-ask-you.md). | 113 | - **Questions to you**: the agent asks with its `ask_user` tool — pick one, pick several, type a text, yes or no — as numbered choices in the terminal, an arrow-key box in the window, a form or a dialog in an editor; a called agent's questions come up to you too. `askUserTool: false` turns it off. See [Let the agent ask you questions](docs/en/how-to/let-the-agent-ask-you.md). |
| @@ -131,7 +131,7 @@ Anything that speaks ACP can host `mm -acp` the same way: **JetBrains IDEs** (In | |||
| 131 | - **Models that take turns in memory**: `unloadModels: true` on an `mmConfigs` entry unloads the asking agent's model before the hand-off and the asked agent's model after it, so two models are never loaded together. See [Free a model's memory](docs/en/how-to/unload-a-model.md) and `demos/coding-helpers-unload-option/`. | 131 | - **Models that take turns in memory**: `unloadModels: true` on an `mmConfigs` entry unloads the asking agent's model before the hand-off and the asked agent's model after it, so two models are never loaded together. See [Free a model's memory](docs/en/how-to/unload-a-model.md) and `demos/coding-helpers-unload-option/`. |
| 132 | - **Use mini-me from another program**: drive `mm -acp` over ACP — `demo.acp.sdk/nodejs/minime.js` gives Node.js `complete`, `stream`, cancellation and a permission callback, with no dependency. See [`demo.acp.sdk/`](demo.acp.sdk/README.en.md). | 132 | - **Use mini-me from another program**: drive `mm -acp` over ACP — `demo.acp.sdk/nodejs/minime.js` gives Node.js `complete`, `stream`, cancellation and a permission callback, with no dependency. See [`demo.acp.sdk/`](demo.acp.sdk/README.en.md). |
| 133 | - **Test a model**: `testing/` holds two benches run by mm itself — a coordinator agent gives small tasks to a candidate agent whose model is under test, checks each result with a command, and writes a Markdown report: the model as a working agent (`testing/model-check/`) and function calling alone, with tools declared in the agent file (`testing/function-calling/`). See [`testing/`](testing/README.en.md). | 133 | - **Test a model**: `testing/` holds two benches run by mm itself — a coordinator agent gives small tasks to a candidate agent whose model is under test, checks each result with a command, and writes a Markdown report: the model as a working agent (`testing/model-check/`) and function calling alone, with tools declared in the agent file (`testing/function-calling/`). See [`testing/`](testing/README.en.md). |
| 134 | -- **Containers**: `mm dockerinit` writes a `Dockerfile` (the latest release, for amd64 and arm64, not root) and a `compose.yaml` that mounts your project. See [run mini-me in a container](docs/en/how-to/run-in-a-container.md). | 134 | +- **Containers**: `mm dockerinit` writes a `Dockerfile` (the latest release, for amd64 and arm64, not root) and a `compose.yaml` that mounts your project — with a `.mm` project, `.mm/` read-only and the sessions and trace folders writable. See [run mini-me in a container](docs/en/how-to/run-in-a-container.md). |
| 135 | - **Docker Sandboxes**: the sandbox kit lives in its own repository, [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit); `demos/use-with-sbx/` explains how to use it (`sbx run …`), with two mixins (`go-toolchain`, `cli-tools`) and ready-made configurations — see `demos/use-with-sbx/README.md` and [Run mini-me in a Docker Sandbox](docs/en/how-to/run-in-a-docker-sandbox.md). `mm sbxinit` writes a `sbxenv.yaml` with the latest kit. | 135 | - **Docker Sandboxes**: the sandbox kit lives in its own repository, [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit); `demos/use-with-sbx/` explains how to use it (`sbx run …`), with two mixins (`go-toolchain`, `cli-tools`) and ready-made configurations — see `demos/use-with-sbx/README.md` and [Run mini-me in a Docker Sandbox](docs/en/how-to/run-in-a-docker-sandbox.md). `mm sbxinit` writes a `sbxenv.yaml` with the latest kit. |
| 136 | 136 | ||
| 137 | ## Demos | 137 | ## Demos |
modified
docs/en/README.md +1 -0 | @@ -12,6 +12,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | ||
| 12 | 12 | ## How-to guides — get a task done |
| 13 | 13 | |
| 14 | 14 | - [Create an agent with `mm init`](how-to/create-an-agent.md) — a wizard that writes a commented agent.yaml from the model server's facts, a family table, the usage and the machine |
| 15 | +- [Create an agent from your editor (`mm acp-create`)](how-to/create-an-agent-from-an-editor.md) — the same wizard over ACP, from Zed or any ACP client: forms when the client shows them, a conversation otherwise | |
| 15 | 16 | - [Run mini-me on llama.cpp instead of Docker Model Runner](how-to/run-with-llama-cpp.md) |
| 16 | 17 | - [Run mini-me on llmman](how-to/run-with-llmman.md) — `provider: llmman`, the demo config in `demos/llmman/` |
| 17 | 18 | - [Run mini-me on Ollama](how-to/run-with-ollama.md) — `provider: ollama`, the demo config in `demos/ollama/` |
| @@ -12,6 +12,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | |||
| 12 | ## How-to guides — get a task done | 12 | ## How-to guides — get a task done |
| 13 | 13 | ||
| 14 | - [Create an agent with `mm init`](how-to/create-an-agent.md) — a wizard that writes a commented agent.yaml from the model server's facts, a family table, the usage and the machine | 14 | - [Create an agent with `mm init`](how-to/create-an-agent.md) — a wizard that writes a commented agent.yaml from the model server's facts, a family table, the usage and the machine |
| 15 | +- [Create an agent from your editor (`mm acp-create`)](how-to/create-an-agent-from-an-editor.md) — the same wizard over ACP, from Zed or any ACP client: forms when the client shows them, a conversation otherwise | ||
| 15 | - [Run mini-me on llama.cpp instead of Docker Model Runner](how-to/run-with-llama-cpp.md) | 16 | - [Run mini-me on llama.cpp instead of Docker Model Runner](how-to/run-with-llama-cpp.md) |
| 16 | - [Run mini-me on llmman](how-to/run-with-llmman.md) — `provider: llmman`, the demo config in `demos/llmman/` | 17 | - [Run mini-me on llmman](how-to/run-with-llmman.md) — `provider: llmman`, the demo config in `demos/llmman/` |
| 17 | - [Run mini-me on Ollama](how-to/run-with-ollama.md) — `provider: ollama`, the demo config in `demos/ollama/` | 18 | - [Run mini-me on Ollama](how-to/run-with-ollama.md) — `provider: ollama`, the demo config in `demos/ollama/` |
modified
docs/en/explanation/mm-init.md +1 -0 | @@ -118,3 +118,4 @@ The wizard overwrites an existing file only if you agree, and never with `--yes` | ||
| 118 | 118 | - [Context compression](context-compression.md): what `context.threshold` triggers. |
| 119 | 119 | - [Structured output](structured-output.md): why the `json` usage has no tools. |
| 120 | 120 | - [Asking another agent](asking-another-agent.md): what the `coordinator` usage relies on. |
| 121 | +- [Create an agent from your editor](../how-to/create-an-agent-from-an-editor.md): the same questions over ACP (`mm acp-create`). The wizard's state is kept apart from the way it is shown, so the line mode, the window and the ACP mode drive one flow and write the same file. | |
| @@ -118,3 +118,4 @@ The wizard overwrites an existing file only if you agree, and never with `--yes` | |||
| 118 | - [Context compression](context-compression.md): what `context.threshold` triggers. | 118 | - [Context compression](context-compression.md): what `context.threshold` triggers. |
| 119 | - [Structured output](structured-output.md): why the `json` usage has no tools. | 119 | - [Structured output](structured-output.md): why the `json` usage has no tools. |
| 120 | - [Asking another agent](asking-another-agent.md): what the `coordinator` usage relies on. | 120 | - [Asking another agent](asking-another-agent.md): what the `coordinator` usage relies on. |
| 121 | +- [Create an agent from your editor](../how-to/create-an-agent-from-an-editor.md): the same questions over ACP (`mm acp-create`). The wizard's state is kept apart from the way it is shown, so the line mode, the window and the ACP mode drive one flow and write the same file. | ||
modified
docs/en/explanation/persistent-sessions.md +4 -0 | @@ -26,6 +26,10 @@ That choice still holds, but it is no longer exclusive. When it was made, `sessi | ||
| 26 | 26 | |
| 27 | 27 | A reloaded or resumed session continues under the current system prompt, not the one stored with it: the prompt is the agent's, and a change to `agent.yaml` must reach old conversations too. |
| 28 | 28 | |
| 29 | +## Without saved sessions, a reload starts over | |
| 30 | + | |
| 31 | +Until 2026-11-14 an agent without `sessions.enabled` advertised neither `loadSession` nor `session/resume`: there was nothing to read back, and saying so honestly let the editor know. Zed then answered its own Reload Agent with "Loading or resuming sessions is not supported by this agent", and the thread was dead — `mm init` writes `sessions` off by default, so most agents were in that case. Since then both are always advertised. Without saved sessions the id the editor asks for becomes a new, empty session, and one message in the thread says the history is gone and how to keep it next time (`sessions.enabled`). A reload thus never fails; what it brings back depends on the setting. `session/list` still exists only with saved sessions: a history picker with nothing to list helps nobody. `mm acp-create` does the same: its wizard keeps nothing on disk, and a reload starts it over. | |
| 32 | + | |
| 29 | 33 | ## `/new` in the two front ends |
| 30 | 34 | |
| 31 | 35 | In the terminal `/new` simply leaves the old file alone and lets the next question open a new one. Over ACP the editor owns the session id and keeps using it, so the id's file must become the fresh session; the conversation so far is moved to a new id first. Either way nothing is lost on disk, and a later reload of the thread gives the current conversation, not the one `/new` closed. |
| @@ -26,6 +26,10 @@ That choice still holds, but it is no longer exclusive. When it was made, `sessi | |||
| 26 | 26 | ||
| 27 | A reloaded or resumed session continues under the current system prompt, not the one stored with it: the prompt is the agent's, and a change to `agent.yaml` must reach old conversations too. | 27 | A reloaded or resumed session continues under the current system prompt, not the one stored with it: the prompt is the agent's, and a change to `agent.yaml` must reach old conversations too. |
| 28 | 28 | ||
| 29 | +## Without saved sessions, a reload starts over | ||
| 30 | + | ||
| 31 | +Until 2026-11-14 an agent without `sessions.enabled` advertised neither `loadSession` nor `session/resume`: there was nothing to read back, and saying so honestly let the editor know. Zed then answered its own Reload Agent with "Loading or resuming sessions is not supported by this agent", and the thread was dead — `mm init` writes `sessions` off by default, so most agents were in that case. Since then both are always advertised. Without saved sessions the id the editor asks for becomes a new, empty session, and one message in the thread says the history is gone and how to keep it next time (`sessions.enabled`). A reload thus never fails; what it brings back depends on the setting. `session/list` still exists only with saved sessions: a history picker with nothing to list helps nobody. `mm acp-create` does the same: its wizard keeps nothing on disk, and a reload starts it over. | ||
| 32 | + | ||
| 29 | ## `/new` in the two front ends | 33 | ## `/new` in the two front ends |
| 30 | 34 | ||
| 31 | In the terminal `/new` simply leaves the old file alone and lets the next question open a new one. Over ACP the editor owns the session id and keeps using it, so the id's file must become the fresh session; the conversation so far is moved to a new id first. Either way nothing is lost on disk, and a later reload of the thread gives the current conversation, not the one `/new` closed. | 35 | In the terminal `/new` simply leaves the old file alone and lets the next question open a new one. Over ACP the editor owns the session id and keeps using it, so the id's file must become the fresh session; the conversation so far is moved to a new id first. Either way nothing is lost on disk, and a later reload of the thread gives the current conversation, not the one `/new` closed. |
added
docs/en/how-to/create-an-agent-from-an-editor.md +61 -0 | new file mode 100644 | ||
| @@ -0,0 +1,61 @@ | ||
| 1 | +# How to create an agent from your editor (`mm acp-create`) | |
| 2 | + | |
| 3 | +This guide shows how to create an agent.yaml without a terminal, from any editor or tool that speaks the Agent Client Protocol (Zed, a JetBrains IDE, a Neovim plugin…). `mm acp-create` serves the questions of [`mm init`](create-an-agent.md) over ACP and writes the same file. It assumes `mm` is installed on your `PATH` and that a model server is running (the wizard lists its models). | |
| 4 | + | |
| 5 | +## Steps | |
| 6 | + | |
| 7 | +1. Register `mm acp-create` as an agent server in your client. In Zed, add to `settings.json`: | |
| 8 | + | |
| 9 | + ```json | |
| 10 | + { | |
| 11 | + "agent_servers": { | |
| 12 | + "mm-create": { | |
| 13 | + "type": "custom", | |
| 14 | + "command": "mm", | |
| 15 | + "args": ["acp-create"] | |
| 16 | + } | |
| 17 | + } | |
| 18 | + } | |
| 19 | + ``` | |
| 20 | + | |
| 21 | + Other clients take the same two things: the command `mm` and the argument `acp-create`. No configuration file is needed: this mode reads none. | |
| 22 | + | |
| 23 | +2. Open the project the agent will work in, start a new thread with `mm-create`, and send `/start` (or any message). The wizard starts. The file will be written in the project's directory — the session's working directory — not where the client started `mm`. | |
| 24 | + | |
| 25 | +3. Answer the questions. How they are asked depends on what your client can show: | |
| 26 | + | |
| 27 | + - **A client that shows forms** (it advertises the `elicitation.form` capability): the questions come in a few forms, the defaults already filled in. A question whose choices depend on an earlier answer — the model list on the server you chose — comes in the next form. | |
| 28 | + - **Any other client**: each question is a message, with numbered choices. Reply with a number or a value; `.` takes the default. | |
| 29 | + | |
| 30 | +4. Read the closing message. It shows `✅ wrote agent.yaml` and `✅ mm loads it`, then the command that starts the agent, `mm acp /absolute/path/to/agent.yaml`. Zed gets a ready `agent_servers` entry; for another client, use that command line. | |
| 31 | + | |
| 32 | +5. To run the agent in a container or a Docker Sandbox rather than on your machine, send `/dockerinit` (writes `Dockerfile` and `compose.yaml`) or `/sbxinit` (writes `sbxenv.yaml`) in the same thread. See [run mini-me in a container](run-in-a-container.md) and [run mini-me in a Docker Sandbox](run-in-a-docker-sandbox.md). | |
| 33 | + | |
| 34 | +6. Send `/zedconfig`. It shows the `agent_servers` entries for every way the project can now start its agent — on your machine, in its container, in its sandbox — and what to start first for each. Copy the ones you want into your client's settings, and start a thread with one of them: this one is your agent. | |
| 35 | + | |
| 36 | +## Commands during the wizard | |
| 37 | + | |
| 38 | +| Command | What it does | | |
| 39 | +|---------|--------------| | |
| 40 | +| `/start` | Starts the wizard; once started, asks the current question again. | | |
| 41 | +| `/preview` | Shows the agent.yaml as it would be written now. | | |
| 42 | +| `/back` | Takes back the last answer and asks it again. | | |
| 43 | +| `/write` | Takes the default for every question left and writes the file (replacing an existing file is still asked). | | |
| 44 | +| `/restart` | Forgets every answer and starts again. | | |
| 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. | | |
| 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 | +| `/help` | Lists these commands. | | |
| 49 | + | |
| 50 | +## Variations | |
| 51 | + | |
| 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"]`. | |
| 53 | +- **The `.mm` layout.** Answer `yes` to *Keep everything in a .mm folder?*: the file is `.mm/agent.yaml`, see [Keep a project's mini-me files in a `.mm` folder](keep-the-config-in-a-mm-folder.md). | |
| 54 | +- **Declining a form** writes nothing. Send another message to pick up where you stopped. | |
| 55 | +- **Reloading the agent** (Zed's *Reload Agent*) restarts the wizard from the first question: the answers are not kept. A file already written stays as it is. | |
| 56 | + | |
| 57 | +## See also | |
| 58 | + | |
| 59 | +- [Create an agent with `mm init`](create-an-agent.md) — the same wizard in a terminal. | |
| 60 | +- [Use mini-me from an editor (ACP)](use-from-an-editor.md) — running the agent you created. | |
| 61 | +- [Reference: command line — `mm acp-create`](../reference/cli.md#mm-acp-create--the-agentyaml-wizard-over-acp) | |
| new file mode 100644 | |||
| @@ -0,0 +1,61 @@ | |||
| 1 | +# How to create an agent from your editor (`mm acp-create`) | ||
| 2 | + | ||
| 3 | +This guide shows how to create an agent.yaml without a terminal, from any editor or tool that speaks the Agent Client Protocol (Zed, a JetBrains IDE, a Neovim plugin…). `mm acp-create` serves the questions of [`mm init`](create-an-agent.md) over ACP and writes the same file. It assumes `mm` is installed on your `PATH` and that a model server is running (the wizard lists its models). | ||
| 4 | + | ||
| 5 | +## Steps | ||
| 6 | + | ||
| 7 | +1. Register `mm acp-create` as an agent server in your client. In Zed, add to `settings.json`: | ||
| 8 | + | ||
| 9 | + ```json | ||
| 10 | + { | ||
| 11 | + "agent_servers": { | ||
| 12 | + "mm-create": { | ||
| 13 | + "type": "custom", | ||
| 14 | + "command": "mm", | ||
| 15 | + "args": ["acp-create"] | ||
| 16 | + } | ||
| 17 | + } | ||
| 18 | + } | ||
| 19 | + ``` | ||
| 20 | + | ||
| 21 | + Other clients take the same two things: the command `mm` and the argument `acp-create`. No configuration file is needed: this mode reads none. | ||
| 22 | + | ||
| 23 | +2. Open the project the agent will work in, start a new thread with `mm-create`, and send `/start` (or any message). The wizard starts. The file will be written in the project's directory — the session's working directory — not where the client started `mm`. | ||
| 24 | + | ||
| 25 | +3. Answer the questions. How they are asked depends on what your client can show: | ||
| 26 | + | ||
| 27 | + - **A client that shows forms** (it advertises the `elicitation.form` capability): the questions come in a few forms, the defaults already filled in. A question whose choices depend on an earlier answer — the model list on the server you chose — comes in the next form. | ||
| 28 | + - **Any other client**: each question is a message, with numbered choices. Reply with a number or a value; `.` takes the default. | ||
| 29 | + | ||
| 30 | +4. Read the closing message. It shows `✅ wrote agent.yaml` and `✅ mm loads it`, then the command that starts the agent, `mm acp /absolute/path/to/agent.yaml`. Zed gets a ready `agent_servers` entry; for another client, use that command line. | ||
| 31 | + | ||
| 32 | +5. To run the agent in a container or a Docker Sandbox rather than on your machine, send `/dockerinit` (writes `Dockerfile` and `compose.yaml`) or `/sbxinit` (writes `sbxenv.yaml`) in the same thread. See [run mini-me in a container](run-in-a-container.md) and [run mini-me in a Docker Sandbox](run-in-a-docker-sandbox.md). | ||
| 33 | + | ||
| 34 | +6. Send `/zedconfig`. It shows the `agent_servers` entries for every way the project can now start its agent — on your machine, in its container, in its sandbox — and what to start first for each. Copy the ones you want into your client's settings, and start a thread with one of them: this one is your agent. | ||
| 35 | + | ||
| 36 | +## Commands during the wizard | ||
| 37 | + | ||
| 38 | +| Command | What it does | | ||
| 39 | +|---------|--------------| | ||
| 40 | +| `/start` | Starts the wizard; once started, asks the current question again. | | ||
| 41 | +| `/preview` | Shows the agent.yaml as it would be written now. | | ||
| 42 | +| `/back` | Takes back the last answer and asks it again. | | ||
| 43 | +| `/write` | Takes the default for every question left and writes the file (replacing an existing file is still asked). | | ||
| 44 | +| `/restart` | Forgets every answer and starts again. | | ||
| 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. | | ||
| 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 | +| `/help` | Lists these commands. | | ||
| 49 | + | ||
| 50 | +## Variations | ||
| 51 | + | ||
| 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"]`. | ||
| 53 | +- **The `.mm` layout.** Answer `yes` to *Keep everything in a .mm folder?*: the file is `.mm/agent.yaml`, see [Keep a project's mini-me files in a `.mm` folder](keep-the-config-in-a-mm-folder.md). | ||
| 54 | +- **Declining a form** writes nothing. Send another message to pick up where you stopped. | ||
| 55 | +- **Reloading the agent** (Zed's *Reload Agent*) restarts the wizard from the first question: the answers are not kept. A file already written stays as it is. | ||
| 56 | + | ||
| 57 | +## See also | ||
| 58 | + | ||
| 59 | +- [Create an agent with `mm init`](create-an-agent.md) — the same wizard in a terminal. | ||
| 60 | +- [Use mini-me from an editor (ACP)](use-from-an-editor.md) — running the agent you created. | ||
| 61 | +- [Reference: command line — `mm acp-create`](../reference/cli.md#mm-acp-create--the-agentyaml-wizard-over-acp) | ||
modified
docs/en/how-to/create-an-agent.md +1 -0 | @@ -90,6 +90,7 @@ This guide shows how to write a new agent.yaml for a model and a job, with mm's | ||
| 90 | 90 | |
| 91 | 91 | ## See also |
| 92 | 92 | |
| 93 | +- [Create an agent from your editor (`mm acp-create`)](create-an-agent-from-an-editor.md) — the same wizard over ACP | |
| 93 | 94 | - [Command line reference — `mm init`](../reference/cli.md#mm-init--the-agentyaml-wizard): every flag, the sources, the exit codes |
| 94 | 95 | - [Configuration reference](../reference/configuration.md): every key the file holds |
| 95 | 96 | - [Agent settings for small models](../../../guides/agent-settings-for-small-models.md): what each setting does, the machine rows |
| @@ -90,6 +90,7 @@ This guide shows how to write a new agent.yaml for a model and a job, with mm's | |||
| 90 | 90 | ||
| 91 | ## See also | 91 | ## See also |
| 92 | 92 | ||
| 93 | +- [Create an agent from your editor (`mm acp-create`)](create-an-agent-from-an-editor.md) — the same wizard over ACP | ||
| 93 | - [Command line reference — `mm init`](../reference/cli.md#mm-init--the-agentyaml-wizard): every flag, the sources, the exit codes | 94 | - [Command line reference — `mm init`](../reference/cli.md#mm-init--the-agentyaml-wizard): every flag, the sources, the exit codes |
| 94 | - [Configuration reference](../reference/configuration.md): every key the file holds | 95 | - [Configuration reference](../reference/configuration.md): every key the file holds |
| 95 | - [Agent settings for small models](../../../guides/agent-settings-for-small-models.md): what each setting does, the machine rows | 96 | - [Agent settings for small models](../../../guides/agent-settings-for-small-models.md): what each setting does, the machine rows |
modified
docs/en/how-to/run-in-a-container.md +18 -1 | @@ -22,6 +22,23 @@ The service is named after `acp.name` in `agent.yaml`, or `mm` if there is none. | ||
| 22 | 22 | |
| 23 | 23 | If a file already exists, mm asks `Overwrite Dockerfile?`, and `no` is the default. With `--yes`, or when the input ends, nothing at all is written. |
| 24 | 24 | |
| 25 | +### A project with a `.mm` folder | |
| 26 | + | |
| 27 | +When the agent is `.mm/agent.yaml` (made with `mm init --project yes`, see [keep a project's mini-me files in a `.mm` folder](keep-the-config-in-a-mm-folder.md)), `mm dockerinit` finds it with no flag — it looks for `agent.yaml`, then `.mm/agent.yaml`, as mm does — and names the service after it. `compose.yaml` then: | |
| 28 | + | |
| 29 | +- mounts `.mm/` **read-only** over the project: the agent cannot change its own settings, skills or hooks; | |
| 30 | +- mounts the folders mm writes in — the agent file's `sessions.dir` and `trace.dir`, when they are on and under `.mm/` — writable again, and creates them now if they do not exist (Docker would create them as root, and the agent never runs as root); | |
| 31 | +- starts `mm tui` with no file: mm finds `.mm/agent.yaml` from the project. | |
| 32 | + | |
| 33 | +```yaml | |
| 34 | + volumes: | |
| 35 | + - .:${PWD:-/workspace} | |
| 36 | + - ./.mm:${PWD:-/workspace}/.mm:ro | |
| 37 | + - ./.mm/sessions:${PWD:-/workspace}/.mm/sessions # sessions.dir | |
| 38 | +``` | |
| 39 | + | |
| 40 | +Anything else written under `.mm/` — a hook that keeps a log there — needs a line of its own. After turning sessions or the trace on or off, run `mm dockerinit` again (answer `yes` to replace `compose.yaml`). | |
| 41 | + | |
| 25 | 42 | ## Run the agent |
| 26 | 43 | |
| 27 | 44 | Either keep a container running, which also serves an editor: |
| @@ -47,7 +64,7 @@ Start the container with `docker compose up -d --wait`, then give your editor th | ||
| 47 | 64 | docker exec -i bob-box mm --acp --config /path/to/project/agent.yaml |
| 48 | 65 | ``` |
| 49 | 66 | |
| 50 | -The project is mounted at the same path as on your machine, so the paths the editor sends mean the same thing inside the container. | |
| 67 | +The project is mounted at the same path as on your machine, so the paths the editor sends mean the same thing inside the container. For a `.mm` project, `docker exec -i bob-box mm --acp` is enough: the container starts in the project, where mm finds `.mm/agent.yaml`. | |
| 51 | 68 | |
| 52 | 69 | ## Reach the model |
| 53 | 70 | |
| @@ -22,6 +22,23 @@ The service is named after `acp.name` in `agent.yaml`, or `mm` if there is none. | |||
| 22 | 22 | ||
| 23 | If a file already exists, mm asks `Overwrite Dockerfile?`, and `no` is the default. With `--yes`, or when the input ends, nothing at all is written. | 23 | If a file already exists, mm asks `Overwrite Dockerfile?`, and `no` is the default. With `--yes`, or when the input ends, nothing at all is written. |
| 24 | 24 | ||
| 25 | +### A project with a `.mm` folder | ||
| 26 | + | ||
| 27 | +When the agent is `.mm/agent.yaml` (made with `mm init --project yes`, see [keep a project's mini-me files in a `.mm` folder](keep-the-config-in-a-mm-folder.md)), `mm dockerinit` finds it with no flag — it looks for `agent.yaml`, then `.mm/agent.yaml`, as mm does — and names the service after it. `compose.yaml` then: | ||
| 28 | + | ||
| 29 | +- mounts `.mm/` **read-only** over the project: the agent cannot change its own settings, skills or hooks; | ||
| 30 | +- mounts the folders mm writes in — the agent file's `sessions.dir` and `trace.dir`, when they are on and under `.mm/` — writable again, and creates them now if they do not exist (Docker would create them as root, and the agent never runs as root); | ||
| 31 | +- starts `mm tui` with no file: mm finds `.mm/agent.yaml` from the project. | ||
| 32 | + | ||
| 33 | +```yaml | ||
| 34 | + volumes: | ||
| 35 | + - .:${PWD:-/workspace} | ||
| 36 | + - ./.mm:${PWD:-/workspace}/.mm:ro | ||
| 37 | + - ./.mm/sessions:${PWD:-/workspace}/.mm/sessions # sessions.dir | ||
| 38 | +``` | ||
| 39 | + | ||
| 40 | +Anything else written under `.mm/` — a hook that keeps a log there — needs a line of its own. After turning sessions or the trace on or off, run `mm dockerinit` again (answer `yes` to replace `compose.yaml`). | ||
| 41 | + | ||
| 25 | ## Run the agent | 42 | ## Run the agent |
| 26 | 43 | ||
| 27 | Either keep a container running, which also serves an editor: | 44 | Either keep a container running, which also serves an editor: |
| @@ -47,7 +64,7 @@ Start the container with `docker compose up -d --wait`, then give your editor th | |||
| 47 | docker exec -i bob-box mm --acp --config /path/to/project/agent.yaml | 64 | docker exec -i bob-box mm --acp --config /path/to/project/agent.yaml |
| 48 | ``` | 65 | ``` |
| 49 | 66 | ||
| 50 | -The project is mounted at the same path as on your machine, so the paths the editor sends mean the same thing inside the container. | 67 | +The project is mounted at the same path as on your machine, so the paths the editor sends mean the same thing inside the container. For a `.mm` project, `docker exec -i bob-box mm --acp` is enough: the container starts in the project, where mm finds `.mm/agent.yaml`. |
| 51 | 68 | ||
| 52 | ## Reach the model | 69 | ## Reach the model |
| 53 | 70 | ||
modified
docs/en/how-to/run-in-a-docker-sandbox.md +1 -0 | @@ -97,6 +97,7 @@ To describe the sandbox in a file, run this in your project's directory: | ||
| 97 | 97 | ```bash |
| 98 | 98 | mm sbxinit |
| 99 | 99 | sbx env create --force |
| 100 | +sbx env run --force | |
| 100 | 101 | ``` |
| 101 | 102 | |
| 102 | 103 | `sbx env` needs sbx 0.39.0 or newer, and `docker.io/` among the allowed kit sources (step 1). The file names the latest `mini-me-kit`, and `pullPolicy: always` makes the sandbox fetch it at each creation. If that version cannot be read, for instance without network, mm says so and uses the tag `latest`. The sandbox is named after `acp.name` in `agent.yaml`, followed by `-sbx`; `--name` chooses another one. |
| @@ -97,6 +97,7 @@ To describe the sandbox in a file, run this in your project's directory: | |||
| 97 | ```bash | 97 | ```bash |
| 98 | mm sbxinit | 98 | mm sbxinit |
| 99 | sbx env create --force | 99 | sbx env create --force |
| 100 | +sbx env run --force | ||
| 100 | ``` | 101 | ``` |
| 101 | 102 | ||
| 102 | `sbx env` needs sbx 0.39.0 or newer, and `docker.io/` among the allowed kit sources (step 1). The file names the latest `mini-me-kit`, and `pullPolicy: always` makes the sandbox fetch it at each creation. If that version cannot be read, for instance without network, mm says so and uses the tag `latest`. The sandbox is named after `acp.name` in `agent.yaml`, followed by `-sbx`; `--name` chooses another one. | 103 | `sbx env` needs sbx 0.39.0 or newer, and `docker.io/` among the allowed kit sources (step 1). The file names the latest `mini-me-kit`, and `pullPolicy: always` makes the sandbox fetch it at each creation. If that version cannot be read, for instance without network, mm says so and uses the tag `latest`. The sandbox is named after `acp.name` in `agent.yaml`, followed by `-sbx`; `--name` chooses another one. |
modified
docs/en/how-to/use-from-an-editor.md +2 -1 | @@ -31,7 +31,7 @@ This guide shows how to host `mm` inside an editor that speaks the Agent Client | ||
| 31 | 31 | - **Cancel a running turn.** Use the editor's stop key (Escape in Zed). The agent answers the prompt with stop reason `cancelled` and keeps the commands that already ran in the history. There is no `/abort` over ACP. |
| 32 | 32 | - **Compress or restart the conversation.** Send `/compact` or `/new`; editors that list the agent's commands offer both in the slash-command menu. See the [slash commands reference](../reference/slash-commands.md#acp-mode). |
| 33 | 33 | - **Attach a file with `@`.** The editor's picker sends a `resource_link`, passed to the model as `[attached file: <path>]`; a path typed by hand as `@path` is understood the same way (see [`@path`](attach-a-file.md)). The model reads the file with its tools. |
| 34 | -- **Reload the agent without losing the thread.** With `sessions.enabled: true` in the config file, the editor's Reload Agent (Zed) calls `session/load` and the whole conversation, tool calls included, is replayed into the thread; and the editor's history of threads lists the project's stored conversations (`session/list`), so an older one can be reopened. See [Keep sessions and resume them](keep-sessions.md). | |
| 34 | +- **Reload the agent without losing the thread.** With `sessions.enabled: true` in the config file, the editor's Reload Agent (Zed) calls `session/load` and the whole conversation, tool calls included, is replayed into the thread; and the editor's history of threads lists the project's stored conversations (`session/list`), so an older one can be reopened. See [Keep sessions and resume them](keep-sessions.md). Without `sessions.enabled`, Reload Agent still works, but the conversation starts over: mm says so in the thread. | |
| 35 | 35 | - **See the JSON exchanged with the model.** Turn the trace on, in the config file (`trace: {enabled: true}`) or in the editor's settings (`"env": { "AGENT_CONFIG": "…", "AGENT_TRACE": "true" }` — the editor does not pass your shell's variables). The files land in `<project>/.mm/traces/`, the project being the session's `cwd`; the banner in the ACP logs says `trace: <session cwd>/.mm/traces`. See [How to see the JSON sent to the model](inspect-model-traffic.md). |
| 36 | 36 | - **Change the name the editor sees.** Add an `acp:` block to the config file; `name` is the identifier the editor logs, `title` the name it may display (leave `title` empty to not send one). The version sent is always the binary's, the one `mm --version` prints — check it with the `initialize` request below. See the [`acp` keys](../reference/configuration.md#acp-keys). |
| 37 | 37 | |
| @@ -52,5 +52,6 @@ This guide shows how to host `mm` inside an editor that speaks the Agent Client | ||
| 52 | 52 | |
| 53 | 53 | ## See also |
| 54 | 54 | |
| 55 | +- [Create an agent from your editor (`mm acp-create`)](create-an-agent-from-an-editor.md) — write the agent.yaml without a terminal | |
| 55 | 56 | - The exact methods and capabilities exposed: [ACP reference](../reference/acp.md) |
| 56 | 57 | - Why the terminal and the editor share one loop: [architecture](../explanation/architecture.md) |
| @@ -31,7 +31,7 @@ This guide shows how to host `mm` inside an editor that speaks the Agent Client | |||
| 31 | - **Cancel a running turn.** Use the editor's stop key (Escape in Zed). The agent answers the prompt with stop reason `cancelled` and keeps the commands that already ran in the history. There is no `/abort` over ACP. | 31 | - **Cancel a running turn.** Use the editor's stop key (Escape in Zed). The agent answers the prompt with stop reason `cancelled` and keeps the commands that already ran in the history. There is no `/abort` over ACP. |
| 32 | - **Compress or restart the conversation.** Send `/compact` or `/new`; editors that list the agent's commands offer both in the slash-command menu. See the [slash commands reference](../reference/slash-commands.md#acp-mode). | 32 | - **Compress or restart the conversation.** Send `/compact` or `/new`; editors that list the agent's commands offer both in the slash-command menu. See the [slash commands reference](../reference/slash-commands.md#acp-mode). |
| 33 | - **Attach a file with `@`.** The editor's picker sends a `resource_link`, passed to the model as `[attached file: <path>]`; a path typed by hand as `@path` is understood the same way (see [`@path`](attach-a-file.md)). The model reads the file with its tools. | 33 | - **Attach a file with `@`.** The editor's picker sends a `resource_link`, passed to the model as `[attached file: <path>]`; a path typed by hand as `@path` is understood the same way (see [`@path`](attach-a-file.md)). The model reads the file with its tools. |
| 34 | -- **Reload the agent without losing the thread.** With `sessions.enabled: true` in the config file, the editor's Reload Agent (Zed) calls `session/load` and the whole conversation, tool calls included, is replayed into the thread; and the editor's history of threads lists the project's stored conversations (`session/list`), so an older one can be reopened. See [Keep sessions and resume them](keep-sessions.md). | 34 | +- **Reload the agent without losing the thread.** With `sessions.enabled: true` in the config file, the editor's Reload Agent (Zed) calls `session/load` and the whole conversation, tool calls included, is replayed into the thread; and the editor's history of threads lists the project's stored conversations (`session/list`), so an older one can be reopened. See [Keep sessions and resume them](keep-sessions.md). Without `sessions.enabled`, Reload Agent still works, but the conversation starts over: mm says so in the thread. |
| 35 | - **See the JSON exchanged with the model.** Turn the trace on, in the config file (`trace: {enabled: true}`) or in the editor's settings (`"env": { "AGENT_CONFIG": "…", "AGENT_TRACE": "true" }` — the editor does not pass your shell's variables). The files land in `<project>/.mm/traces/`, the project being the session's `cwd`; the banner in the ACP logs says `trace: <session cwd>/.mm/traces`. See [How to see the JSON sent to the model](inspect-model-traffic.md). | 35 | - **See the JSON exchanged with the model.** Turn the trace on, in the config file (`trace: {enabled: true}`) or in the editor's settings (`"env": { "AGENT_CONFIG": "…", "AGENT_TRACE": "true" }` — the editor does not pass your shell's variables). The files land in `<project>/.mm/traces/`, the project being the session's `cwd`; the banner in the ACP logs says `trace: <session cwd>/.mm/traces`. See [How to see the JSON sent to the model](inspect-model-traffic.md). |
| 36 | - **Change the name the editor sees.** Add an `acp:` block to the config file; `name` is the identifier the editor logs, `title` the name it may display (leave `title` empty to not send one). The version sent is always the binary's, the one `mm --version` prints — check it with the `initialize` request below. See the [`acp` keys](../reference/configuration.md#acp-keys). | 36 | - **Change the name the editor sees.** Add an `acp:` block to the config file; `name` is the identifier the editor logs, `title` the name it may display (leave `title` empty to not send one). The version sent is always the binary's, the one `mm --version` prints — check it with the `initialize` request below. See the [`acp` keys](../reference/configuration.md#acp-keys). |
| 37 | 37 | ||
| @@ -52,5 +52,6 @@ This guide shows how to host `mm` inside an editor that speaks the Agent Client | |||
| 52 | 52 | ||
| 53 | ## See also | 53 | ## See also |
| 54 | 54 | ||
| 55 | +- [Create an agent from your editor (`mm acp-create`)](create-an-agent-from-an-editor.md) — write the agent.yaml without a terminal | ||
| 55 | - The exact methods and capabilities exposed: [ACP reference](../reference/acp.md) | 56 | - The exact methods and capabilities exposed: [ACP reference](../reference/acp.md) |
| 56 | - Why the terminal and the editor share one loop: [architecture](../explanation/architecture.md) | 57 | - Why the terminal and the editor share one loop: [architecture](../explanation/architecture.md) |
modified
docs/en/reference/acp.md +3 -3 | @@ -16,7 +16,7 @@ | ||
| 16 | 16 | | Field | Value | |
| 17 | 17 | |-------|-------| |
| 18 | 18 | | `protocolVersion` | `1` | |
| 19 | -| `agentCapabilities` | When `sessions.enabled` is set: `loadSession: true` and `sessionCapabilities: {"list": {}, "resume": {}}`; both absent (false) otherwise. Everything else absent (false): no image or audio prompts, no HTTP MCP. | | |
| 19 | +| `agentCapabilities` | `loadSession: true`. `sessionCapabilities`: `{"list": {}, "resume": {}}` when `sessions.enabled` is set, `{"resume": {}}` otherwise. Everything else absent (false): no image or audio prompts, no HTTP MCP. | | |
| 20 | 20 | | `agentInfo.name` | `acp.name` of the configuration, `bob` by default. | |
| 21 | 21 | | `agentInfo.title` | `acp.title` of the configuration, `Bob (bash-first agent)` by default; absent when empty. | |
| 22 | 22 | | `agentInfo.version` | The binary's version, the same string as `mm --version` (for example `v0.1.7`). | |
| @@ -31,9 +31,9 @@ | ||
| 31 | 31 | | `session/new` | Creates a session with a time-sortable id (`20260916-101530-4f2a9c1e`) and the request's `cwd`, a fresh history holding the system prompt, and an empty allow-always set. `mcpServers` are counted in the log and ignored. Once the response is written, sends an `available_commands_update` notification listing the slash commands (see below). With `sessions.enabled`, writes `<cwd>/<sessions.dir>/<id>.json` at once and rewrites it after every turn, `/new` and `/compact`. Before answering, gives the session its client tools: the list read at `initialize`, or, with `acp.clientTools.discovery: request`, the answer to a `_org.mini-me/tools/list` request. | |
| 32 | 32 | | `session/prompt` | Runs one generation on the session's history, or executes a slash command without consulting the model. Turns are serialised process-wide. | |
| 33 | 33 | | `session/cancel` | Cancels the turn in flight for that session. A `session/prompt` that arrives during a turn never cancels it: it waits for the turn to end, then runs. Whether a message typed during a turn is sent at once, queued, or preceded by a `session/cancel` is the editor's choice. | |
| 34 | -| `session/load` | With `sessions.enabled`: reads `<cwd>/<sessions.dir>/<sessionId>.json`, replaces its system prompt by the current one, registers the session (empty allow-always set), sends the replay (below) then an `available_commands_update`, and answers `{}`. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: `method not found`. The session's client tools are set as for `session/new`. | | |
| 34 | +| `session/load` | With `sessions.enabled`: reads `<cwd>/<sessions.dir>/<sessionId>.json`, replaces its system prompt by the current one, registers the session (empty allow-always set), sends the replay (below) then an `available_commands_update`, and answers `{}`. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: the requested id becomes a new, empty session in the request's `cwd` (nothing is read or written), an `available_commands_update` and one `agent_message_chunk` saying the conversation starts over are sent, and the answer is `{}` — so that an editor's reload does not fail. The session's client tools are set as for `session/new`. | | |
| 35 | 35 | | `session/list` | With `sessions.enabled`: lists the sessions stored in `<cwd>/<sessions.dir>`, `cwd` being the request's, else `mm`'s own directory. Most recently updated first; a session without any question is left out. Each entry: `sessionId`, `cwd` (the one listed), `title` (the first question, one line; absent when empty), `updatedAt` (RFC 3339, UTC). One page: no `nextCursor`, `cursor` ignored. Sessions written by the line mode or the window of the same project are listed too. Without `sessions.enabled`: `method not found`. | |
| 36 | -| `session/resume` | With `sessions.enabled`: like `session/load` without the replay — reads the file, replaces its system prompt by the current one, registers the session, sends an `available_commands_update`, and answers `{}`. `mcpServers` are ignored. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: `method not found`. The session's client tools are set as for `session/new`. | | |
| 36 | +| `session/resume` | With `sessions.enabled`: like `session/load` without the replay — reads the file, replaces its system prompt by the current one, registers the session, sends an `available_commands_update`, and answers `{}`. `mcpServers` are ignored. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: as `session/load` without `sessions.enabled` — a new, empty session under the requested id, and the message saying so. The session's client tools are set as for `session/new`. | | |
| 37 | 37 | | `session/set_mode`, `session/set_config_option`, `session/close`, `logout` | `method not found`. | |
| 38 | 38 | |
| 39 | 39 | ## Skills as commands |
| @@ -16,7 +16,7 @@ | |||
| 16 | | Field | Value | | 16 | | Field | Value | |
| 17 | |-------|-------| | 17 | |-------|-------| |
| 18 | | `protocolVersion` | `1` | | 18 | | `protocolVersion` | `1` | |
| 19 | -| `agentCapabilities` | When `sessions.enabled` is set: `loadSession: true` and `sessionCapabilities: {"list": {}, "resume": {}}`; both absent (false) otherwise. Everything else absent (false): no image or audio prompts, no HTTP MCP. | | 19 | +| `agentCapabilities` | `loadSession: true`. `sessionCapabilities`: `{"list": {}, "resume": {}}` when `sessions.enabled` is set, `{"resume": {}}` otherwise. Everything else absent (false): no image or audio prompts, no HTTP MCP. | |
| 20 | | `agentInfo.name` | `acp.name` of the configuration, `bob` by default. | | 20 | | `agentInfo.name` | `acp.name` of the configuration, `bob` by default. | |
| 21 | | `agentInfo.title` | `acp.title` of the configuration, `Bob (bash-first agent)` by default; absent when empty. | | 21 | | `agentInfo.title` | `acp.title` of the configuration, `Bob (bash-first agent)` by default; absent when empty. | |
| 22 | | `agentInfo.version` | The binary's version, the same string as `mm --version` (for example `v0.1.7`). | | 22 | | `agentInfo.version` | The binary's version, the same string as `mm --version` (for example `v0.1.7`). | |
| @@ -31,9 +31,9 @@ | |||
| 31 | | `session/new` | Creates a session with a time-sortable id (`20260916-101530-4f2a9c1e`) and the request's `cwd`, a fresh history holding the system prompt, and an empty allow-always set. `mcpServers` are counted in the log and ignored. Once the response is written, sends an `available_commands_update` notification listing the slash commands (see below). With `sessions.enabled`, writes `<cwd>/<sessions.dir>/<id>.json` at once and rewrites it after every turn, `/new` and `/compact`. Before answering, gives the session its client tools: the list read at `initialize`, or, with `acp.clientTools.discovery: request`, the answer to a `_org.mini-me/tools/list` request. | | 31 | | `session/new` | Creates a session with a time-sortable id (`20260916-101530-4f2a9c1e`) and the request's `cwd`, a fresh history holding the system prompt, and an empty allow-always set. `mcpServers` are counted in the log and ignored. Once the response is written, sends an `available_commands_update` notification listing the slash commands (see below). With `sessions.enabled`, writes `<cwd>/<sessions.dir>/<id>.json` at once and rewrites it after every turn, `/new` and `/compact`. Before answering, gives the session its client tools: the list read at `initialize`, or, with `acp.clientTools.discovery: request`, the answer to a `_org.mini-me/tools/list` request. | |
| 32 | | `session/prompt` | Runs one generation on the session's history, or executes a slash command without consulting the model. Turns are serialised process-wide. | | 32 | | `session/prompt` | Runs one generation on the session's history, or executes a slash command without consulting the model. Turns are serialised process-wide. | |
| 33 | | `session/cancel` | Cancels the turn in flight for that session. A `session/prompt` that arrives during a turn never cancels it: it waits for the turn to end, then runs. Whether a message typed during a turn is sent at once, queued, or preceded by a `session/cancel` is the editor's choice. | | 33 | | `session/cancel` | Cancels the turn in flight for that session. A `session/prompt` that arrives during a turn never cancels it: it waits for the turn to end, then runs. Whether a message typed during a turn is sent at once, queued, or preceded by a `session/cancel` is the editor's choice. | |
| 34 | -| `session/load` | With `sessions.enabled`: reads `<cwd>/<sessions.dir>/<sessionId>.json`, replaces its system prompt by the current one, registers the session (empty allow-always set), sends the replay (below) then an `available_commands_update`, and answers `{}`. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: `method not found`. The session's client tools are set as for `session/new`. | | 34 | +| `session/load` | With `sessions.enabled`: reads `<cwd>/<sessions.dir>/<sessionId>.json`, replaces its system prompt by the current one, registers the session (empty allow-always set), sends the replay (below) then an `available_commands_update`, and answers `{}`. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: the requested id becomes a new, empty session in the request's `cwd` (nothing is read or written), an `available_commands_update` and one `agent_message_chunk` saying the conversation starts over are sent, and the answer is `{}` — so that an editor's reload does not fail. The session's client tools are set as for `session/new`. | |
| 35 | | `session/list` | With `sessions.enabled`: lists the sessions stored in `<cwd>/<sessions.dir>`, `cwd` being the request's, else `mm`'s own directory. Most recently updated first; a session without any question is left out. Each entry: `sessionId`, `cwd` (the one listed), `title` (the first question, one line; absent when empty), `updatedAt` (RFC 3339, UTC). One page: no `nextCursor`, `cursor` ignored. Sessions written by the line mode or the window of the same project are listed too. Without `sessions.enabled`: `method not found`. | | 35 | | `session/list` | With `sessions.enabled`: lists the sessions stored in `<cwd>/<sessions.dir>`, `cwd` being the request's, else `mm`'s own directory. Most recently updated first; a session without any question is left out. Each entry: `sessionId`, `cwd` (the one listed), `title` (the first question, one line; absent when empty), `updatedAt` (RFC 3339, UTC). One page: no `nextCursor`, `cursor` ignored. Sessions written by the line mode or the window of the same project are listed too. Without `sessions.enabled`: `method not found`. | |
| 36 | -| `session/resume` | With `sessions.enabled`: like `session/load` without the replay — reads the file, replaces its system prompt by the current one, registers the session, sends an `available_commands_update`, and answers `{}`. `mcpServers` are ignored. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: `method not found`. The session's client tools are set as for `session/new`. | | 36 | +| `session/resume` | With `sessions.enabled`: like `session/load` without the replay — reads the file, replaces its system prompt by the current one, registers the session, sends an `available_commands_update`, and answers `{}`. `mcpServers` are ignored. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: as `session/load` without `sessions.enabled` — a new, empty session under the requested id, and the message saying so. The session's client tools are set as for `session/new`. | |
| 37 | | `session/set_mode`, `session/set_config_option`, `session/close`, `logout` | `method not found`. | | 37 | | `session/set_mode`, `session/set_config_option`, `session/close`, `logout` | `method not found`. | |
| 38 | 38 | ||
| 39 | ## Skills as commands | 39 | ## Skills as commands |
modified
docs/en/reference/cli.md +18 -1 | @@ -9,6 +9,7 @@ mm [-config <path>] [-acp] [-tui] [-prompt <text>|-p <text>] [--version] [--abou | ||
| 9 | 9 | mm [-config <path>] --ask <agent> [-p <question>] (question on stdin when -p is absent) |
| 10 | 10 | mm [-config <path>] --ask-with-context <agent> [-p <question>] (context on stdin) |
| 11 | 11 | mm init [flags] (write a new agent.yaml; also mm --init) |
| 12 | +mm acp-create [flags] (mm init's wizard over ACP, for an editor) | |
| 12 | 13 | mm dockerinit [flags] (write a Dockerfile and a compose.yaml) |
| 13 | 14 | mm sbxinit [flags] (write a sbxenv.yaml for a Docker Sandbox) |
| 14 | 15 | mm skillcreate [flags] (write a new skill with a wizard) |
| @@ -209,7 +210,7 @@ A turn cancelled by the user reports nothing. `demos/struggle-signals/` triggers | ||
| 209 | 210 | | Flag | Meaning | Default | |
| 210 | 211 | |------|---------|---------| |
| 211 | 212 | | `--name` | The service's name, as lower case letters and digits joined by dashes (`Bob The Bot` gives `bob-the-bot`). The container is `<name>-box`, the image `mm-<name>:latest` | `acp.name` in the agent file, else `mm` | |
| 212 | -| `--agent` | The agent file that names the service and that the printed commands start | `agent.yaml` | | |
| 213 | +| `--agent` | The agent file that names the service and that the printed commands start. Without the flag, `.mm/agent.yaml` is looked for too, after `agent.yaml` (the order mm follows); found in `.mm/`, `compose.yaml` mounts `.mm/` read-only and the agent file's `sessions.dir` and `trace.dir` (when on and under `.mm/`) writable again, with paths relative to the project, creates those folders when missing, and starts `mm tui` with no file for `.mm/agent.yaml` | `agent.yaml`, then `.mm/agent.yaml` | | |
| 213 | 214 | | `--yes` | Ask nothing. An existing file stops everything: nothing is overwritten or written | off | |
| 214 | 215 | |
| 215 | 216 | Each existing file is asked about, the default being `no`: that file is kept, the other is written. Exit codes: `0` done, `1` nothing written because a file exists and nobody could answer, or a write failed, `2` wrong flags or arguments. |
| @@ -267,6 +268,22 @@ An agent file outside the workspace only gives a warning: see [when the agent fi | ||
| 267 | 268 | | After writing | The file is loaded as mm loads a configuration; an error is printed and the exit code is 1. | |
| 268 | 269 | | Exit codes | 0 written (and checked); 1 an unknown provider, usage or machine, no model, an existing file, a file mm does not load, a failed check; 2 a bad flag. | |
| 269 | 270 | |
| 271 | +## `mm acp-create` — the agent.yaml wizard over ACP | |
| 272 | + | |
| 273 | +`mm acp-create` (also `--acp-create`, `-acp-create`) serves the questions of [`mm init`](#mm-init--the-agentyaml-wizard) over the Agent Client Protocol on stdin/stdout, for an editor or any ACP client. It is answered before any configuration is read and calls no model (it lists the model server's models, as `mm init` does). See [How to create an agent from your editor](../how-to/create-an-agent-from-an-editor.md). | |
| 274 | + | |
| 275 | +| Aspect | Behaviour | | |
| 276 | +|--------|-----------| | |
| 277 | +| Flags | Those of `mm init` except `--check`, `--yes` and `--line`; a question whose flag is given is not asked. A refused flag value stops `mm` at start (exit 1). `-h` and errors go to stderr: stdout carries JSON-RPC only. | | |
| 278 | +| `initialize` | `agentInfo.name` `mm-acp-create`, title `mm init (agent.yaml wizard)`; `loadSession: true` and `sessionCapabilities.resume`, no authentication method. | | |
| 279 | +| `session/load`, `session/resume` | Nothing is kept across a restart: a fresh wizard is registered under the requested id, in the given `cwd`, then the commands and one message saying the wizard starts over (no replay). A file already written is untouched. This is what a client's "reload agent" uses. | | |
| 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. | | |
| 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). | | |
| 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. | | |
| 286 | + | |
| 270 | 287 | ## `mm skillcreate` — the skill wizard |
| 271 | 288 | |
| 272 | 289 | `mm skillcreate` (also `--skillcreate`, `-skillcreate`) asks for a skill and writes it as `<skillsDir>/<name>/SKILL.md`. It is answered before any agent starts. Each question below has a flag; a question whose flag is given is not asked. See [How to add a skill](../how-to/add-a-skill.md). |
| @@ -9,6 +9,7 @@ mm [-config <path>] [-acp] [-tui] [-prompt <text>|-p <text>] [--version] [--abou | |||
| 9 | mm [-config <path>] --ask <agent> [-p <question>] (question on stdin when -p is absent) | 9 | mm [-config <path>] --ask <agent> [-p <question>] (question on stdin when -p is absent) |
| 10 | mm [-config <path>] --ask-with-context <agent> [-p <question>] (context on stdin) | 10 | mm [-config <path>] --ask-with-context <agent> [-p <question>] (context on stdin) |
| 11 | mm init [flags] (write a new agent.yaml; also mm --init) | 11 | mm init [flags] (write a new agent.yaml; also mm --init) |
| 12 | +mm acp-create [flags] (mm init's wizard over ACP, for an editor) | ||
| 12 | mm dockerinit [flags] (write a Dockerfile and a compose.yaml) | 13 | mm dockerinit [flags] (write a Dockerfile and a compose.yaml) |
| 13 | mm sbxinit [flags] (write a sbxenv.yaml for a Docker Sandbox) | 14 | mm sbxinit [flags] (write a sbxenv.yaml for a Docker Sandbox) |
| 14 | mm skillcreate [flags] (write a new skill with a wizard) | 15 | mm skillcreate [flags] (write a new skill with a wizard) |
| @@ -209,7 +210,7 @@ A turn cancelled by the user reports nothing. `demos/struggle-signals/` triggers | |||
| 209 | | Flag | Meaning | Default | | 210 | | Flag | Meaning | Default | |
| 210 | |------|---------|---------| | 211 | |------|---------|---------| |
| 211 | | `--name` | The service's name, as lower case letters and digits joined by dashes (`Bob The Bot` gives `bob-the-bot`). The container is `<name>-box`, the image `mm-<name>:latest` | `acp.name` in the agent file, else `mm` | | 212 | | `--name` | The service's name, as lower case letters and digits joined by dashes (`Bob The Bot` gives `bob-the-bot`). The container is `<name>-box`, the image `mm-<name>:latest` | `acp.name` in the agent file, else `mm` | |
| 212 | -| `--agent` | The agent file that names the service and that the printed commands start | `agent.yaml` | | 213 | +| `--agent` | The agent file that names the service and that the printed commands start. Without the flag, `.mm/agent.yaml` is looked for too, after `agent.yaml` (the order mm follows); found in `.mm/`, `compose.yaml` mounts `.mm/` read-only and the agent file's `sessions.dir` and `trace.dir` (when on and under `.mm/`) writable again, with paths relative to the project, creates those folders when missing, and starts `mm tui` with no file for `.mm/agent.yaml` | `agent.yaml`, then `.mm/agent.yaml` | |
| 213 | | `--yes` | Ask nothing. An existing file stops everything: nothing is overwritten or written | off | | 214 | | `--yes` | Ask nothing. An existing file stops everything: nothing is overwritten or written | off | |
| 214 | 215 | ||
| 215 | Each existing file is asked about, the default being `no`: that file is kept, the other is written. Exit codes: `0` done, `1` nothing written because a file exists and nobody could answer, or a write failed, `2` wrong flags or arguments. | 216 | Each existing file is asked about, the default being `no`: that file is kept, the other is written. Exit codes: `0` done, `1` nothing written because a file exists and nobody could answer, or a write failed, `2` wrong flags or arguments. |
| @@ -267,6 +268,22 @@ An agent file outside the workspace only gives a warning: see [when the agent fi | |||
| 267 | | After writing | The file is loaded as mm loads a configuration; an error is printed and the exit code is 1. | | 268 | | After writing | The file is loaded as mm loads a configuration; an error is printed and the exit code is 1. | |
| 268 | | Exit codes | 0 written (and checked); 1 an unknown provider, usage or machine, no model, an existing file, a file mm does not load, a failed check; 2 a bad flag. | | 269 | | Exit codes | 0 written (and checked); 1 an unknown provider, usage or machine, no model, an existing file, a file mm does not load, a failed check; 2 a bad flag. | |
| 269 | 270 | ||
| 271 | +## `mm acp-create` — the agent.yaml wizard over ACP | ||
| 272 | + | ||
| 273 | +`mm acp-create` (also `--acp-create`, `-acp-create`) serves the questions of [`mm init`](#mm-init--the-agentyaml-wizard) over the Agent Client Protocol on stdin/stdout, for an editor or any ACP client. It is answered before any configuration is read and calls no model (it lists the model server's models, as `mm init` does). See [How to create an agent from your editor](../how-to/create-an-agent-from-an-editor.md). | ||
| 274 | + | ||
| 275 | +| Aspect | Behaviour | | ||
| 276 | +|--------|-----------| | ||
| 277 | +| Flags | Those of `mm init` except `--check`, `--yes` and `--line`; a question whose flag is given is not asked. A refused flag value stops `mm` at start (exit 1). `-h` and errors go to stderr: stdout carries JSON-RPC only. | | ||
| 278 | +| `initialize` | `agentInfo.name` `mm-acp-create`, title `mm init (agent.yaml wizard)`; `loadSession: true` and `sessionCapabilities.resume`, no authentication method. | | ||
| 279 | +| `session/load`, `session/resume` | Nothing is kept across a restart: a fresh wizard is registered under the requested id, in the given `cwd`, then the commands and one message saying the wizard starts over (no replay). A file already written is untouched. This is what a client's "reload agent" uses. | | ||
| 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. | | ||
| 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). | | ||
| 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. | | ||
| 286 | + | ||
| 270 | ## `mm skillcreate` — the skill wizard | 287 | ## `mm skillcreate` — the skill wizard |
| 271 | 288 | ||
| 272 | `mm skillcreate` (also `--skillcreate`, `-skillcreate`) asks for a skill and writes it as `<skillsDir>/<name>/SKILL.md`. It is answered before any agent starts. Each question below has a flag; a question whose flag is given is not asked. See [How to add a skill](../how-to/add-a-skill.md). | 289 | `mm skillcreate` (also `--skillcreate`, `-skillcreate`) asks for a skill and writes it as `<skillsDir>/<name>/SKILL.md`. It is answered before any agent starts. Each question below has a flag; a question whose flag is given is not asked. See [How to add a skill](../how-to/add-a-skill.md). |
modified
docs/en/reference/configuration.md +1 -1 | @@ -70,7 +70,7 @@ Where `mm` looks for this file: [Configuration file resolution](cli.md#configura | ||
| 70 | 70 | |
| 71 | 71 | | Key | Type | Default | Description | |
| 72 | 72 | |-----|------|---------|-------------| |
| 73 | -| `sessions.enabled` | bool | `false` | Save every conversation to a file, rewritten after each turn, `/compact` and `/new`. The file also records, outside the history, every error shown (`errors` array: failed generation, failed compaction, failed `/resume`). Enables `/sessions` and `/resume` in the terminal and `session/load` over ACP. | | |
| 73 | +| `sessions.enabled` | bool | `false` | Save every conversation to a file, rewritten after each turn, `/compact` and `/new`. The file also records, outside the history, every error shown (`errors` array: failed generation, failed compaction, failed `/resume`). Enables `/sessions` and `/resume` in the terminal and, over ACP, `session/list` and a reload (`session/load`, `session/resume`) that brings the conversation back. Off, an editor's reload still succeeds but the conversation starts over. | | |
| 74 | 74 | | `sessions.dir` | string | `.mm/sessions` | Directory of the session files, one `<id>.json` each. Relative to the directory `mm` is started from (terminal) or to the session's `cwd` (ACP); an absolute path is used as-is. Must not be empty. | |
| 75 | 75 | | `sessions.searchMemory` | bool | `false` | The `/memory <words>` command (terminal, window, editor): searches the stored sessions of the project. Needs `sessions.enabled`. See [slash commands](slash-commands.md) and [How to search earlier sessions](../how-to/search-earlier-sessions.md). | |
| 76 | 76 | | `sessions.autoSearchMemory` | bool | `false` | The [`search_memory`](tools.md#search_memory) tool: the same search, called by the model. Independent of `searchMemory`. Needs `sessions.enabled`. | |
| @@ -70,7 +70,7 @@ Where `mm` looks for this file: [Configuration file resolution](cli.md#configura | |||
| 70 | 70 | ||
| 71 | | Key | Type | Default | Description | | 71 | | Key | Type | Default | Description | |
| 72 | |-----|------|---------|-------------| | 72 | |-----|------|---------|-------------| |
| 73 | -| `sessions.enabled` | bool | `false` | Save every conversation to a file, rewritten after each turn, `/compact` and `/new`. The file also records, outside the history, every error shown (`errors` array: failed generation, failed compaction, failed `/resume`). Enables `/sessions` and `/resume` in the terminal and `session/load` over ACP. | | 73 | +| `sessions.enabled` | bool | `false` | Save every conversation to a file, rewritten after each turn, `/compact` and `/new`. The file also records, outside the history, every error shown (`errors` array: failed generation, failed compaction, failed `/resume`). Enables `/sessions` and `/resume` in the terminal and, over ACP, `session/list` and a reload (`session/load`, `session/resume`) that brings the conversation back. Off, an editor's reload still succeeds but the conversation starts over. | |
| 74 | | `sessions.dir` | string | `.mm/sessions` | Directory of the session files, one `<id>.json` each. Relative to the directory `mm` is started from (terminal) or to the session's `cwd` (ACP); an absolute path is used as-is. Must not be empty. | | 74 | | `sessions.dir` | string | `.mm/sessions` | Directory of the session files, one `<id>.json` each. Relative to the directory `mm` is started from (terminal) or to the session's `cwd` (ACP); an absolute path is used as-is. Must not be empty. | |
| 75 | | `sessions.searchMemory` | bool | `false` | The `/memory <words>` command (terminal, window, editor): searches the stored sessions of the project. Needs `sessions.enabled`. See [slash commands](slash-commands.md) and [How to search earlier sessions](../how-to/search-earlier-sessions.md). | | 75 | | `sessions.searchMemory` | bool | `false` | The `/memory <words>` command (terminal, window, editor): searches the stored sessions of the project. Needs `sessions.enabled`. See [slash commands](slash-commands.md) and [How to search earlier sessions](../how-to/search-earlier-sessions.md). | |
| 76 | | `sessions.autoSearchMemory` | bool | `false` | The [`search_memory`](tools.md#search_memory) tool: the same search, called by the model. Independent of `searchMemory`. Needs `sessions.enabled`. | | 76 | | `sessions.autoSearchMemory` | bool | `false` | The [`search_memory`](tools.md#search_memory) tool: the same search, called by the model. Independent of `searchMemory`. Needs `sessions.enabled`. | |
modified
docs/fr/README.md +1 -0 | @@ -12,6 +12,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | ||
| 12 | 12 | ## Guides pratiques — accomplir une tâche |
| 13 | 13 | |
| 14 | 14 | - [Créer un agent avec `mm init`](how-to/create-an-agent.md) — un assistant qui écrit un agent.yaml commenté à partir des faits du serveur, d'une table de familles, de l'usage et de la machine |
| 15 | +- [Créer un agent depuis votre éditeur (`mm acp-create`)](how-to/create-an-agent-from-an-editor.md) — le même assistant par ACP, depuis Zed ou tout client ACP : des formulaires quand le client sait les afficher, une conversation sinon | |
| 15 | 16 | - [Faire tourner mini-me sur llama.cpp plutôt que Docker Model Runner](how-to/run-with-llama-cpp.md) |
| 16 | 17 | - [Faire tourner mini-me sur llmman](how-to/run-with-llmman.md) — `provider: llmman`, la config de démo dans `demos/llmman/` |
| 17 | 18 | - [Faire tourner mini-me sur Ollama](how-to/run-with-ollama.md) — `provider: ollama`, la config de démo dans `demos/ollama/` |
| @@ -12,6 +12,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | |||
| 12 | ## Guides pratiques — accomplir une tâche | 12 | ## Guides pratiques — accomplir une tâche |
| 13 | 13 | ||
| 14 | - [Créer un agent avec `mm init`](how-to/create-an-agent.md) — un assistant qui écrit un agent.yaml commenté à partir des faits du serveur, d'une table de familles, de l'usage et de la machine | 14 | - [Créer un agent avec `mm init`](how-to/create-an-agent.md) — un assistant qui écrit un agent.yaml commenté à partir des faits du serveur, d'une table de familles, de l'usage et de la machine |
| 15 | +- [Créer un agent depuis votre éditeur (`mm acp-create`)](how-to/create-an-agent-from-an-editor.md) — le même assistant par ACP, depuis Zed ou tout client ACP : des formulaires quand le client sait les afficher, une conversation sinon | ||
| 15 | - [Faire tourner mini-me sur llama.cpp plutôt que Docker Model Runner](how-to/run-with-llama-cpp.md) | 16 | - [Faire tourner mini-me sur llama.cpp plutôt que Docker Model Runner](how-to/run-with-llama-cpp.md) |
| 16 | - [Faire tourner mini-me sur llmman](how-to/run-with-llmman.md) — `provider: llmman`, la config de démo dans `demos/llmman/` | 17 | - [Faire tourner mini-me sur llmman](how-to/run-with-llmman.md) — `provider: llmman`, la config de démo dans `demos/llmman/` |
| 17 | - [Faire tourner mini-me sur Ollama](how-to/run-with-ollama.md) — `provider: ollama`, la config de démo dans `demos/ollama/` | 18 | - [Faire tourner mini-me sur Ollama](how-to/run-with-ollama.md) — `provider: ollama`, la config de démo dans `demos/ollama/` |
modified
docs/fr/explanation/mm-init.md +1 -0 | @@ -118,3 +118,4 @@ L'assistant n'écrase un fichier existant que si vous l'acceptez, et jamais avec | ||
| 118 | 118 | - [Compression du contexte](context-compression.md) : ce que déclenche `context.threshold`. |
| 119 | 119 | - [Sortie structurée](structured-output.md) : pourquoi l'usage `json` n'a pas d'outils. |
| 120 | 120 | - [Interroger un autre agent](asking-another-agent.md) : ce sur quoi s'appuie l'usage `coordinator`. |
| 121 | +- [Créer un agent depuis votre éditeur](../how-to/create-an-agent-from-an-editor.md) : les mêmes questions par ACP (`mm acp-create`). L'état de l'assistant est séparé de sa présentation : le mode ligne, la fenêtre et le mode ACP pilotent un même déroulé et écrivent le même fichier. | |
| @@ -118,3 +118,4 @@ L'assistant n'écrase un fichier existant que si vous l'acceptez, et jamais avec | |||
| 118 | - [Compression du contexte](context-compression.md) : ce que déclenche `context.threshold`. | 118 | - [Compression du contexte](context-compression.md) : ce que déclenche `context.threshold`. |
| 119 | - [Sortie structurée](structured-output.md) : pourquoi l'usage `json` n'a pas d'outils. | 119 | - [Sortie structurée](structured-output.md) : pourquoi l'usage `json` n'a pas d'outils. |
| 120 | - [Interroger un autre agent](asking-another-agent.md) : ce sur quoi s'appuie l'usage `coordinator`. | 120 | - [Interroger un autre agent](asking-another-agent.md) : ce sur quoi s'appuie l'usage `coordinator`. |
| 121 | +- [Créer un agent depuis votre éditeur](../how-to/create-an-agent-from-an-editor.md) : les mêmes questions par ACP (`mm acp-create`). L'état de l'assistant est séparé de sa présentation : le mode ligne, la fenêtre et le mode ACP pilotent un même déroulé et écrivent le même fichier. | ||
modified
docs/fr/explanation/persistent-sessions.md +4 -0 | @@ -26,6 +26,10 @@ Ce choix tient toujours, mais il n'est plus exclusif. Quand il a été fait, `se | ||
| 26 | 26 | |
| 27 | 27 | Une session rechargée ou reprise continue sous le prompt système actuel, pas celui enregistré avec elle : le prompt appartient à l'agent, et un changement d'`agent.yaml` doit atteindre aussi les anciennes conversations. |
| 28 | 28 | |
| 29 | +## Sans sessions enregistrées, un rechargement recommence | |
| 30 | + | |
| 31 | +Jusqu'au 14 novembre 2026, un agent sans `sessions.enabled` n'annonçait ni `loadSession` ni `session/resume` : il n'y avait rien à relire, et le dire honnêtement renseignait l'éditeur. Zed répondait alors à son propre Reload Agent par « Loading or resuming sessions is not supported by this agent », et le fil était mort — `mm init` écrit `sessions` désactivé par défaut, donc la plupart des agents étaient dans ce cas. Depuis, les deux sont toujours annoncés. Sans sessions enregistrées, l'identifiant que demande l'éditeur devient une nouvelle session vide, et un message dans le fil dit que l'historique est perdu et comment le garder la prochaine fois (`sessions.enabled`). Un rechargement n'échoue donc jamais ; ce qu'il ramène dépend du réglage. `session/list` n'existe toujours qu'avec des sessions enregistrées : un sélecteur d'historique qui n'a rien à lister n'aide personne. `mm acp-create` fait de même : son assistant ne garde rien sur disque, et un rechargement le relance. | |
| 32 | + | |
| 29 | 33 | ## `/new` dans les deux façades |
| 30 | 34 | |
| 31 | 35 | Dans le terminal, `/new` laisse simplement l'ancien fichier tel quel et la question suivante en ouvre un nouveau. En ACP, l'éditeur possède l'identifiant de session et continue de l'utiliser : le fichier de cet identifiant doit devenir la session neuve, et la conversation écoulée est d'abord déplacée sous un nouvel identifiant. Dans les deux cas rien n'est perdu sur disque, et un rechargement ultérieur du fil rend la conversation courante, pas celle que `/new` a fermée. |
| @@ -26,6 +26,10 @@ Ce choix tient toujours, mais il n'est plus exclusif. Quand il a été fait, `se | |||
| 26 | 26 | ||
| 27 | Une session rechargée ou reprise continue sous le prompt système actuel, pas celui enregistré avec elle : le prompt appartient à l'agent, et un changement d'`agent.yaml` doit atteindre aussi les anciennes conversations. | 27 | Une session rechargée ou reprise continue sous le prompt système actuel, pas celui enregistré avec elle : le prompt appartient à l'agent, et un changement d'`agent.yaml` doit atteindre aussi les anciennes conversations. |
| 28 | 28 | ||
| 29 | +## Sans sessions enregistrées, un rechargement recommence | ||
| 30 | + | ||
| 31 | +Jusqu'au 14 novembre 2026, un agent sans `sessions.enabled` n'annonçait ni `loadSession` ni `session/resume` : il n'y avait rien à relire, et le dire honnêtement renseignait l'éditeur. Zed répondait alors à son propre Reload Agent par « Loading or resuming sessions is not supported by this agent », et le fil était mort — `mm init` écrit `sessions` désactivé par défaut, donc la plupart des agents étaient dans ce cas. Depuis, les deux sont toujours annoncés. Sans sessions enregistrées, l'identifiant que demande l'éditeur devient une nouvelle session vide, et un message dans le fil dit que l'historique est perdu et comment le garder la prochaine fois (`sessions.enabled`). Un rechargement n'échoue donc jamais ; ce qu'il ramène dépend du réglage. `session/list` n'existe toujours qu'avec des sessions enregistrées : un sélecteur d'historique qui n'a rien à lister n'aide personne. `mm acp-create` fait de même : son assistant ne garde rien sur disque, et un rechargement le relance. | ||
| 32 | + | ||
| 29 | ## `/new` dans les deux façades | 33 | ## `/new` dans les deux façades |
| 30 | 34 | ||
| 31 | Dans le terminal, `/new` laisse simplement l'ancien fichier tel quel et la question suivante en ouvre un nouveau. En ACP, l'éditeur possède l'identifiant de session et continue de l'utiliser : le fichier de cet identifiant doit devenir la session neuve, et la conversation écoulée est d'abord déplacée sous un nouvel identifiant. Dans les deux cas rien n'est perdu sur disque, et un rechargement ultérieur du fil rend la conversation courante, pas celle que `/new` a fermée. | 35 | Dans le terminal, `/new` laisse simplement l'ancien fichier tel quel et la question suivante en ouvre un nouveau. En ACP, l'éditeur possède l'identifiant de session et continue de l'utiliser : le fichier de cet identifiant doit devenir la session neuve, et la conversation écoulée est d'abord déplacée sous un nouvel identifiant. Dans les deux cas rien n'est perdu sur disque, et un rechargement ultérieur du fil rend la conversation courante, pas celle que `/new` a fermée. |
added
docs/fr/how-to/create-an-agent-from-an-editor.md +61 -0 | new file mode 100644 | ||
| @@ -0,0 +1,61 @@ | ||
| 1 | +# Créer un agent depuis votre éditeur (`mm acp-create`) | |
| 2 | + | |
| 3 | +Ce guide montre comment créer un agent.yaml sans terminal, depuis n'importe quel éditeur ou outil qui parle l'Agent Client Protocol (Zed, un IDE JetBrains, un plugin Neovim…). `mm acp-create` pose les questions de [`mm init`](create-an-agent.md) par ACP et écrit le même fichier. Il suppose que `mm` est installé dans votre `PATH` et qu'un serveur de modèles tourne (l'assistant liste ses modèles). | |
| 4 | + | |
| 5 | +## Étapes | |
| 6 | + | |
| 7 | +1. Déclarez `mm acp-create` comme serveur d'agent dans votre client. Dans Zed, ajoutez à `settings.json` : | |
| 8 | + | |
| 9 | + ```json | |
| 10 | + { | |
| 11 | + "agent_servers": { | |
| 12 | + "mm-create": { | |
| 13 | + "type": "custom", | |
| 14 | + "command": "mm", | |
| 15 | + "args": ["acp-create"] | |
| 16 | + } | |
| 17 | + } | |
| 18 | + } | |
| 19 | + ``` | |
| 20 | + | |
| 21 | + Les autres clients demandent les deux mêmes choses : la commande `mm` et l'argument `acp-create`. Aucun fichier de configuration n'est nécessaire : ce mode n'en lit aucun. | |
| 22 | + | |
| 23 | +2. Ouvrez le projet dans lequel l'agent travaillera, démarrez une nouvelle conversation avec `mm-create` et envoyez `/start` (ou n'importe quel message). L'assistant démarre. Le fichier sera écrit dans le répertoire du projet — le répertoire de travail de la session — et non là où le client a lancé `mm`. | |
| 24 | + | |
| 25 | +3. Répondez aux questions. Leur forme dépend de ce que votre client sait afficher : | |
| 26 | + | |
| 27 | + - **Un client qui affiche des formulaires** (il annonce la capacité `elicitation.form`) : les questions arrivent en quelques formulaires, valeurs par défaut déjà remplies. Une question dont les choix dépendent d'une réponse précédente — la liste des modèles du serveur choisi — arrive dans le formulaire suivant. | |
| 28 | + - **Tout autre client** : chaque question est un message, avec des choix numérotés. Répondez par un numéro ou une valeur ; `.` prend la valeur par défaut. | |
| 29 | + | |
| 30 | +4. Lisez le message de fin. Il affiche `✅ wrote agent.yaml` et `✅ mm loads it`, puis la commande qui lance l'agent, `mm acp /chemin/absolu/vers/agent.yaml`. Zed reçoit une entrée `agent_servers` toute prête ; pour un autre client, utilisez cette ligne de commande. | |
| 31 | + | |
| 32 | +5. Pour faire tourner l'agent dans un conteneur ou un Docker Sandbox plutôt que sur votre machine, envoyez `/dockerinit` (écrit `Dockerfile` et `compose.yaml`) ou `/sbxinit` (écrit `sbxenv.yaml`) dans la même conversation. Voir [faire tourner mini-me dans un conteneur](run-in-a-container.md) et [faire tourner mini-me dans un Docker Sandbox](run-in-a-docker-sandbox.md). | |
| 33 | + | |
| 34 | +6. Envoyez `/zedconfig`. Il affiche les entrées `agent_servers` de chaque façon dont le projet peut maintenant lancer son agent — sur votre machine, dans son conteneur, dans sa sandbox — et ce qu'il faut lancer avant pour chacune. Copiez celles que vous voulez dans les réglages de votre client, et démarrez une conversation avec l'une d'elles : c'est votre agent. | |
| 35 | + | |
| 36 | +## Commandes pendant l'assistant | |
| 37 | + | |
| 38 | +| Commande | Effet | | |
| 39 | +|----------|-------| | |
| 40 | +| `/start` | Démarre l'assistant ; une fois démarré, repose la question en cours. | | |
| 41 | +| `/preview` | Affiche l'agent.yaml tel qu'il serait écrit maintenant. | | |
| 42 | +| `/back` | Reprend la dernière réponse et repose la question. | | |
| 43 | +| `/write` | Prend la valeur par défaut de chaque question restante et écrit le fichier (remplacer un fichier existant reste demandé). | | |
| 44 | +| `/restart` | Oublie toutes les réponses et recommence. | | |
| 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. | | |
| 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 | +| `/help` | Liste ces commandes. | | |
| 49 | + | |
| 50 | +## Variantes | |
| 51 | + | |
| 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"]`. | |
| 53 | +- **La disposition `.mm`.** Répondez `yes` à *Keep everything in a .mm folder?* : le fichier est `.mm/agent.yaml`, voir [Ranger les fichiers mini-me d'un projet dans un dossier `.mm`](keep-the-config-in-a-mm-folder.md). | |
| 54 | +- **Refuser un formulaire** n'écrit rien. Envoyez un autre message pour reprendre là où vous vous êtes arrêté. | |
| 55 | +- **Recharger l'agent** (*Reload Agent* dans Zed) relance l'assistant à la première question : les réponses ne sont pas gardées. Un fichier déjà écrit reste tel quel. | |
| 56 | + | |
| 57 | +## Voir aussi | |
| 58 | + | |
| 59 | +- [Créer un agent avec `mm init`](create-an-agent.md) — le même assistant dans un terminal. | |
| 60 | +- [Utiliser mini-me depuis un éditeur (ACP)](use-from-an-editor.md) — faire tourner l'agent créé. | |
| 61 | +- [Référence : ligne de commande — `mm acp-create`](../reference/cli.md#mm-acp-create--lassistant-agentyaml-par-acp) | |
| new file mode 100644 | |||
| @@ -0,0 +1,61 @@ | |||
| 1 | +# Créer un agent depuis votre éditeur (`mm acp-create`) | ||
| 2 | + | ||
| 3 | +Ce guide montre comment créer un agent.yaml sans terminal, depuis n'importe quel éditeur ou outil qui parle l'Agent Client Protocol (Zed, un IDE JetBrains, un plugin Neovim…). `mm acp-create` pose les questions de [`mm init`](create-an-agent.md) par ACP et écrit le même fichier. Il suppose que `mm` est installé dans votre `PATH` et qu'un serveur de modèles tourne (l'assistant liste ses modèles). | ||
| 4 | + | ||
| 5 | +## Étapes | ||
| 6 | + | ||
| 7 | +1. Déclarez `mm acp-create` comme serveur d'agent dans votre client. Dans Zed, ajoutez à `settings.json` : | ||
| 8 | + | ||
| 9 | + ```json | ||
| 10 | + { | ||
| 11 | + "agent_servers": { | ||
| 12 | + "mm-create": { | ||
| 13 | + "type": "custom", | ||
| 14 | + "command": "mm", | ||
| 15 | + "args": ["acp-create"] | ||
| 16 | + } | ||
| 17 | + } | ||
| 18 | + } | ||
| 19 | + ``` | ||
| 20 | + | ||
| 21 | + Les autres clients demandent les deux mêmes choses : la commande `mm` et l'argument `acp-create`. Aucun fichier de configuration n'est nécessaire : ce mode n'en lit aucun. | ||
| 22 | + | ||
| 23 | +2. Ouvrez le projet dans lequel l'agent travaillera, démarrez une nouvelle conversation avec `mm-create` et envoyez `/start` (ou n'importe quel message). L'assistant démarre. Le fichier sera écrit dans le répertoire du projet — le répertoire de travail de la session — et non là où le client a lancé `mm`. | ||
| 24 | + | ||
| 25 | +3. Répondez aux questions. Leur forme dépend de ce que votre client sait afficher : | ||
| 26 | + | ||
| 27 | + - **Un client qui affiche des formulaires** (il annonce la capacité `elicitation.form`) : les questions arrivent en quelques formulaires, valeurs par défaut déjà remplies. Une question dont les choix dépendent d'une réponse précédente — la liste des modèles du serveur choisi — arrive dans le formulaire suivant. | ||
| 28 | + - **Tout autre client** : chaque question est un message, avec des choix numérotés. Répondez par un numéro ou une valeur ; `.` prend la valeur par défaut. | ||
| 29 | + | ||
| 30 | +4. Lisez le message de fin. Il affiche `✅ wrote agent.yaml` et `✅ mm loads it`, puis la commande qui lance l'agent, `mm acp /chemin/absolu/vers/agent.yaml`. Zed reçoit une entrée `agent_servers` toute prête ; pour un autre client, utilisez cette ligne de commande. | ||
| 31 | + | ||
| 32 | +5. Pour faire tourner l'agent dans un conteneur ou un Docker Sandbox plutôt que sur votre machine, envoyez `/dockerinit` (écrit `Dockerfile` et `compose.yaml`) ou `/sbxinit` (écrit `sbxenv.yaml`) dans la même conversation. Voir [faire tourner mini-me dans un conteneur](run-in-a-container.md) et [faire tourner mini-me dans un Docker Sandbox](run-in-a-docker-sandbox.md). | ||
| 33 | + | ||
| 34 | +6. Envoyez `/zedconfig`. Il affiche les entrées `agent_servers` de chaque façon dont le projet peut maintenant lancer son agent — sur votre machine, dans son conteneur, dans sa sandbox — et ce qu'il faut lancer avant pour chacune. Copiez celles que vous voulez dans les réglages de votre client, et démarrez une conversation avec l'une d'elles : c'est votre agent. | ||
| 35 | + | ||
| 36 | +## Commandes pendant l'assistant | ||
| 37 | + | ||
| 38 | +| Commande | Effet | | ||
| 39 | +|----------|-------| | ||
| 40 | +| `/start` | Démarre l'assistant ; une fois démarré, repose la question en cours. | | ||
| 41 | +| `/preview` | Affiche l'agent.yaml tel qu'il serait écrit maintenant. | | ||
| 42 | +| `/back` | Reprend la dernière réponse et repose la question. | | ||
| 43 | +| `/write` | Prend la valeur par défaut de chaque question restante et écrit le fichier (remplacer un fichier existant reste demandé). | | ||
| 44 | +| `/restart` | Oublie toutes les réponses et recommence. | | ||
| 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. | | ||
| 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 | +| `/help` | Liste ces commandes. | | ||
| 49 | + | ||
| 50 | +## Variantes | ||
| 51 | + | ||
| 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"]`. | ||
| 53 | +- **La disposition `.mm`.** Répondez `yes` à *Keep everything in a .mm folder?* : le fichier est `.mm/agent.yaml`, voir [Ranger les fichiers mini-me d'un projet dans un dossier `.mm`](keep-the-config-in-a-mm-folder.md). | ||
| 54 | +- **Refuser un formulaire** n'écrit rien. Envoyez un autre message pour reprendre là où vous vous êtes arrêté. | ||
| 55 | +- **Recharger l'agent** (*Reload Agent* dans Zed) relance l'assistant à la première question : les réponses ne sont pas gardées. Un fichier déjà écrit reste tel quel. | ||
| 56 | + | ||
| 57 | +## Voir aussi | ||
| 58 | + | ||
| 59 | +- [Créer un agent avec `mm init`](create-an-agent.md) — le même assistant dans un terminal. | ||
| 60 | +- [Utiliser mini-me depuis un éditeur (ACP)](use-from-an-editor.md) — faire tourner l'agent créé. | ||
| 61 | +- [Référence : ligne de commande — `mm acp-create`](../reference/cli.md#mm-acp-create--lassistant-agentyaml-par-acp) | ||
modified
docs/fr/how-to/create-an-agent.md +1 -0 | @@ -90,6 +90,7 @@ Ce guide montre comment écrire un nouvel agent.yaml pour un modèle et une tâc | ||
| 90 | 90 | |
| 91 | 91 | ## Voir aussi |
| 92 | 92 | |
| 93 | +- [Créer un agent depuis votre éditeur (`mm acp-create`)](create-an-agent-from-an-editor.md) — le même assistant par ACP | |
| 93 | 94 | - [Référence de la ligne de commande — `mm init`](../reference/cli.md#mm-init--lassistant-agentyaml) : chaque option, les sources, les codes de sortie |
| 94 | 95 | - [Référence de la configuration](../reference/configuration.md) : chaque clé du fichier |
| 95 | 96 | - [Réglages des agents pour petits modèles](../../../guides/reglages-agents-petits-modeles.md) : ce que fait chaque réglage, les lignes par machine |
| @@ -90,6 +90,7 @@ Ce guide montre comment écrire un nouvel agent.yaml pour un modèle et une tâc | |||
| 90 | 90 | ||
| 91 | ## Voir aussi | 91 | ## Voir aussi |
| 92 | 92 | ||
| 93 | +- [Créer un agent depuis votre éditeur (`mm acp-create`)](create-an-agent-from-an-editor.md) — le même assistant par ACP | ||
| 93 | - [Référence de la ligne de commande — `mm init`](../reference/cli.md#mm-init--lassistant-agentyaml) : chaque option, les sources, les codes de sortie | 94 | - [Référence de la ligne de commande — `mm init`](../reference/cli.md#mm-init--lassistant-agentyaml) : chaque option, les sources, les codes de sortie |
| 94 | - [Référence de la configuration](../reference/configuration.md) : chaque clé du fichier | 95 | - [Référence de la configuration](../reference/configuration.md) : chaque clé du fichier |
| 95 | - [Réglages des agents pour petits modèles](../../../guides/reglages-agents-petits-modeles.md) : ce que fait chaque réglage, les lignes par machine | 96 | - [Réglages des agents pour petits modèles](../../../guides/reglages-agents-petits-modeles.md) : ce que fait chaque réglage, les lignes par machine |
modified
docs/fr/how-to/run-in-a-container.md +18 -1 | @@ -22,6 +22,23 @@ Le service prend le nom de `acp.name` dans `agent.yaml`, ou `mm` s'il n'y en a p | ||
| 22 | 22 | |
| 23 | 23 | Si un fichier existe déjà, mm demande `Overwrite Dockerfile?`, et `no` est la réponse par défaut. Avec `--yes`, ou quand l'entrée se termine, rien n'est écrit. |
| 24 | 24 | |
| 25 | +### Un projet avec un dossier `.mm` | |
| 26 | + | |
| 27 | +Quand l'agent est `.mm/agent.yaml` (créé avec `mm init --project yes`, voir [ranger les fichiers mini-me d'un projet dans un dossier `.mm`](keep-the-config-in-a-mm-folder.md)), `mm dockerinit` le trouve sans option — il cherche `agent.yaml`, puis `.mm/agent.yaml`, comme mm — et nomme le service d'après lui. `compose.yaml` alors : | |
| 28 | + | |
| 29 | +- monte `.mm/` **en lecture seule** sur le projet : l'agent ne peut pas modifier ses propres réglages, skills ou hooks ; | |
| 30 | +- remonte en écriture les dossiers où mm écrit — le `sessions.dir` et le `trace.dir` du fichier d'agent, quand ils sont activés et sous `.mm/` — et les crée tout de suite s'ils n'existent pas (Docker les créerait au nom de root, et l'agent ne tourne jamais en root) ; | |
| 31 | +- lance `mm tui` sans fichier : mm trouve `.mm/agent.yaml` depuis le projet. | |
| 32 | + | |
| 33 | +```yaml | |
| 34 | + volumes: | |
| 35 | + - .:${PWD:-/workspace} | |
| 36 | + - ./.mm:${PWD:-/workspace}/.mm:ro | |
| 37 | + - ./.mm/sessions:${PWD:-/workspace}/.mm/sessions # sessions.dir | |
| 38 | +``` | |
| 39 | + | |
| 40 | +Tout ce qui est écrit d'autre sous `.mm/` — un hook qui y tient un journal — demande sa propre ligne. Après avoir activé ou coupé les sessions ou la trace, relancez `mm dockerinit` (répondez `yes` pour remplacer `compose.yaml`). | |
| 41 | + | |
| 25 | 42 | ## Lancer l'agent |
| 26 | 43 | |
| 27 | 44 | Soit vous gardez un conteneur démarré, qui sert aussi à un éditeur : |
| @@ -47,7 +64,7 @@ Démarrez le conteneur avec `docker compose up -d --wait`, puis donnez cette com | ||
| 47 | 64 | docker exec -i bob-box mm --acp --config /chemin/du/projet/agent.yaml |
| 48 | 65 | ``` |
| 49 | 66 | |
| 50 | -Le projet est monté au même chemin que sur votre machine : les chemins envoyés par l'éditeur veulent donc dire la même chose dans le conteneur. | |
| 67 | +Le projet est monté au même chemin que sur votre machine : les chemins envoyés par l'éditeur veulent donc dire la même chose dans le conteneur. Pour un projet `.mm`, `docker exec -i bob-box mm --acp` suffit : le conteneur démarre dans le projet, où mm trouve `.mm/agent.yaml`. | |
| 51 | 68 | |
| 52 | 69 | ## Joindre le modèle |
| 53 | 70 | |
| @@ -22,6 +22,23 @@ Le service prend le nom de `acp.name` dans `agent.yaml`, ou `mm` s'il n'y en a p | |||
| 22 | 22 | ||
| 23 | Si un fichier existe déjà, mm demande `Overwrite Dockerfile?`, et `no` est la réponse par défaut. Avec `--yes`, ou quand l'entrée se termine, rien n'est écrit. | 23 | Si un fichier existe déjà, mm demande `Overwrite Dockerfile?`, et `no` est la réponse par défaut. Avec `--yes`, ou quand l'entrée se termine, rien n'est écrit. |
| 24 | 24 | ||
| 25 | +### Un projet avec un dossier `.mm` | ||
| 26 | + | ||
| 27 | +Quand l'agent est `.mm/agent.yaml` (créé avec `mm init --project yes`, voir [ranger les fichiers mini-me d'un projet dans un dossier `.mm`](keep-the-config-in-a-mm-folder.md)), `mm dockerinit` le trouve sans option — il cherche `agent.yaml`, puis `.mm/agent.yaml`, comme mm — et nomme le service d'après lui. `compose.yaml` alors : | ||
| 28 | + | ||
| 29 | +- monte `.mm/` **en lecture seule** sur le projet : l'agent ne peut pas modifier ses propres réglages, skills ou hooks ; | ||
| 30 | +- remonte en écriture les dossiers où mm écrit — le `sessions.dir` et le `trace.dir` du fichier d'agent, quand ils sont activés et sous `.mm/` — et les crée tout de suite s'ils n'existent pas (Docker les créerait au nom de root, et l'agent ne tourne jamais en root) ; | ||
| 31 | +- lance `mm tui` sans fichier : mm trouve `.mm/agent.yaml` depuis le projet. | ||
| 32 | + | ||
| 33 | +```yaml | ||
| 34 | + volumes: | ||
| 35 | + - .:${PWD:-/workspace} | ||
| 36 | + - ./.mm:${PWD:-/workspace}/.mm:ro | ||
| 37 | + - ./.mm/sessions:${PWD:-/workspace}/.mm/sessions # sessions.dir | ||
| 38 | +``` | ||
| 39 | + | ||
| 40 | +Tout ce qui est écrit d'autre sous `.mm/` — un hook qui y tient un journal — demande sa propre ligne. Après avoir activé ou coupé les sessions ou la trace, relancez `mm dockerinit` (répondez `yes` pour remplacer `compose.yaml`). | ||
| 41 | + | ||
| 25 | ## Lancer l'agent | 42 | ## Lancer l'agent |
| 26 | 43 | ||
| 27 | Soit vous gardez un conteneur démarré, qui sert aussi à un éditeur : | 44 | Soit vous gardez un conteneur démarré, qui sert aussi à un éditeur : |
| @@ -47,7 +64,7 @@ Démarrez le conteneur avec `docker compose up -d --wait`, puis donnez cette com | |||
| 47 | docker exec -i bob-box mm --acp --config /chemin/du/projet/agent.yaml | 64 | docker exec -i bob-box mm --acp --config /chemin/du/projet/agent.yaml |
| 48 | ``` | 65 | ``` |
| 49 | 66 | ||
| 50 | -Le projet est monté au même chemin que sur votre machine : les chemins envoyés par l'éditeur veulent donc dire la même chose dans le conteneur. | 67 | +Le projet est monté au même chemin que sur votre machine : les chemins envoyés par l'éditeur veulent donc dire la même chose dans le conteneur. Pour un projet `.mm`, `docker exec -i bob-box mm --acp` suffit : le conteneur démarre dans le projet, où mm trouve `.mm/agent.yaml`. |
| 51 | 68 | ||
| 52 | ## Joindre le modèle | 69 | ## Joindre le modèle |
| 53 | 70 | ||
modified
docs/fr/how-to/run-in-a-docker-sandbox.md +1 -0 | @@ -97,6 +97,7 @@ Pour décrire la sandbox dans un fichier, lancez ceci dans le dossier de votre p | ||
| 97 | 97 | ```bash |
| 98 | 98 | mm sbxinit |
| 99 | 99 | sbx env create --force |
| 100 | +sbx env run --force | |
| 100 | 101 | ``` |
| 101 | 102 | |
| 102 | 103 | `sbx env` demande sbx 0.39.0 ou plus, et `docker.io/` parmi les sources de kits autorisées (étape 1). Le fichier indique la dernière version de `mini-me-kit`, et `pullPolicy: always` fait récupérer ce kit par la sandbox à chaque création. Si cette version ne peut pas être lue, par exemple sans réseau, mm le signale et utilise le tag `latest`. La sandbox prend le nom de `acp.name` dans `agent.yaml`, suivi de `-sbx` ; `--name` permet d'en choisir un autre. |
| @@ -97,6 +97,7 @@ Pour décrire la sandbox dans un fichier, lancez ceci dans le dossier de votre p | |||
| 97 | ```bash | 97 | ```bash |
| 98 | mm sbxinit | 98 | mm sbxinit |
| 99 | sbx env create --force | 99 | sbx env create --force |
| 100 | +sbx env run --force | ||
| 100 | ``` | 101 | ``` |
| 101 | 102 | ||
| 102 | `sbx env` demande sbx 0.39.0 ou plus, et `docker.io/` parmi les sources de kits autorisées (étape 1). Le fichier indique la dernière version de `mini-me-kit`, et `pullPolicy: always` fait récupérer ce kit par la sandbox à chaque création. Si cette version ne peut pas être lue, par exemple sans réseau, mm le signale et utilise le tag `latest`. La sandbox prend le nom de `acp.name` dans `agent.yaml`, suivi de `-sbx` ; `--name` permet d'en choisir un autre. | 103 | `sbx env` demande sbx 0.39.0 ou plus, et `docker.io/` parmi les sources de kits autorisées (étape 1). Le fichier indique la dernière version de `mini-me-kit`, et `pullPolicy: always` fait récupérer ce kit par la sandbox à chaque création. Si cette version ne peut pas être lue, par exemple sans réseau, mm le signale et utilise le tag `latest`. La sandbox prend le nom de `acp.name` dans `agent.yaml`, suivi de `-sbx` ; `--name` permet d'en choisir un autre. |
modified
docs/fr/how-to/use-from-an-editor.md +2 -1 | @@ -31,7 +31,7 @@ Ce guide montre comment héberger `mm` dans un éditeur qui parle l'Agent Client | ||
| 31 | 31 | - **Annuler un tour en cours.** Utilisez la touche d'arrêt de l'éditeur (Échap dans Zed). L'agent répond au prompt avec la raison d'arrêt `cancelled` et conserve dans l'historique les commandes déjà exécutées. Il n'y a pas de `/abort` en ACP. |
| 32 | 32 | - **Compresser ou recommencer la conversation.** Envoyez `/compact` ou `/new` ; les éditeurs qui listent les commandes de l'agent proposent les deux dans le menu des commandes slash. Voir la [référence des commandes slash](../reference/slash-commands.md#mode-acp). |
| 33 | 33 | - **Attacher un fichier avec `@`.** Le sélecteur de l'éditeur envoie un `resource_link`, transmis au modèle sous la forme `[attached file: <chemin>]` ; un chemin tapé à la main comme `@chemin` est compris de la même façon (voir [`@chemin`](attach-a-file.md)). Le modèle lit le fichier avec ses outils. |
| 34 | -- **Recharger l'agent sans perdre le fil.** Avec `sessions.enabled: true` dans le fichier de configuration, le Reload Agent de l'éditeur (Zed) appelle `session/load` et toute la conversation, appels d'outils compris, est rejouée dans le fil ; et l'historique des fils de l'éditeur liste les conversations stockées du projet (`session/list`), pour en rouvrir une plus ancienne. Voir [Conserver les sessions et les reprendre](keep-sessions.md). | |
| 34 | +- **Recharger l'agent sans perdre le fil.** Avec `sessions.enabled: true` dans le fichier de configuration, le Reload Agent de l'éditeur (Zed) appelle `session/load` et toute la conversation, appels d'outils compris, est rejouée dans le fil ; et l'historique des fils de l'éditeur liste les conversations stockées du projet (`session/list`), pour en rouvrir une plus ancienne. Voir [Conserver les sessions et les reprendre](keep-sessions.md). Sans `sessions.enabled`, le Reload Agent marche quand même, mais la conversation recommence : mm le dit dans le fil. | |
| 35 | 35 | - **Voir le JSON échangé avec le modèle.** Activez la trace, dans le fichier de configuration (`trace: {enabled: true}`) ou dans les réglages de l'éditeur (`"env": { "AGENT_CONFIG": "…", "AGENT_TRACE": "true" }` — l'éditeur ne transmet pas les variables de votre shell). Les fichiers arrivent dans `<projet>/.mm/traces/`, le projet étant le `cwd` de la session ; la bannière, dans les logs ACP, indique `trace: <session cwd>/.mm/traces`. Voir [Comment voir le JSON envoyé au modèle](inspect-model-traffic.md). |
| 36 | 36 | - **Changer le nom que voit l'éditeur.** Ajoutez un bloc `acp:` au fichier de configuration ; `name` est l'identifiant que l'éditeur journalise, `title` le nom qu'il peut afficher (laissez `title` vide pour n'en envoyer aucun). La version envoyée est toujours celle du binaire, celle qu'affiche `mm --version` — vérifiez-la avec la requête `initialize` ci-dessous. Voir les [clés de `acp`](../reference/configuration.md#clés-de-acp). |
| 37 | 37 | |
| @@ -52,5 +52,6 @@ Ce guide montre comment héberger `mm` dans un éditeur qui parle l'Agent Client | ||
| 52 | 52 | |
| 53 | 53 | ## Voir aussi |
| 54 | 54 | |
| 55 | +- [Créer un agent depuis votre éditeur (`mm acp-create`)](create-an-agent-from-an-editor.md) — écrire l'agent.yaml sans terminal | |
| 55 | 56 | - Les méthodes et capacités exactement exposées : [référence ACP](../reference/acp.md) |
| 56 | 57 | - Pourquoi le terminal et l'éditeur partagent une seule boucle : [architecture](../explanation/architecture.md) |
| @@ -31,7 +31,7 @@ Ce guide montre comment héberger `mm` dans un éditeur qui parle l'Agent Client | |||
| 31 | - **Annuler un tour en cours.** Utilisez la touche d'arrêt de l'éditeur (Échap dans Zed). L'agent répond au prompt avec la raison d'arrêt `cancelled` et conserve dans l'historique les commandes déjà exécutées. Il n'y a pas de `/abort` en ACP. | 31 | - **Annuler un tour en cours.** Utilisez la touche d'arrêt de l'éditeur (Échap dans Zed). L'agent répond au prompt avec la raison d'arrêt `cancelled` et conserve dans l'historique les commandes déjà exécutées. Il n'y a pas de `/abort` en ACP. |
| 32 | - **Compresser ou recommencer la conversation.** Envoyez `/compact` ou `/new` ; les éditeurs qui listent les commandes de l'agent proposent les deux dans le menu des commandes slash. Voir la [référence des commandes slash](../reference/slash-commands.md#mode-acp). | 32 | - **Compresser ou recommencer la conversation.** Envoyez `/compact` ou `/new` ; les éditeurs qui listent les commandes de l'agent proposent les deux dans le menu des commandes slash. Voir la [référence des commandes slash](../reference/slash-commands.md#mode-acp). |
| 33 | - **Attacher un fichier avec `@`.** Le sélecteur de l'éditeur envoie un `resource_link`, transmis au modèle sous la forme `[attached file: <chemin>]` ; un chemin tapé à la main comme `@chemin` est compris de la même façon (voir [`@chemin`](attach-a-file.md)). Le modèle lit le fichier avec ses outils. | 33 | - **Attacher un fichier avec `@`.** Le sélecteur de l'éditeur envoie un `resource_link`, transmis au modèle sous la forme `[attached file: <chemin>]` ; un chemin tapé à la main comme `@chemin` est compris de la même façon (voir [`@chemin`](attach-a-file.md)). Le modèle lit le fichier avec ses outils. |
| 34 | -- **Recharger l'agent sans perdre le fil.** Avec `sessions.enabled: true` dans le fichier de configuration, le Reload Agent de l'éditeur (Zed) appelle `session/load` et toute la conversation, appels d'outils compris, est rejouée dans le fil ; et l'historique des fils de l'éditeur liste les conversations stockées du projet (`session/list`), pour en rouvrir une plus ancienne. Voir [Conserver les sessions et les reprendre](keep-sessions.md). | 34 | +- **Recharger l'agent sans perdre le fil.** Avec `sessions.enabled: true` dans le fichier de configuration, le Reload Agent de l'éditeur (Zed) appelle `session/load` et toute la conversation, appels d'outils compris, est rejouée dans le fil ; et l'historique des fils de l'éditeur liste les conversations stockées du projet (`session/list`), pour en rouvrir une plus ancienne. Voir [Conserver les sessions et les reprendre](keep-sessions.md). Sans `sessions.enabled`, le Reload Agent marche quand même, mais la conversation recommence : mm le dit dans le fil. |
| 35 | - **Voir le JSON échangé avec le modèle.** Activez la trace, dans le fichier de configuration (`trace: {enabled: true}`) ou dans les réglages de l'éditeur (`"env": { "AGENT_CONFIG": "…", "AGENT_TRACE": "true" }` — l'éditeur ne transmet pas les variables de votre shell). Les fichiers arrivent dans `<projet>/.mm/traces/`, le projet étant le `cwd` de la session ; la bannière, dans les logs ACP, indique `trace: <session cwd>/.mm/traces`. Voir [Comment voir le JSON envoyé au modèle](inspect-model-traffic.md). | 35 | - **Voir le JSON échangé avec le modèle.** Activez la trace, dans le fichier de configuration (`trace: {enabled: true}`) ou dans les réglages de l'éditeur (`"env": { "AGENT_CONFIG": "…", "AGENT_TRACE": "true" }` — l'éditeur ne transmet pas les variables de votre shell). Les fichiers arrivent dans `<projet>/.mm/traces/`, le projet étant le `cwd` de la session ; la bannière, dans les logs ACP, indique `trace: <session cwd>/.mm/traces`. Voir [Comment voir le JSON envoyé au modèle](inspect-model-traffic.md). |
| 36 | - **Changer le nom que voit l'éditeur.** Ajoutez un bloc `acp:` au fichier de configuration ; `name` est l'identifiant que l'éditeur journalise, `title` le nom qu'il peut afficher (laissez `title` vide pour n'en envoyer aucun). La version envoyée est toujours celle du binaire, celle qu'affiche `mm --version` — vérifiez-la avec la requête `initialize` ci-dessous. Voir les [clés de `acp`](../reference/configuration.md#clés-de-acp). | 36 | - **Changer le nom que voit l'éditeur.** Ajoutez un bloc `acp:` au fichier de configuration ; `name` est l'identifiant que l'éditeur journalise, `title` le nom qu'il peut afficher (laissez `title` vide pour n'en envoyer aucun). La version envoyée est toujours celle du binaire, celle qu'affiche `mm --version` — vérifiez-la avec la requête `initialize` ci-dessous. Voir les [clés de `acp`](../reference/configuration.md#clés-de-acp). |
| 37 | 37 | ||
| @@ -52,5 +52,6 @@ Ce guide montre comment héberger `mm` dans un éditeur qui parle l'Agent Client | |||
| 52 | 52 | ||
| 53 | ## Voir aussi | 53 | ## Voir aussi |
| 54 | 54 | ||
| 55 | +- [Créer un agent depuis votre éditeur (`mm acp-create`)](create-an-agent-from-an-editor.md) — écrire l'agent.yaml sans terminal | ||
| 55 | - Les méthodes et capacités exactement exposées : [référence ACP](../reference/acp.md) | 56 | - Les méthodes et capacités exactement exposées : [référence ACP](../reference/acp.md) |
| 56 | - Pourquoi le terminal et l'éditeur partagent une seule boucle : [architecture](../explanation/architecture.md) | 57 | - Pourquoi le terminal et l'éditeur partagent une seule boucle : [architecture](../explanation/architecture.md) |
modified
docs/fr/reference/acp.md +3 -3 | @@ -16,7 +16,7 @@ | ||
| 16 | 16 | | Champ | Valeur | |
| 17 | 17 | |-------|--------| |
| 18 | 18 | | `protocolVersion` | `1` | |
| 19 | -| `agentCapabilities` | Quand `sessions.enabled` est activé : `loadSession: true` et `sessionCapabilities: {"list": {}, "resume": {}}` ; les deux absentes (false) sinon. Tout le reste absent (false) : pas de prompts image ou audio, pas de MCP HTTP. | | |
| 19 | +| `agentCapabilities` | `loadSession: true`. `sessionCapabilities` : `{"list": {}, "resume": {}}` quand `sessions.enabled` est activé, `{"resume": {}}` sinon. Tout le reste absent (false) : pas de prompts image ou audio, pas de MCP HTTP. | | |
| 20 | 20 | | `agentInfo.name` | `acp.name` de la configuration, `bob` par défaut. | |
| 21 | 21 | | `agentInfo.title` | `acp.title` de la configuration, `Bob (bash-first agent)` par défaut ; absent quand vide. | |
| 22 | 22 | | `agentInfo.version` | La version du binaire, la même chaîne que `mm --version` (par exemple `v0.1.7`). | |
| @@ -31,9 +31,9 @@ | ||
| 31 | 31 | | `session/new` | Crée une session avec un identifiant triable par date (`20260916-101530-4f2a9c1e`) et le `cwd` de la requête, un historique neuf contenant le prompt système et un ensemble « toujours autoriser » vide. Les `mcpServers` sont comptés dans le journal et ignorés. Une fois la réponse écrite, envoie une notification `available_commands_update` listant les commandes slash (voir plus bas). Avec `sessions.enabled`, écrit `<cwd>/<sessions.dir>/<id>.json` immédiatement et le réécrit après chaque tour, `/new` et `/compact`. Avant de répondre, donne à la session ses outils client : la liste lue à `initialize`, ou, avec `acp.clientTools.discovery: request`, la réponse à une requête `_org.mini-me/tools/list`. | |
| 32 | 32 | | `session/prompt` | Exécute une génération sur l'historique de la session, ou exécute une commande slash sans consulter le modèle. Les tours sont sérialisés à l'échelle du processus. | |
| 33 | 33 | | `session/cancel` | Annule le tour en cours pour cette session. Un `session/prompt` qui arrive pendant un tour ne l'annule jamais : il attend la fin du tour, puis s'exécute. Qu'un message tapé pendant un tour soit envoyé tout de suite, mis en attente ou précédé d'un `session/cancel`, c'est le choix de l'éditeur. | |
| 34 | -| `session/load` | Avec `sessions.enabled` : lit `<cwd>/<sessions.dir>/<sessionId>.json`, remplace son prompt système par l'actuel, enregistre la session (ensemble « toujours autoriser » vide), envoie le rejeu (ci-dessous) puis un `available_commands_update`, et répond `{}`. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : `method not found`. Les outils client de la session sont fixés comme pour `session/new`. | | |
| 34 | +| `session/load` | Avec `sessions.enabled` : lit `<cwd>/<sessions.dir>/<sessionId>.json`, remplace son prompt système par l'actuel, enregistre la session (ensemble « toujours autoriser » vide), envoie le rejeu (ci-dessous) puis un `available_commands_update`, et répond `{}`. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : l'id demandé devient une nouvelle session vide dans le `cwd` de la requête (rien n'est lu ni écrit), un `available_commands_update` et un `agent_message_chunk` disant que la conversation recommence sont envoyés, et la réponse est `{}` — pour que le rechargement d'un éditeur n'échoue pas. Les outils client de la session sont fixés comme pour `session/new`. | | |
| 35 | 35 | | `session/list` | Avec `sessions.enabled` : liste les sessions stockées dans `<cwd>/<sessions.dir>`, `cwd` étant celui de la requête, sinon le dossier de `mm`. Les plus récemment modifiées d'abord ; une session sans aucune question est omise. Chaque entrée : `sessionId`, `cwd` (celui listé), `title` (la première question, sur une ligne ; absent quand elle est vide), `updatedAt` (RFC 3339, UTC). Une seule page : pas de `nextCursor`, `cursor` ignoré. Les sessions écrites par le mode ligne ou la fenêtre dans le même projet sont listées aussi. Sans `sessions.enabled` : `method not found`. | |
| 36 | -| `session/resume` | Avec `sessions.enabled` : comme `session/load` sans le rejeu — lit le fichier, remplace son prompt système par l'actuel, enregistre la session, envoie un `available_commands_update` et répond `{}`. Les `mcpServers` sont ignorés. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : `method not found`. Les outils client de la session sont fixés comme pour `session/new`. | | |
| 36 | +| `session/resume` | Avec `sessions.enabled` : comme `session/load` sans le rejeu — lit le fichier, remplace son prompt système par l'actuel, enregistre la session, envoie un `available_commands_update` et répond `{}`. Les `mcpServers` sont ignorés. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : comme `session/load` sans `sessions.enabled` — une nouvelle session vide sous l'id demandé, et le message qui le dit. Les outils client de la session sont fixés comme pour `session/new`. | | |
| 37 | 37 | | `session/set_mode`, `session/set_config_option`, `session/close`, `logout` | `method not found`. | |
| 38 | 38 | |
| 39 | 39 | ## Les skills comme commandes |
| @@ -16,7 +16,7 @@ | |||
| 16 | | Champ | Valeur | | 16 | | Champ | Valeur | |
| 17 | |-------|--------| | 17 | |-------|--------| |
| 18 | | `protocolVersion` | `1` | | 18 | | `protocolVersion` | `1` | |
| 19 | -| `agentCapabilities` | Quand `sessions.enabled` est activé : `loadSession: true` et `sessionCapabilities: {"list": {}, "resume": {}}` ; les deux absentes (false) sinon. Tout le reste absent (false) : pas de prompts image ou audio, pas de MCP HTTP. | | 19 | +| `agentCapabilities` | `loadSession: true`. `sessionCapabilities` : `{"list": {}, "resume": {}}` quand `sessions.enabled` est activé, `{"resume": {}}` sinon. Tout le reste absent (false) : pas de prompts image ou audio, pas de MCP HTTP. | |
| 20 | | `agentInfo.name` | `acp.name` de la configuration, `bob` par défaut. | | 20 | | `agentInfo.name` | `acp.name` de la configuration, `bob` par défaut. | |
| 21 | | `agentInfo.title` | `acp.title` de la configuration, `Bob (bash-first agent)` par défaut ; absent quand vide. | | 21 | | `agentInfo.title` | `acp.title` de la configuration, `Bob (bash-first agent)` par défaut ; absent quand vide. | |
| 22 | | `agentInfo.version` | La version du binaire, la même chaîne que `mm --version` (par exemple `v0.1.7`). | | 22 | | `agentInfo.version` | La version du binaire, la même chaîne que `mm --version` (par exemple `v0.1.7`). | |
| @@ -31,9 +31,9 @@ | |||
| 31 | | `session/new` | Crée une session avec un identifiant triable par date (`20260916-101530-4f2a9c1e`) et le `cwd` de la requête, un historique neuf contenant le prompt système et un ensemble « toujours autoriser » vide. Les `mcpServers` sont comptés dans le journal et ignorés. Une fois la réponse écrite, envoie une notification `available_commands_update` listant les commandes slash (voir plus bas). Avec `sessions.enabled`, écrit `<cwd>/<sessions.dir>/<id>.json` immédiatement et le réécrit après chaque tour, `/new` et `/compact`. Avant de répondre, donne à la session ses outils client : la liste lue à `initialize`, ou, avec `acp.clientTools.discovery: request`, la réponse à une requête `_org.mini-me/tools/list`. | | 31 | | `session/new` | Crée une session avec un identifiant triable par date (`20260916-101530-4f2a9c1e`) et le `cwd` de la requête, un historique neuf contenant le prompt système et un ensemble « toujours autoriser » vide. Les `mcpServers` sont comptés dans le journal et ignorés. Une fois la réponse écrite, envoie une notification `available_commands_update` listant les commandes slash (voir plus bas). Avec `sessions.enabled`, écrit `<cwd>/<sessions.dir>/<id>.json` immédiatement et le réécrit après chaque tour, `/new` et `/compact`. Avant de répondre, donne à la session ses outils client : la liste lue à `initialize`, ou, avec `acp.clientTools.discovery: request`, la réponse à une requête `_org.mini-me/tools/list`. | |
| 32 | | `session/prompt` | Exécute une génération sur l'historique de la session, ou exécute une commande slash sans consulter le modèle. Les tours sont sérialisés à l'échelle du processus. | | 32 | | `session/prompt` | Exécute une génération sur l'historique de la session, ou exécute une commande slash sans consulter le modèle. Les tours sont sérialisés à l'échelle du processus. | |
| 33 | | `session/cancel` | Annule le tour en cours pour cette session. Un `session/prompt` qui arrive pendant un tour ne l'annule jamais : il attend la fin du tour, puis s'exécute. Qu'un message tapé pendant un tour soit envoyé tout de suite, mis en attente ou précédé d'un `session/cancel`, c'est le choix de l'éditeur. | | 33 | | `session/cancel` | Annule le tour en cours pour cette session. Un `session/prompt` qui arrive pendant un tour ne l'annule jamais : il attend la fin du tour, puis s'exécute. Qu'un message tapé pendant un tour soit envoyé tout de suite, mis en attente ou précédé d'un `session/cancel`, c'est le choix de l'éditeur. | |
| 34 | -| `session/load` | Avec `sessions.enabled` : lit `<cwd>/<sessions.dir>/<sessionId>.json`, remplace son prompt système par l'actuel, enregistre la session (ensemble « toujours autoriser » vide), envoie le rejeu (ci-dessous) puis un `available_commands_update`, et répond `{}`. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : `method not found`. Les outils client de la session sont fixés comme pour `session/new`. | | 34 | +| `session/load` | Avec `sessions.enabled` : lit `<cwd>/<sessions.dir>/<sessionId>.json`, remplace son prompt système par l'actuel, enregistre la session (ensemble « toujours autoriser » vide), envoie le rejeu (ci-dessous) puis un `available_commands_update`, et répond `{}`. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : l'id demandé devient une nouvelle session vide dans le `cwd` de la requête (rien n'est lu ni écrit), un `available_commands_update` et un `agent_message_chunk` disant que la conversation recommence sont envoyés, et la réponse est `{}` — pour que le rechargement d'un éditeur n'échoue pas. Les outils client de la session sont fixés comme pour `session/new`. | |
| 35 | | `session/list` | Avec `sessions.enabled` : liste les sessions stockées dans `<cwd>/<sessions.dir>`, `cwd` étant celui de la requête, sinon le dossier de `mm`. Les plus récemment modifiées d'abord ; une session sans aucune question est omise. Chaque entrée : `sessionId`, `cwd` (celui listé), `title` (la première question, sur une ligne ; absent quand elle est vide), `updatedAt` (RFC 3339, UTC). Une seule page : pas de `nextCursor`, `cursor` ignoré. Les sessions écrites par le mode ligne ou la fenêtre dans le même projet sont listées aussi. Sans `sessions.enabled` : `method not found`. | | 35 | | `session/list` | Avec `sessions.enabled` : liste les sessions stockées dans `<cwd>/<sessions.dir>`, `cwd` étant celui de la requête, sinon le dossier de `mm`. Les plus récemment modifiées d'abord ; une session sans aucune question est omise. Chaque entrée : `sessionId`, `cwd` (celui listé), `title` (la première question, sur une ligne ; absent quand elle est vide), `updatedAt` (RFC 3339, UTC). Une seule page : pas de `nextCursor`, `cursor` ignoré. Les sessions écrites par le mode ligne ou la fenêtre dans le même projet sont listées aussi. Sans `sessions.enabled` : `method not found`. | |
| 36 | -| `session/resume` | Avec `sessions.enabled` : comme `session/load` sans le rejeu — lit le fichier, remplace son prompt système par l'actuel, enregistre la session, envoie un `available_commands_update` et répond `{}`. Les `mcpServers` sont ignorés. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : `method not found`. Les outils client de la session sont fixés comme pour `session/new`. | | 36 | +| `session/resume` | Avec `sessions.enabled` : comme `session/load` sans le rejeu — lit le fichier, remplace son prompt système par l'actuel, enregistre la session, envoie un `available_commands_update` et répond `{}`. Les `mcpServers` sont ignorés. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : comme `session/load` sans `sessions.enabled` — une nouvelle session vide sous l'id demandé, et le message qui le dit. Les outils client de la session sont fixés comme pour `session/new`. | |
| 37 | | `session/set_mode`, `session/set_config_option`, `session/close`, `logout` | `method not found`. | | 37 | | `session/set_mode`, `session/set_config_option`, `session/close`, `logout` | `method not found`. | |
| 38 | 38 | ||
| 39 | ## Les skills comme commandes | 39 | ## Les skills comme commandes |
modified
docs/fr/reference/cli.md +18 -1 | @@ -9,6 +9,7 @@ mm [-config <chemin>] [-acp] [-tui] [-prompt <texte>|-p <texte>] [--version] [-- | ||
| 9 | 9 | mm [-config <chemin>] --ask <agent> [-p <question>] (question sur stdin sans -p) |
| 10 | 10 | mm [-config <chemin>] --ask-with-context <agent> [-p <question>] (contexte sur stdin) |
| 11 | 11 | mm init [options] (écrire un nouvel agent.yaml ; aussi mm --init) |
| 12 | +mm acp-create [options] (l'assistant de mm init par ACP, pour un éditeur) | |
| 12 | 13 | mm dockerinit [options] (écrire un Dockerfile et un compose.yaml) |
| 13 | 14 | mm sbxinit [options] (écrire un sbxenv.yaml pour un Docker Sandbox) |
| 14 | 15 | mm skillcreate [options] (écrire un nouveau skill avec un assistant) |
| @@ -209,7 +210,7 @@ Un tour annulé par l'utilisateur ne signale rien. `demos/struggle-signals/` dé | ||
| 209 | 210 | | Option | Signification | Défaut | |
| 210 | 211 | |--------|---------------|--------| |
| 211 | 212 | | `--name` | Le nom du service, en minuscules et chiffres reliés par des tirets (`Bob The Bot` donne `bob-the-bot`). Le conteneur est `<nom>-box`, l'image `mm-<nom>:latest` | `acp.name` dans le fichier d'agent, sinon `mm` | |
| 212 | -| `--agent` | Le fichier d'agent qui nomme le service et que lancent les commandes affichées | `agent.yaml` | | |
| 213 | +| `--agent` | Le fichier d'agent qui nomme le service et que lancent les commandes affichées. Sans l'option, `.mm/agent.yaml` est cherché aussi, après `agent.yaml` (l'ordre que suit mm) ; trouvé dans `.mm/`, `compose.yaml` monte `.mm/` en lecture seule et remonte en écriture le `sessions.dir` et le `trace.dir` du fichier d'agent (quand ils sont activés et sous `.mm/`), avec des chemins relatifs au projet, crée ces dossiers s'ils manquent, et lance `mm tui` sans fichier pour `.mm/agent.yaml` | `agent.yaml`, puis `.mm/agent.yaml` | | |
| 213 | 214 | | `--yes` | Ne rien demander. Un fichier existant arrête tout : rien n'est écrasé ni écrit | désactivé | |
| 214 | 215 | |
| 215 | 216 | La question est posée pour chaque fichier existant, avec `no` par défaut : ce fichier est gardé, l'autre est écrit. Codes de sortie : `0` terminé, `1` rien d'écrit parce qu'un fichier existe et que personne ne pouvait répondre, ou écriture en échec, `2` options ou arguments incorrects. |
| @@ -267,6 +268,22 @@ Un fichier d'agent hors du workspace ne donne qu'un avertissement : voir [quand | ||
| 267 | 268 | | Après l'écriture | Le fichier est chargé comme mm charge une configuration ; une erreur est affichée et le code de sortie vaut 1. | |
| 268 | 269 | | Codes de sortie | 0 écrit (et vérifié) ; 1 fournisseur, usage ou machine inconnu, pas de modèle, fichier existant, fichier que mm ne charge pas, vérification en échec ; 2 option invalide. | |
| 269 | 270 | |
| 271 | +## `mm acp-create` — l'assistant agent.yaml par ACP | |
| 272 | + | |
| 273 | +`mm acp-create` (aussi `--acp-create`, `-acp-create`) pose les questions de [`mm init`](#mm-init--lassistant-agentyaml) par l'Agent Client Protocol sur stdin/stdout, pour un éditeur ou tout client ACP. Il est traité avant toute lecture de configuration et n'appelle aucun modèle (il liste les modèles du serveur, comme `mm init`). Voir [Créer un agent depuis votre éditeur](../how-to/create-an-agent-from-an-editor.md). | |
| 274 | + | |
| 275 | +| Aspect | Comportement | | |
| 276 | +|--------|--------------| | |
| 277 | +| Options | Celles de `mm init` sauf `--check`, `--yes` et `--line` ; une question dont l'option est donnée n'est pas posée. Une valeur d'option refusée arrête `mm` au démarrage (code 1). `-h` et les erreurs vont sur stderr : stdout ne porte que du JSON-RPC. | | |
| 278 | +| `initialize` | `agentInfo.name` `mm-acp-create`, titre `mm init (agent.yaml wizard)` ; `loadSession: true` et `sessionCapabilities.resume`, aucune méthode d'authentification. | | |
| 279 | +| `session/load`, `session/resume` | Rien n'est gardé d'un redémarrage à l'autre : un assistant vierge est enregistré sous l'id demandé, dans le `cwd` donné, puis les commandes et un message disant que l'assistant recommence (pas de rejeu). Un fichier déjà écrit n'est pas touché. C'est ce qu'utilise le « reload agent » d'un client. | | |
| 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. | | |
| 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é). | | |
| 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. | | |
| 286 | + | |
| 270 | 287 | ## `mm skillcreate` — l'assistant de skill |
| 271 | 288 | |
| 272 | 289 | `mm skillcreate` (aussi `--skillcreate`, `-skillcreate`) demande un skill et l'écrit dans `<skillsDir>/<nom>/SKILL.md`. Il répond avant qu'aucun agent ne démarre. Chaque question ci-dessous a une option ; une question dont l'option est donnée n'est pas posée. Voir [Comment ajouter un skill](../how-to/add-a-skill.md). |
| @@ -9,6 +9,7 @@ mm [-config <chemin>] [-acp] [-tui] [-prompt <texte>|-p <texte>] [--version] [-- | |||
| 9 | mm [-config <chemin>] --ask <agent> [-p <question>] (question sur stdin sans -p) | 9 | mm [-config <chemin>] --ask <agent> [-p <question>] (question sur stdin sans -p) |
| 10 | mm [-config <chemin>] --ask-with-context <agent> [-p <question>] (contexte sur stdin) | 10 | mm [-config <chemin>] --ask-with-context <agent> [-p <question>] (contexte sur stdin) |
| 11 | mm init [options] (écrire un nouvel agent.yaml ; aussi mm --init) | 11 | mm init [options] (écrire un nouvel agent.yaml ; aussi mm --init) |
| 12 | +mm acp-create [options] (l'assistant de mm init par ACP, pour un éditeur) | ||
| 12 | mm dockerinit [options] (écrire un Dockerfile et un compose.yaml) | 13 | mm dockerinit [options] (écrire un Dockerfile et un compose.yaml) |
| 13 | mm sbxinit [options] (écrire un sbxenv.yaml pour un Docker Sandbox) | 14 | mm sbxinit [options] (écrire un sbxenv.yaml pour un Docker Sandbox) |
| 14 | mm skillcreate [options] (écrire un nouveau skill avec un assistant) | 15 | mm skillcreate [options] (écrire un nouveau skill avec un assistant) |
| @@ -209,7 +210,7 @@ Un tour annulé par l'utilisateur ne signale rien. `demos/struggle-signals/` dé | |||
| 209 | | Option | Signification | Défaut | | 210 | | Option | Signification | Défaut | |
| 210 | |--------|---------------|--------| | 211 | |--------|---------------|--------| |
| 211 | | `--name` | Le nom du service, en minuscules et chiffres reliés par des tirets (`Bob The Bot` donne `bob-the-bot`). Le conteneur est `<nom>-box`, l'image `mm-<nom>:latest` | `acp.name` dans le fichier d'agent, sinon `mm` | | 212 | | `--name` | Le nom du service, en minuscules et chiffres reliés par des tirets (`Bob The Bot` donne `bob-the-bot`). Le conteneur est `<nom>-box`, l'image `mm-<nom>:latest` | `acp.name` dans le fichier d'agent, sinon `mm` | |
| 212 | -| `--agent` | Le fichier d'agent qui nomme le service et que lancent les commandes affichées | `agent.yaml` | | 213 | +| `--agent` | Le fichier d'agent qui nomme le service et que lancent les commandes affichées. Sans l'option, `.mm/agent.yaml` est cherché aussi, après `agent.yaml` (l'ordre que suit mm) ; trouvé dans `.mm/`, `compose.yaml` monte `.mm/` en lecture seule et remonte en écriture le `sessions.dir` et le `trace.dir` du fichier d'agent (quand ils sont activés et sous `.mm/`), avec des chemins relatifs au projet, crée ces dossiers s'ils manquent, et lance `mm tui` sans fichier pour `.mm/agent.yaml` | `agent.yaml`, puis `.mm/agent.yaml` | |
| 213 | | `--yes` | Ne rien demander. Un fichier existant arrête tout : rien n'est écrasé ni écrit | désactivé | | 214 | | `--yes` | Ne rien demander. Un fichier existant arrête tout : rien n'est écrasé ni écrit | désactivé | |
| 214 | 215 | ||
| 215 | La question est posée pour chaque fichier existant, avec `no` par défaut : ce fichier est gardé, l'autre est écrit. Codes de sortie : `0` terminé, `1` rien d'écrit parce qu'un fichier existe et que personne ne pouvait répondre, ou écriture en échec, `2` options ou arguments incorrects. | 216 | La question est posée pour chaque fichier existant, avec `no` par défaut : ce fichier est gardé, l'autre est écrit. Codes de sortie : `0` terminé, `1` rien d'écrit parce qu'un fichier existe et que personne ne pouvait répondre, ou écriture en échec, `2` options ou arguments incorrects. |
| @@ -267,6 +268,22 @@ Un fichier d'agent hors du workspace ne donne qu'un avertissement : voir [quand | |||
| 267 | | Après l'écriture | Le fichier est chargé comme mm charge une configuration ; une erreur est affichée et le code de sortie vaut 1. | | 268 | | Après l'écriture | Le fichier est chargé comme mm charge une configuration ; une erreur est affichée et le code de sortie vaut 1. | |
| 268 | | Codes de sortie | 0 écrit (et vérifié) ; 1 fournisseur, usage ou machine inconnu, pas de modèle, fichier existant, fichier que mm ne charge pas, vérification en échec ; 2 option invalide. | | 269 | | Codes de sortie | 0 écrit (et vérifié) ; 1 fournisseur, usage ou machine inconnu, pas de modèle, fichier existant, fichier que mm ne charge pas, vérification en échec ; 2 option invalide. | |
| 269 | 270 | ||
| 271 | +## `mm acp-create` — l'assistant agent.yaml par ACP | ||
| 272 | + | ||
| 273 | +`mm acp-create` (aussi `--acp-create`, `-acp-create`) pose les questions de [`mm init`](#mm-init--lassistant-agentyaml) par l'Agent Client Protocol sur stdin/stdout, pour un éditeur ou tout client ACP. Il est traité avant toute lecture de configuration et n'appelle aucun modèle (il liste les modèles du serveur, comme `mm init`). Voir [Créer un agent depuis votre éditeur](../how-to/create-an-agent-from-an-editor.md). | ||
| 274 | + | ||
| 275 | +| Aspect | Comportement | | ||
| 276 | +|--------|--------------| | ||
| 277 | +| Options | Celles de `mm init` sauf `--check`, `--yes` et `--line` ; une question dont l'option est donnée n'est pas posée. Une valeur d'option refusée arrête `mm` au démarrage (code 1). `-h` et les erreurs vont sur stderr : stdout ne porte que du JSON-RPC. | | ||
| 278 | +| `initialize` | `agentInfo.name` `mm-acp-create`, titre `mm init (agent.yaml wizard)` ; `loadSession: true` et `sessionCapabilities.resume`, aucune méthode d'authentification. | | ||
| 279 | +| `session/load`, `session/resume` | Rien n'est gardé d'un redémarrage à l'autre : un assistant vierge est enregistré sous l'id demandé, dans le `cwd` donné, puis les commandes et un message disant que l'assistant recommence (pas de rejeu). Un fichier déjà écrit n'est pas touché. C'est ce qu'utilise le « reload agent » d'un client. | | ||
| 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. | | ||
| 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é). | | ||
| 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. | | ||
| 286 | + | ||
| 270 | ## `mm skillcreate` — l'assistant de skill | 287 | ## `mm skillcreate` — l'assistant de skill |
| 271 | 288 | ||
| 272 | `mm skillcreate` (aussi `--skillcreate`, `-skillcreate`) demande un skill et l'écrit dans `<skillsDir>/<nom>/SKILL.md`. Il répond avant qu'aucun agent ne démarre. Chaque question ci-dessous a une option ; une question dont l'option est donnée n'est pas posée. Voir [Comment ajouter un skill](../how-to/add-a-skill.md). | 289 | `mm skillcreate` (aussi `--skillcreate`, `-skillcreate`) demande un skill et l'écrit dans `<skillsDir>/<nom>/SKILL.md`. Il répond avant qu'aucun agent ne démarre. Chaque question ci-dessous a une option ; une question dont l'option est donnée n'est pas posée. Voir [Comment ajouter un skill](../how-to/add-a-skill.md). |
modified
docs/fr/reference/configuration.md +1 -1 | @@ -70,7 +70,7 @@ Où `mm` cherche ce fichier : [Résolution du fichier de configuration](cli.md#r | ||
| 70 | 70 | |
| 71 | 71 | | Clé | Type | Défaut | Description | |
| 72 | 72 | |-----|------|--------|-------------| |
| 73 | -| `sessions.enabled` | bool | `false` | Enregistre chaque conversation dans un fichier, réécrit après chaque tour, `/compact` et `/new`. Le fichier enregistre aussi, hors de l'historique, chaque erreur affichée (tableau `errors` : génération échouée, compression échouée, `/resume` échoué). Active `/sessions` et `/resume` dans le terminal et `session/load` en ACP. | | |
| 73 | +| `sessions.enabled` | bool | `false` | Enregistre chaque conversation dans un fichier, réécrit après chaque tour, `/compact` et `/new`. Le fichier enregistre aussi, hors de l'historique, chaque erreur affichée (tableau `errors` : génération échouée, compression échouée, `/resume` échoué). Active `/sessions` et `/resume` dans le terminal et, en ACP, `session/list` et un rechargement (`session/load`, `session/resume`) qui ramène la conversation. Désactivé, le rechargement d'un éditeur réussit quand même mais la conversation recommence. | | |
| 74 | 74 | | `sessions.dir` | chaîne | `.mm/sessions` | Répertoire des fichiers de session, un `<id>.json` chacun. Relatif au répertoire de lancement de `mm` (terminal) ou au `cwd` de la session (ACP) ; un chemin absolu est utilisé tel quel. Ne doit pas être vide. | |
| 75 | 75 | | `sessions.searchMemory` | booléen | `false` | La commande `/memory <mots>` (terminal, fenêtre, éditeur) : cherche dans les sessions enregistrées du projet. Demande `sessions.enabled`. Voir [commandes](slash-commands.md) et [Chercher dans les sessions précédentes](../how-to/search-earlier-sessions.md). | |
| 76 | 76 | | `sessions.autoSearchMemory` | booléen | `false` | L'outil [`search_memory`](tools.md#search_memory) : la même recherche, appelée par le modèle. Indépendante de `searchMemory`. Demande `sessions.enabled`. | |
| @@ -70,7 +70,7 @@ Où `mm` cherche ce fichier : [Résolution du fichier de configuration](cli.md#r | |||
| 70 | 70 | ||
| 71 | | Clé | Type | Défaut | Description | | 71 | | Clé | Type | Défaut | Description | |
| 72 | |-----|------|--------|-------------| | 72 | |-----|------|--------|-------------| |
| 73 | -| `sessions.enabled` | bool | `false` | Enregistre chaque conversation dans un fichier, réécrit après chaque tour, `/compact` et `/new`. Le fichier enregistre aussi, hors de l'historique, chaque erreur affichée (tableau `errors` : génération échouée, compression échouée, `/resume` échoué). Active `/sessions` et `/resume` dans le terminal et `session/load` en ACP. | | 73 | +| `sessions.enabled` | bool | `false` | Enregistre chaque conversation dans un fichier, réécrit après chaque tour, `/compact` et `/new`. Le fichier enregistre aussi, hors de l'historique, chaque erreur affichée (tableau `errors` : génération échouée, compression échouée, `/resume` échoué). Active `/sessions` et `/resume` dans le terminal et, en ACP, `session/list` et un rechargement (`session/load`, `session/resume`) qui ramène la conversation. Désactivé, le rechargement d'un éditeur réussit quand même mais la conversation recommence. | |
| 74 | | `sessions.dir` | chaîne | `.mm/sessions` | Répertoire des fichiers de session, un `<id>.json` chacun. Relatif au répertoire de lancement de `mm` (terminal) ou au `cwd` de la session (ACP) ; un chemin absolu est utilisé tel quel. Ne doit pas être vide. | | 74 | | `sessions.dir` | chaîne | `.mm/sessions` | Répertoire des fichiers de session, un `<id>.json` chacun. Relatif au répertoire de lancement de `mm` (terminal) ou au `cwd` de la session (ACP) ; un chemin absolu est utilisé tel quel. Ne doit pas être vide. | |
| 75 | | `sessions.searchMemory` | booléen | `false` | La commande `/memory <mots>` (terminal, fenêtre, éditeur) : cherche dans les sessions enregistrées du projet. Demande `sessions.enabled`. Voir [commandes](slash-commands.md) et [Chercher dans les sessions précédentes](../how-to/search-earlier-sessions.md). | | 75 | | `sessions.searchMemory` | booléen | `false` | La commande `/memory <mots>` (terminal, fenêtre, éditeur) : cherche dans les sessions enregistrées du projet. Demande `sessions.enabled`. Voir [commandes](slash-commands.md) et [Chercher dans les sessions précédentes](../how-to/search-earlier-sessions.md). | |
| 76 | | `sessions.autoSearchMemory` | booléen | `false` | L'outil [`search_memory`](tools.md#search_memory) : la même recherche, appelée par le modèle. Indépendante de `searchMemory`. Demande `sessions.enabled`. | | 76 | | `sessions.autoSearchMemory` | booléen | `false` | L'outil [`search_memory`](tools.md#search_memory) : la même recherche, appelée par le modèle. Indépendante de `searchMemory`. Demande `sessions.enabled`. | |
modified
internal/acp/acp.go +20 -71 | @@ -21,7 +21,6 @@ package acp | ||
| 21 | 21 | |
| 22 | 22 | import ( |
| 23 | 23 | "context" |
| 24 | - "encoding/json" | |
| 25 | 24 | "fmt" |
| 26 | 25 | "io" |
| 27 | 26 | "log/slog" |
| @@ -29,6 +28,7 @@ import ( | ||
| 29 | 28 | "strings" |
| 30 | 29 | "sync" |
| 31 | 30 | |
| 31 | + "mm/internal/acpwire" | |
| 32 | 32 | "mm/internal/agentsmd" |
| 33 | 33 | "mm/internal/ask" |
| 34 | 34 | "mm/internal/command" |
| @@ -141,7 +141,7 @@ func newFront(e *engine.Engine, system string, tools []ai.ToolRef, w io.Writer, | ||
| 141 | 141 | if config.Cfg.Sessions.Enabled { |
| 142 | 142 | f.sessionsDir = config.Cfg.Sessions.Dir |
| 143 | 143 | } |
| 144 | - f.out = &responseWriter{Writer: w, hooks: map[sdk.SessionId]func(){}} | |
| 144 | + f.out = acpwire.NewWriter(w) | |
| 145 | 145 | conn := sdk.NewAgentSideConnection(f, f.out, r) |
| 146 | 146 | conn.SetLogger(slog.New(slog.NewTextHandler(os.Stderr, nil))) |
| 147 | 147 | f.conn = conn |
| @@ -154,67 +154,13 @@ func newFront(e *engine.Engine, system string, tools []ai.ToolRef, w io.Writer, | ||
| 154 | 154 | // only ever build one at a time. |
| 155 | 155 | var lastFront *front |
| 156 | 156 | |
| 157 | -// responseWriter is the io.Writer handed to the SDK: the real output plus one | |
| 158 | -// hook per session, run once the session/new response carrying that session | |
| 159 | -// id has been written. It exists for available_commands_update. The spec says | |
| 160 | -// to announce the commands right after the session is created, and the client | |
| 161 | -// (Zed, verified 2026-09-15) drops any session/update for a session whose | |
| 162 | -// session/new response it has not received yet — "Available commands: none". | |
| 163 | -// But the SDK writes the response only when NewSession returns, after anything | |
| 164 | -// the method itself sent, and offers no "after the response" callback. Watching | |
| 165 | -// the bytes is the one place where the order is certain. | |
| 166 | -type responseWriter struct { | |
| 167 | - io.Writer | |
| 168 | - mu sync.Mutex | |
| 169 | - hooks map[sdk.SessionId]func() | |
| 170 | -} | |
| 171 | - | |
| 172 | -// afterResponse registers hook to run — once, in its own goroutine — after the | |
| 173 | -// response that carries sid has been written. | |
| 174 | -func (w *responseWriter) afterResponse(sid sdk.SessionId, hook func()) { | |
| 175 | - w.mu.Lock() | |
| 176 | - defer w.mu.Unlock() | |
| 177 | - w.hooks[sid] = hook | |
| 178 | -} | |
| 157 | +// responseWriter is acpwire.Writer: the SDK's output plus the hooks run | |
| 158 | +// after a session/new response (see there). An alias, so the code and the | |
| 159 | +// tests here keep their names. | |
| 160 | +type responseWriter = acpwire.Writer | |
| 179 | 161 | |
| 180 | -// Write forwards to the real output, then fires the hook of the session whose | |
| 181 | -// response this line is, if any. The hook runs in a goroutine: the SDK holds | |
| 182 | -// its write lock here, and the hook is about to write a notification. | |
| 183 | -func (w *responseWriter) Write(p []byte) (int, error) { | |
| 184 | - // An elicitation's scope rides in _meta until the SDK can carry it | |
| 185 | - // (elicit.go); the caller is told the length it gave. | |
| 186 | - if _, err := w.Writer.Write(liftElicitationScope(p)); err != nil { | |
| 187 | - return 0, err | |
| 188 | - } | |
| 189 | - n := len(p) | |
| 190 | - if sid, ok := responseSessionID(p); ok { | |
| 191 | - w.mu.Lock() | |
| 192 | - hook := w.hooks[sid] | |
| 193 | - delete(w.hooks, sid) | |
| 194 | - w.mu.Unlock() | |
| 195 | - if hook != nil { | |
| 196 | - go hook() | |
| 197 | - } | |
| 198 | - } | |
| 199 | - return n, nil | |
| 200 | -} | |
| 201 | - | |
| 202 | -// responseSessionID recognises a JSON-RPC response whose result carries a | |
| 203 | -// sessionId — session/new (and session/load, which this agent does not | |
| 204 | -// offer). Notifications have no id and prompt responses no sessionId, so | |
| 205 | -// neither matches. | |
| 206 | -func responseSessionID(line []byte) (sdk.SessionId, bool) { | |
| 207 | - var m struct { | |
| 208 | - ID json.RawMessage `json:"id"` | |
| 209 | - Result struct { | |
| 210 | - SessionId sdk.SessionId `json:"sessionId"` | |
| 211 | - } `json:"result"` | |
| 212 | - } | |
| 213 | - if json.Unmarshal(line, &m) != nil || len(m.ID) == 0 || m.Result.SessionId == "" { | |
| 214 | - return "", false | |
| 215 | - } | |
| 216 | - return m.Result.SessionId, true | |
| 217 | -} | |
| 162 | +// responseSessionID is acpwire.ResponseSessionID (see there). | |
| 163 | +var responseSessionID = acpwire.ResponseSessionID | |
| 218 | 164 | |
| 219 | 165 | // --- initialize --------------------------------------------------------------- |
| 220 | 166 | |
| @@ -230,21 +176,24 @@ func (f *front) Initialize(_ context.Context, p sdk.InitializeRequest) (sdk.Init | ||
| 230 | 176 | return sdk.InitializeResponse{ |
| 231 | 177 | ProtocolVersion: sdk.ProtocolVersionNumber, // 1 |
| 232 | 178 | // Everything absent is false: no image/audio prompts, no MCP over |
| 233 | - // http. loadSession, session/list and session/resume exist only | |
| 234 | - // when sessions are persisted — an editor's "reload" or history | |
| 235 | - // picker needs files to read. Honest capabilities are what lets a | |
| 236 | - // client adapt. | |
| 237 | - AgentCapabilities: sdk.AgentCapabilities{LoadSession: f.sessionsDir != "", SessionCapabilities: f.sessionCapabilities()}, | |
| 179 | + // http. session/list exists only when sessions are persisted — a | |
| 180 | + // history picker needs files to read. loadSession and | |
| 181 | + // session/resume are always there: an editor's "reload agent" | |
| 182 | + // uses them, and without saved sessions the conversation starts | |
| 183 | + // over and says so (persist.go, startOver; 2026-11-14, user's | |
| 184 | + // choice — before, a reload simply failed). | |
| 185 | + AgentCapabilities: sdk.AgentCapabilities{LoadSession: true, SessionCapabilities: f.sessionCapabilities()}, | |
| 238 | 186 | AgentInfo: agentInfo(), |
| 239 | 187 | AuthMethods: []sdk.AuthMethod{}, // the engine is local: nothing to log into |
| 240 | 188 | }, nil |
| 241 | 189 | } |
| 242 | 190 | |
| 243 | -// sessionCapabilities advertises session/list and session/resume when | |
| 244 | -// sessions are persisted, nothing otherwise. | |
| 191 | +// sessionCapabilities advertises session/resume always — without persisted | |
| 192 | +// sessions a reload starts the conversation over (persist.go, startOver) — | |
| 193 | +// and session/list only when there are files to list. | |
| 245 | 194 | func (f *front) sessionCapabilities() sdk.SessionCapabilities { |
| 246 | 195 | if f.sessionsDir == "" { |
| 247 | - return sdk.SessionCapabilities{} | |
| 196 | + return sdk.SessionCapabilities{Resume: &sdk.SessionResumeCapabilities{}} | |
| 248 | 197 | } |
| 249 | 198 | return sdk.SessionCapabilities{List: &sdk.SessionListCapabilities{}, Resume: &sdk.SessionResumeCapabilities{}} |
| 250 | 199 | } |
| @@ -299,7 +248,7 @@ func (f *front) NewSession(ctx context.Context, p sdk.NewSessionRequest) (sdk.Ne | ||
| 299 | 248 | // carries and drops updates for an id it does not know yet. Hence the hook |
| 300 | 249 | // on the writer rather than a SessionUpdate call here (see responseWriter). |
| 301 | 250 | // The request context dies with this method; the hook needs one that lives. |
| 302 | - f.out.afterResponse(sid, func() { | |
| 251 | + f.out.AfterResponse(sid, func() { | |
| 303 | 252 | _ = f.conn.SessionUpdate(context.WithoutCancel(ctx), sdk.SessionNotification{ |
| 304 | 253 | SessionId: sid, |
| 305 | 254 | Update: sdk.SessionUpdate{AvailableCommandsUpdate: &sdk.SessionAvailableCommandsUpdate{AvailableCommands: availableCommands()}}, |
| @@ -21,7 +21,6 @@ package acp | |||
| 21 | 21 | ||
| 22 | import ( | 22 | import ( |
| 23 | "context" | 23 | "context" |
| 24 | - "encoding/json" | ||
| 25 | "fmt" | 24 | "fmt" |
| 26 | "io" | 25 | "io" |
| 27 | "log/slog" | 26 | "log/slog" |
| @@ -29,6 +28,7 @@ import ( | |||
| 29 | "strings" | 28 | "strings" |
| 30 | "sync" | 29 | "sync" |
| 31 | 30 | ||
| 31 | + "mm/internal/acpwire" | ||
| 32 | "mm/internal/agentsmd" | 32 | "mm/internal/agentsmd" |
| 33 | "mm/internal/ask" | 33 | "mm/internal/ask" |
| 34 | "mm/internal/command" | 34 | "mm/internal/command" |
| @@ -141,7 +141,7 @@ func newFront(e *engine.Engine, system string, tools []ai.ToolRef, w io.Writer, | |||
| 141 | if config.Cfg.Sessions.Enabled { | 141 | if config.Cfg.Sessions.Enabled { |
| 142 | f.sessionsDir = config.Cfg.Sessions.Dir | 142 | f.sessionsDir = config.Cfg.Sessions.Dir |
| 143 | } | 143 | } |
| 144 | - f.out = &responseWriter{Writer: w, hooks: map[sdk.SessionId]func(){}} | 144 | + f.out = acpwire.NewWriter(w) |
| 145 | conn := sdk.NewAgentSideConnection(f, f.out, r) | 145 | conn := sdk.NewAgentSideConnection(f, f.out, r) |
| 146 | conn.SetLogger(slog.New(slog.NewTextHandler(os.Stderr, nil))) | 146 | conn.SetLogger(slog.New(slog.NewTextHandler(os.Stderr, nil))) |
| 147 | f.conn = conn | 147 | f.conn = conn |
| @@ -154,67 +154,13 @@ func newFront(e *engine.Engine, system string, tools []ai.ToolRef, w io.Writer, | |||
| 154 | // only ever build one at a time. | 154 | // only ever build one at a time. |
| 155 | var lastFront *front | 155 | var lastFront *front |
| 156 | 156 | ||
| 157 | -// responseWriter is the io.Writer handed to the SDK: the real output plus one | 157 | +// responseWriter is acpwire.Writer: the SDK's output plus the hooks run |
| 158 | -// hook per session, run once the session/new response carrying that session | 158 | +// after a session/new response (see there). An alias, so the code and the |
| 159 | -// id has been written. It exists for available_commands_update. The spec says | 159 | +// tests here keep their names. |
| 160 | -// to announce the commands right after the session is created, and the client | 160 | +type responseWriter = acpwire.Writer |
| 161 | -// (Zed, verified 2026-09-15) drops any session/update for a session whose | ||
| 162 | -// session/new response it has not received yet — "Available commands: none". | ||
| 163 | -// But the SDK writes the response only when NewSession returns, after anything | ||
| 164 | -// the method itself sent, and offers no "after the response" callback. Watching | ||
| 165 | -// the bytes is the one place where the order is certain. | ||
| 166 | -type responseWriter struct { | ||
| 167 | - io.Writer | ||
| 168 | - mu sync.Mutex | ||
| 169 | - hooks map[sdk.SessionId]func() | ||
| 170 | -} | ||
| 171 | - | ||
| 172 | -// afterResponse registers hook to run — once, in its own goroutine — after the | ||
| 173 | -// response that carries sid has been written. | ||
| 174 | -func (w *responseWriter) afterResponse(sid sdk.SessionId, hook func()) { | ||
| 175 | - w.mu.Lock() | ||
| 176 | - defer w.mu.Unlock() | ||
| 177 | - w.hooks[sid] = hook | ||
| 178 | -} | ||
| 179 | 161 | ||
| 180 | -// Write forwards to the real output, then fires the hook of the session whose | 162 | +// responseSessionID is acpwire.ResponseSessionID (see there). |
| 181 | -// response this line is, if any. The hook runs in a goroutine: the SDK holds | 163 | +var responseSessionID = acpwire.ResponseSessionID |
| 182 | -// its write lock here, and the hook is about to write a notification. | ||
| 183 | -func (w *responseWriter) Write(p []byte) (int, error) { | ||
| 184 | - // An elicitation's scope rides in _meta until the SDK can carry it | ||
| 185 | - // (elicit.go); the caller is told the length it gave. | ||
| 186 | - if _, err := w.Writer.Write(liftElicitationScope(p)); err != nil { | ||
| 187 | - return 0, err | ||
| 188 | - } | ||
| 189 | - n := len(p) | ||
| 190 | - if sid, ok := responseSessionID(p); ok { | ||
| 191 | - w.mu.Lock() | ||
| 192 | - hook := w.hooks[sid] | ||
| 193 | - delete(w.hooks, sid) | ||
| 194 | - w.mu.Unlock() | ||
| 195 | - if hook != nil { | ||
| 196 | - go hook() | ||
| 197 | - } | ||
| 198 | - } | ||
| 199 | - return n, nil | ||
| 200 | -} | ||
| 201 | - | ||
| 202 | -// responseSessionID recognises a JSON-RPC response whose result carries a | ||
| 203 | -// sessionId — session/new (and session/load, which this agent does not | ||
| 204 | -// offer). Notifications have no id and prompt responses no sessionId, so | ||
| 205 | -// neither matches. | ||
| 206 | -func responseSessionID(line []byte) (sdk.SessionId, bool) { | ||
| 207 | - var m struct { | ||
| 208 | - ID json.RawMessage `json:"id"` | ||
| 209 | - Result struct { | ||
| 210 | - SessionId sdk.SessionId `json:"sessionId"` | ||
| 211 | - } `json:"result"` | ||
| 212 | - } | ||
| 213 | - if json.Unmarshal(line, &m) != nil || len(m.ID) == 0 || m.Result.SessionId == "" { | ||
| 214 | - return "", false | ||
| 215 | - } | ||
| 216 | - return m.Result.SessionId, true | ||
| 217 | -} | ||
| 218 | 164 | ||
| 219 | // --- initialize --------------------------------------------------------------- | 165 | // --- initialize --------------------------------------------------------------- |
| 220 | 166 | ||
| @@ -230,21 +176,24 @@ func (f *front) Initialize(_ context.Context, p sdk.InitializeRequest) (sdk.Init | |||
| 230 | return sdk.InitializeResponse{ | 176 | return sdk.InitializeResponse{ |
| 231 | ProtocolVersion: sdk.ProtocolVersionNumber, // 1 | 177 | ProtocolVersion: sdk.ProtocolVersionNumber, // 1 |
| 232 | // Everything absent is false: no image/audio prompts, no MCP over | 178 | // Everything absent is false: no image/audio prompts, no MCP over |
| 233 | - // http. loadSession, session/list and session/resume exist only | 179 | + // http. session/list exists only when sessions are persisted — a |
| 234 | - // when sessions are persisted — an editor's "reload" or history | 180 | + // history picker needs files to read. loadSession and |
| 235 | - // picker needs files to read. Honest capabilities are what lets a | 181 | + // session/resume are always there: an editor's "reload agent" |
| 236 | - // client adapt. | 182 | + // uses them, and without saved sessions the conversation starts |
| 237 | - AgentCapabilities: sdk.AgentCapabilities{LoadSession: f.sessionsDir != "", SessionCapabilities: f.sessionCapabilities()}, | 183 | + // over and says so (persist.go, startOver; 2026-11-14, user's |
| 184 | + // choice — before, a reload simply failed). | ||
| 185 | + AgentCapabilities: sdk.AgentCapabilities{LoadSession: true, SessionCapabilities: f.sessionCapabilities()}, | ||
| 238 | AgentInfo: agentInfo(), | 186 | AgentInfo: agentInfo(), |
| 239 | AuthMethods: []sdk.AuthMethod{}, // the engine is local: nothing to log into | 187 | AuthMethods: []sdk.AuthMethod{}, // the engine is local: nothing to log into |
| 240 | }, nil | 188 | }, nil |
| 241 | } | 189 | } |
| 242 | 190 | ||
| 243 | -// sessionCapabilities advertises session/list and session/resume when | 191 | +// sessionCapabilities advertises session/resume always — without persisted |
| 244 | -// sessions are persisted, nothing otherwise. | 192 | +// sessions a reload starts the conversation over (persist.go, startOver) — |
| 193 | +// and session/list only when there are files to list. | ||
| 245 | func (f *front) sessionCapabilities() sdk.SessionCapabilities { | 194 | func (f *front) sessionCapabilities() sdk.SessionCapabilities { |
| 246 | if f.sessionsDir == "" { | 195 | if f.sessionsDir == "" { |
| 247 | - return sdk.SessionCapabilities{} | 196 | + return sdk.SessionCapabilities{Resume: &sdk.SessionResumeCapabilities{}} |
| 248 | } | 197 | } |
| 249 | return sdk.SessionCapabilities{List: &sdk.SessionListCapabilities{}, Resume: &sdk.SessionResumeCapabilities{}} | 198 | return sdk.SessionCapabilities{List: &sdk.SessionListCapabilities{}, Resume: &sdk.SessionResumeCapabilities{}} |
| 250 | } | 199 | } |
| @@ -299,7 +248,7 @@ func (f *front) NewSession(ctx context.Context, p sdk.NewSessionRequest) (sdk.Ne | |||
| 299 | // carries and drops updates for an id it does not know yet. Hence the hook | 248 | // carries and drops updates for an id it does not know yet. Hence the hook |
| 300 | // on the writer rather than a SessionUpdate call here (see responseWriter). | 249 | // on the writer rather than a SessionUpdate call here (see responseWriter). |
| 301 | // The request context dies with this method; the hook needs one that lives. | 250 | // The request context dies with this method; the hook needs one that lives. |
| 302 | - f.out.afterResponse(sid, func() { | 251 | + f.out.AfterResponse(sid, func() { |
| 303 | _ = f.conn.SessionUpdate(context.WithoutCancel(ctx), sdk.SessionNotification{ | 252 | _ = f.conn.SessionUpdate(context.WithoutCancel(ctx), sdk.SessionNotification{ |
| 304 | SessionId: sid, | 253 | SessionId: sid, |
| 305 | Update: sdk.SessionUpdate{AvailableCommandsUpdate: &sdk.SessionAvailableCommandsUpdate{AvailableCommands: availableCommands()}}, | 254 | Update: sdk.SessionUpdate{AvailableCommandsUpdate: &sdk.SessionAvailableCommandsUpdate{AvailableCommands: availableCommands()}}, |
modified
internal/acp/elicit.go +3 -39 | @@ -1,11 +1,10 @@ | ||
| 1 | 1 | package acp |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | - "bytes" | |
| 5 | 4 | "context" |
| 6 | - "encoding/json" | |
| 7 | 5 | "fmt" |
| 8 | 6 | |
| 7 | + "mm/internal/acpwire" | |
| 9 | 8 | "mm/internal/elicit" |
| 10 | 9 | "mm/internal/ui" |
| 11 | 10 | |
| @@ -128,40 +127,5 @@ func (k *sink) elicitByPermission(ctx context.Context, ev ui.ToolEvent, f elicit | ||
| 128 | 127 | return res |
| 129 | 128 | } |
| 130 | 129 | |
| 131 | -// --- the scope the SDK cannot carry ------------------------------------------------ | |
| 132 | - | |
| 133 | -// liftElicitationScope rewrites one outgoing JSON-RPC line. ACP ties a | |
| 134 | -// session-scoped elicitation to its session (and tool call) with `sessionId` | |
| 135 | -// and `toolCallId` at the top level of the params, but acp-go-sdk v0.13.5's | |
| 136 | -// UnstableCreateElicitationForm has no such fields — only `_meta`. So | |
| 137 | -// elicitByForm parks the scope in `_meta` (elicit.ScopeMetaKey), and this | |
| 138 | -// copies it where the specification puts it as the line is written out. The | |
| 139 | -// `_meta` copy stays: an mm client relaying the question reads its tool call | |
| 140 | -// there, the SDK dropping the top-level fields on its side too. Any other | |
| 141 | -// line comes back untouched. Remove both when the SDK carries the fields. | |
| 142 | -func liftElicitationScope(line []byte) []byte { | |
| 143 | - if !bytes.Contains(line, []byte(elicit.ScopeMetaKey)) { | |
| 144 | - return line | |
| 145 | - } | |
| 146 | - var msg map[string]any | |
| 147 | - if json.Unmarshal(line, &msg) != nil || msg["method"] != sdk.ClientMethodElicitationCreate { | |
| 148 | - return line | |
| 149 | - } | |
| 150 | - params, _ := msg["params"].(map[string]any) | |
| 151 | - meta, _ := params["_meta"].(map[string]any) | |
| 152 | - scope, ok := meta[elicit.ScopeMetaKey].(map[string]any) | |
| 153 | - if !ok { | |
| 154 | - return line | |
| 155 | - } | |
| 156 | - for key, value := range scope { | |
| 157 | - params[key] = value | |
| 158 | - } | |
| 159 | - out, err := json.Marshal(msg) | |
| 160 | - if err != nil { | |
| 161 | - return line | |
| 162 | - } | |
| 163 | - if bytes.HasSuffix(line, []byte("\n")) { | |
| 164 | - out = append(out, '\n') | |
| 165 | - } | |
| 166 | - return out | |
| 167 | -} | |
| 130 | +// liftElicitationScope is acpwire.LiftElicitationScope (see there). | |
| 131 | +var liftElicitationScope = acpwire.LiftElicitationScope | |
| @@ -1,11 +1,10 @@ | |||
| 1 | package acp | 1 | package acp |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | - "bytes" | ||
| 5 | "context" | 4 | "context" |
| 6 | - "encoding/json" | ||
| 7 | "fmt" | 5 | "fmt" |
| 8 | 6 | ||
| 7 | + "mm/internal/acpwire" | ||
| 9 | "mm/internal/elicit" | 8 | "mm/internal/elicit" |
| 10 | "mm/internal/ui" | 9 | "mm/internal/ui" |
| 11 | 10 | ||
| @@ -128,40 +127,5 @@ func (k *sink) elicitByPermission(ctx context.Context, ev ui.ToolEvent, f elicit | |||
| 128 | return res | 127 | return res |
| 129 | } | 128 | } |
| 130 | 129 | ||
| 131 | -// --- the scope the SDK cannot carry ------------------------------------------------ | 130 | +// liftElicitationScope is acpwire.LiftElicitationScope (see there). |
| 132 | - | 131 | +var liftElicitationScope = acpwire.LiftElicitationScope |
| 133 | -// liftElicitationScope rewrites one outgoing JSON-RPC line. ACP ties a | ||
| 134 | -// session-scoped elicitation to its session (and tool call) with `sessionId` | ||
| 135 | -// and `toolCallId` at the top level of the params, but acp-go-sdk v0.13.5's | ||
| 136 | -// UnstableCreateElicitationForm has no such fields — only `_meta`. So | ||
| 137 | -// elicitByForm parks the scope in `_meta` (elicit.ScopeMetaKey), and this | ||
| 138 | -// copies it where the specification puts it as the line is written out. The | ||
| 139 | -// `_meta` copy stays: an mm client relaying the question reads its tool call | ||
| 140 | -// there, the SDK dropping the top-level fields on its side too. Any other | ||
| 141 | -// line comes back untouched. Remove both when the SDK carries the fields. | ||
| 142 | -func liftElicitationScope(line []byte) []byte { | ||
| 143 | - if !bytes.Contains(line, []byte(elicit.ScopeMetaKey)) { | ||
| 144 | - return line | ||
| 145 | - } | ||
| 146 | - var msg map[string]any | ||
| 147 | - if json.Unmarshal(line, &msg) != nil || msg["method"] != sdk.ClientMethodElicitationCreate { | ||
| 148 | - return line | ||
| 149 | - } | ||
| 150 | - params, _ := msg["params"].(map[string]any) | ||
| 151 | - meta, _ := params["_meta"].(map[string]any) | ||
| 152 | - scope, ok := meta[elicit.ScopeMetaKey].(map[string]any) | ||
| 153 | - if !ok { | ||
| 154 | - return line | ||
| 155 | - } | ||
| 156 | - for key, value := range scope { | ||
| 157 | - params[key] = value | ||
| 158 | - } | ||
| 159 | - out, err := json.Marshal(msg) | ||
| 160 | - if err != nil { | ||
| 161 | - return line | ||
| 162 | - } | ||
| 163 | - if bytes.HasSuffix(line, []byte("\n")) { | ||
| 164 | - out = append(out, '\n') | ||
| 165 | - } | ||
| 166 | - return out | ||
| 167 | -} | ||
modified
internal/acp/persist.go +27 -4 | @@ -30,11 +30,12 @@ var _ sdk.AgentLoader = (*front)(nil) | ||
| 30 | 30 | // session/update notifications before the response — the spec's contract, |
| 31 | 31 | // and the editor accepts them because it asked for this id — then becomes |
| 32 | 32 | // the session's history again. Nothing is executed again (see |
| 33 | -// replayUpdates). Without persistence the method does not exist, as | |
| 34 | -// initialize said. | |
| 33 | +// replayUpdates). Without persistence there is nothing to read: the | |
| 34 | +// session starts over (startOver). | |
| 35 | 35 | func (f *front) LoadSession(ctx context.Context, p sdk.LoadSessionRequest) (sdk.LoadSessionResponse, error) { |
| 36 | 36 | if f.sessionsDir == "" { |
| 37 | - return sdk.LoadSessionResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionLoad) | |
| 37 | + f.startOver(ctx, "session/load", p.SessionId, p.Cwd) | |
| 38 | + return sdk.LoadSessionResponse{}, nil | |
| 38 | 39 | } |
| 39 | 40 | s, err := f.restore("session/load", p.SessionId, p.Cwd) |
| 40 | 41 | if err != nil { |
| @@ -55,7 +56,8 @@ func (f *front) LoadSession(ctx context.Context, p sdk.LoadSessionRequest) (sdk. | ||
| 55 | 56 | // would only duplicate it on its screen. |
| 56 | 57 | func (f *front) ResumeSession(ctx context.Context, p sdk.ResumeSessionRequest) (sdk.ResumeSessionResponse, error) { |
| 57 | 58 | if f.sessionsDir == "" { |
| 58 | - return sdk.ResumeSessionResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionResume) | |
| 59 | + f.startOver(ctx, "session/resume", p.SessionId, p.Cwd) | |
| 60 | + return sdk.ResumeSessionResponse{}, nil | |
| 59 | 61 | } |
| 60 | 62 | s, err := f.restore("session/resume", p.SessionId, p.Cwd) |
| 61 | 63 | if err != nil { |
| @@ -67,6 +69,27 @@ func (f *front) ResumeSession(ctx context.Context, p sdk.ResumeSessionRequest) ( | ||
| 67 | 69 | return sdk.ResumeSessionResponse{}, nil |
| 68 | 70 | } |
| 69 | 71 | |
| 72 | +// startOver is session/load and session/resume when sessions are not | |
| 73 | +// persisted: an editor's "reload agent" must not fail (it did, ticket 210 | |
| 74 | +// follow-up, user's choice 2026-11-14), but there is no history to bring | |
| 75 | +// back. The id the editor asked for becomes a new, empty session in cwd, | |
| 76 | +// and one message says the conversation starts over — and how to keep the | |
| 77 | +// next one (sessions.enabled). | |
| 78 | +func (f *front) startOver(ctx context.Context, method string, id sdk.SessionId, cwd string) { | |
| 79 | + s := &session{cwd: cwd, messages: history.Fresh(agentsmd.System(f.system, cwd))} | |
| 80 | + s.clientTools = f.sessionClientTools(ctx, id, cwd) | |
| 81 | + f.mu.Lock() | |
| 82 | + f.sessions[id] = s | |
| 83 | + f.mu.Unlock() | |
| 84 | + fmt.Fprintf(os.Stderr, "[acp] %s %s cwd=%s: sessions are not saved, starting over\n", method, id, cwd) | |
| 85 | + f.sendCommands(ctx, id) | |
| 86 | + _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{SessionId: id, Update: sdk.UpdateAgentMessageText(startOverMessage)}) | |
| 87 | +} | |
| 88 | + | |
| 89 | +// startOverMessage is what the editor shows after a reload without saved | |
| 90 | +// sessions. | |
| 91 | +const startOverMessage = "mm restarted: this agent does not save its conversations, so the history above is gone and the conversation starts over. To keep it across a reload, set `sessions: {enabled: true}` in the agent file." | |
| 92 | + | |
| 70 | 93 | // restore reads the session id stored under cwd and registers it as live, |
| 71 | 94 | // its history continuing under TODAY's system prompt: the file may predate a |
| 72 | 95 | // change to agent.yaml, and the prompt is the agent's, not the |
| @@ -30,11 +30,12 @@ var _ sdk.AgentLoader = (*front)(nil) | |||
| 30 | // session/update notifications before the response — the spec's contract, | 30 | // session/update notifications before the response — the spec's contract, |
| 31 | // and the editor accepts them because it asked for this id — then becomes | 31 | // and the editor accepts them because it asked for this id — then becomes |
| 32 | // the session's history again. Nothing is executed again (see | 32 | // the session's history again. Nothing is executed again (see |
| 33 | -// replayUpdates). Without persistence the method does not exist, as | 33 | +// replayUpdates). Without persistence there is nothing to read: the |
| 34 | -// initialize said. | 34 | +// session starts over (startOver). |
| 35 | func (f *front) LoadSession(ctx context.Context, p sdk.LoadSessionRequest) (sdk.LoadSessionResponse, error) { | 35 | func (f *front) LoadSession(ctx context.Context, p sdk.LoadSessionRequest) (sdk.LoadSessionResponse, error) { |
| 36 | if f.sessionsDir == "" { | 36 | if f.sessionsDir == "" { |
| 37 | - return sdk.LoadSessionResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionLoad) | 37 | + f.startOver(ctx, "session/load", p.SessionId, p.Cwd) |
| 38 | + return sdk.LoadSessionResponse{}, nil | ||
| 38 | } | 39 | } |
| 39 | s, err := f.restore("session/load", p.SessionId, p.Cwd) | 40 | s, err := f.restore("session/load", p.SessionId, p.Cwd) |
| 40 | if err != nil { | 41 | if err != nil { |
| @@ -55,7 +56,8 @@ func (f *front) LoadSession(ctx context.Context, p sdk.LoadSessionRequest) (sdk. | |||
| 55 | // would only duplicate it on its screen. | 56 | // would only duplicate it on its screen. |
| 56 | func (f *front) ResumeSession(ctx context.Context, p sdk.ResumeSessionRequest) (sdk.ResumeSessionResponse, error) { | 57 | func (f *front) ResumeSession(ctx context.Context, p sdk.ResumeSessionRequest) (sdk.ResumeSessionResponse, error) { |
| 57 | if f.sessionsDir == "" { | 58 | if f.sessionsDir == "" { |
| 58 | - return sdk.ResumeSessionResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionResume) | 59 | + f.startOver(ctx, "session/resume", p.SessionId, p.Cwd) |
| 60 | + return sdk.ResumeSessionResponse{}, nil | ||
| 59 | } | 61 | } |
| 60 | s, err := f.restore("session/resume", p.SessionId, p.Cwd) | 62 | s, err := f.restore("session/resume", p.SessionId, p.Cwd) |
| 61 | if err != nil { | 63 | if err != nil { |
| @@ -67,6 +69,27 @@ func (f *front) ResumeSession(ctx context.Context, p sdk.ResumeSessionRequest) ( | |||
| 67 | return sdk.ResumeSessionResponse{}, nil | 69 | return sdk.ResumeSessionResponse{}, nil |
| 68 | } | 70 | } |
| 69 | 71 | ||
| 72 | +// startOver is session/load and session/resume when sessions are not | ||
| 73 | +// persisted: an editor's "reload agent" must not fail (it did, ticket 210 | ||
| 74 | +// follow-up, user's choice 2026-11-14), but there is no history to bring | ||
| 75 | +// back. The id the editor asked for becomes a new, empty session in cwd, | ||
| 76 | +// and one message says the conversation starts over — and how to keep the | ||
| 77 | +// next one (sessions.enabled). | ||
| 78 | +func (f *front) startOver(ctx context.Context, method string, id sdk.SessionId, cwd string) { | ||
| 79 | + s := &session{cwd: cwd, messages: history.Fresh(agentsmd.System(f.system, cwd))} | ||
| 80 | + s.clientTools = f.sessionClientTools(ctx, id, cwd) | ||
| 81 | + f.mu.Lock() | ||
| 82 | + f.sessions[id] = s | ||
| 83 | + f.mu.Unlock() | ||
| 84 | + fmt.Fprintf(os.Stderr, "[acp] %s %s cwd=%s: sessions are not saved, starting over\n", method, id, cwd) | ||
| 85 | + f.sendCommands(ctx, id) | ||
| 86 | + _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{SessionId: id, Update: sdk.UpdateAgentMessageText(startOverMessage)}) | ||
| 87 | +} | ||
| 88 | + | ||
| 89 | +// startOverMessage is what the editor shows after a reload without saved | ||
| 90 | +// sessions. | ||
| 91 | +const startOverMessage = "mm restarted: this agent does not save its conversations, so the history above is gone and the conversation starts over. To keep it across a reload, set `sessions: {enabled: true}` in the agent file." | ||
| 92 | + | ||
| 70 | // restore reads the session id stored under cwd and registers it as live, | 93 | // restore reads the session id stored under cwd and registers it as live, |
| 71 | // its history continuing under TODAY's system prompt: the file may predate a | 94 | // its history continuing under TODAY's system prompt: the file may predate a |
| 72 | // change to agent.yaml, and the prompt is the agent's, not the | 95 | // change to agent.yaml, and the prompt is the agent's, not the |
modified
internal/acp/persist_test.go +37 -7 | @@ -160,18 +160,48 @@ func frontOf(t *testing.T, _ *wire) *front { | ||
| 160 | 160 | } |
| 161 | 161 | |
| 162 | 162 | // Unknown id, or persistence off: two different refusals, both explicit. |
| 163 | -func TestLoadSessionRefusals(t *testing.T) { | |
| 163 | +// Without saved sessions, a reload (session/load or session/resume) does | |
| 164 | +// not fail: the id the editor asked for becomes a new, empty session, the | |
| 165 | +// commands are announced and one message says the conversation starts over. | |
| 166 | +// initialize offers both, but not session/list: there is nothing to list. | |
| 167 | +func TestReloadWithoutSessionsStartsOver(t *testing.T) { | |
| 164 | 168 | w := newWire(t, "sys") |
| 165 | - w.send(1, "session/load", `{"sessionId":"nope","cwd":"`+t.TempDir()+`","mcpServers":[]}`) | |
| 166 | - _, resp := w.until(1) | |
| 167 | - if e, _ := resp["error"].(map[string]any); e == nil || e["code"].(float64) != -32601 { | |
| 168 | - t.Errorf("persistence off: %v, want method not found (-32601)", resp["error"]) | |
| 169 | + w.send(1, "initialize", `{"protocolVersion":1,"clientCapabilities":{}}`) | |
| 170 | + _, init := w.until(1) | |
| 171 | + caps := init["result"].(map[string]any)["agentCapabilities"].(map[string]any) | |
| 172 | + session, _ := caps["sessionCapabilities"].(map[string]any) | |
| 173 | + if caps["loadSession"] != true || session == nil || session["resume"] == nil || session["list"] != nil { | |
| 174 | + t.Errorf("agentCapabilities = %v, want loadSession and resume, no list", caps) | |
| 175 | + } | |
| 176 | + for i, method := range []string{"session/load", "session/resume"} { | |
| 177 | + id := 2 + i | |
| 178 | + cwd := t.TempDir() | |
| 179 | + w.send(id, method, `{"sessionId":"old-thread","cwd":"`+cwd+`","mcpServers":[]}`) | |
| 180 | + notes, resp := w.until(id) | |
| 181 | + if resp["error"] != nil { | |
| 182 | + t.Fatalf("%s: %v", method, resp["error"]) | |
| 183 | + } | |
| 184 | + raw, _ := json.Marshal(notes) | |
| 185 | + text := string(raw) | |
| 186 | + if !strings.Contains(text, "available_commands_update") || !strings.Contains(text, "starts over") { | |
| 187 | + t.Errorf("%s: notifications:\n%s", method, text) | |
| 188 | + } | |
| 189 | + if _, err := os.Stat(filepath.Join(cwd, ".mm")); err == nil { | |
| 190 | + t.Errorf("%s: a file was written with persistence off", method) | |
| 191 | + } | |
| 192 | + if lastFront.sessions["old-thread"] == nil { | |
| 193 | + t.Errorf("%s: the session is not live under the requested id", method) | |
| 194 | + } | |
| 169 | 195 | } |
| 196 | +} | |
| 170 | 197 | |
| 198 | +// With saved sessions, an unknown id is refused: there the editor expects its | |
| 199 | +// history, and silently starting over would hide a missing file. | |
| 200 | +func TestLoadSessionRefusals(t *testing.T) { | |
| 171 | 201 | withSessions(t) |
| 172 | - w = newWire(t, "sys") | |
| 202 | + w := newWire(t, "sys") | |
| 173 | 203 | w.send(2, "session/load", `{"sessionId":"nope","cwd":"`+t.TempDir()+`","mcpServers":[]}`) |
| 174 | - _, resp = w.until(2) | |
| 204 | + _, resp := w.until(2) | |
| 175 | 205 | if e, _ := resp["error"].(map[string]any); e == nil || e["code"].(float64) != -32602 { |
| 176 | 206 | t.Errorf("unknown id: %v, want invalid params (-32602)", resp["error"]) |
| 177 | 207 | } |
| @@ -160,18 +160,48 @@ func frontOf(t *testing.T, _ *wire) *front { | |||
| 160 | } | 160 | } |
| 161 | 161 | ||
| 162 | // Unknown id, or persistence off: two different refusals, both explicit. | 162 | // Unknown id, or persistence off: two different refusals, both explicit. |
| 163 | -func TestLoadSessionRefusals(t *testing.T) { | 163 | +// Without saved sessions, a reload (session/load or session/resume) does |
| 164 | +// not fail: the id the editor asked for becomes a new, empty session, the | ||
| 165 | +// commands are announced and one message says the conversation starts over. | ||
| 166 | +// initialize offers both, but not session/list: there is nothing to list. | ||
| 167 | +func TestReloadWithoutSessionsStartsOver(t *testing.T) { | ||
| 164 | w := newWire(t, "sys") | 168 | w := newWire(t, "sys") |
| 165 | - w.send(1, "session/load", `{"sessionId":"nope","cwd":"`+t.TempDir()+`","mcpServers":[]}`) | 169 | + w.send(1, "initialize", `{"protocolVersion":1,"clientCapabilities":{}}`) |
| 166 | - _, resp := w.until(1) | 170 | + _, init := w.until(1) |
| 167 | - if e, _ := resp["error"].(map[string]any); e == nil || e["code"].(float64) != -32601 { | 171 | + caps := init["result"].(map[string]any)["agentCapabilities"].(map[string]any) |
| 168 | - t.Errorf("persistence off: %v, want method not found (-32601)", resp["error"]) | 172 | + session, _ := caps["sessionCapabilities"].(map[string]any) |
| 173 | + if caps["loadSession"] != true || session == nil || session["resume"] == nil || session["list"] != nil { | ||
| 174 | + t.Errorf("agentCapabilities = %v, want loadSession and resume, no list", caps) | ||
| 175 | + } | ||
| 176 | + for i, method := range []string{"session/load", "session/resume"} { | ||
| 177 | + id := 2 + i | ||
| 178 | + cwd := t.TempDir() | ||
| 179 | + w.send(id, method, `{"sessionId":"old-thread","cwd":"`+cwd+`","mcpServers":[]}`) | ||
| 180 | + notes, resp := w.until(id) | ||
| 181 | + if resp["error"] != nil { | ||
| 182 | + t.Fatalf("%s: %v", method, resp["error"]) | ||
| 183 | + } | ||
| 184 | + raw, _ := json.Marshal(notes) | ||
| 185 | + text := string(raw) | ||
| 186 | + if !strings.Contains(text, "available_commands_update") || !strings.Contains(text, "starts over") { | ||
| 187 | + t.Errorf("%s: notifications:\n%s", method, text) | ||
| 188 | + } | ||
| 189 | + if _, err := os.Stat(filepath.Join(cwd, ".mm")); err == nil { | ||
| 190 | + t.Errorf("%s: a file was written with persistence off", method) | ||
| 191 | + } | ||
| 192 | + if lastFront.sessions["old-thread"] == nil { | ||
| 193 | + t.Errorf("%s: the session is not live under the requested id", method) | ||
| 194 | + } | ||
| 169 | } | 195 | } |
| 196 | +} | ||
| 170 | 197 | ||
| 198 | +// With saved sessions, an unknown id is refused: there the editor expects its | ||
| 199 | +// history, and silently starting over would hide a missing file. | ||
| 200 | +func TestLoadSessionRefusals(t *testing.T) { | ||
| 171 | withSessions(t) | 201 | withSessions(t) |
| 172 | - w = newWire(t, "sys") | 202 | + w := newWire(t, "sys") |
| 173 | w.send(2, "session/load", `{"sessionId":"nope","cwd":"`+t.TempDir()+`","mcpServers":[]}`) | 203 | w.send(2, "session/load", `{"sessionId":"nope","cwd":"`+t.TempDir()+`","mcpServers":[]}`) |
| 174 | - _, resp = w.until(2) | 204 | + _, resp := w.until(2) |
| 175 | if e, _ := resp["error"].(map[string]any); e == nil || e["code"].(float64) != -32602 { | 205 | if e, _ := resp["error"].(map[string]any); e == nil || e["code"].(float64) != -32602 { |
| 176 | t.Errorf("unknown id: %v, want invalid params (-32602)", resp["error"]) | 206 | t.Errorf("unknown id: %v, want invalid params (-32602)", resp["error"]) |
| 177 | } | 207 | } |
modified
internal/acp/sessionlist_test.go +3 -6 | @@ -109,7 +109,9 @@ func TestResumeSessionRestoresWithoutReplay(t *testing.T) { | ||
| 109 | 109 | } |
| 110 | 110 | } |
| 111 | 111 | |
| 112 | -// Persistence off: list and resume do not exist. Unknown id: invalid params. | |
| 112 | +// Persistence off: list does not exist (resume starts over: see | |
| 113 | +// TestReloadWithoutSessionsStartsOver). Unknown id with persistence on: | |
| 114 | +// invalid params. | |
| 113 | 115 | func TestListAndResumeRefusals(t *testing.T) { |
| 114 | 116 | w := newWire(t, "sys") |
| 115 | 117 | w.send(1, "session/list", `{}`) |
| @@ -117,11 +119,6 @@ func TestListAndResumeRefusals(t *testing.T) { | ||
| 117 | 119 | if e, _ := resp["error"].(map[string]any); e == nil || e["code"].(float64) != -32601 { |
| 118 | 120 | t.Errorf("list, persistence off: %v, want method not found", resp["error"]) |
| 119 | 121 | } |
| 120 | - w.send(2, "session/resume", `{"sessionId":"nope","cwd":"`+t.TempDir()+`"}`) | |
| 121 | - _, resp = w.until(2) | |
| 122 | - if e, _ := resp["error"].(map[string]any); e == nil || e["code"].(float64) != -32601 { | |
| 123 | - t.Errorf("resume, persistence off: %v, want method not found", resp["error"]) | |
| 124 | - } | |
| 125 | 122 | |
| 126 | 123 | withSessions(t) |
| 127 | 124 | w = newWire(t, "sys") |
| @@ -109,7 +109,9 @@ func TestResumeSessionRestoresWithoutReplay(t *testing.T) { | |||
| 109 | } | 109 | } |
| 110 | } | 110 | } |
| 111 | 111 | ||
| 112 | -// Persistence off: list and resume do not exist. Unknown id: invalid params. | 112 | +// Persistence off: list does not exist (resume starts over: see |
| 113 | +// TestReloadWithoutSessionsStartsOver). Unknown id with persistence on: | ||
| 114 | +// invalid params. | ||
| 113 | func TestListAndResumeRefusals(t *testing.T) { | 115 | func TestListAndResumeRefusals(t *testing.T) { |
| 114 | w := newWire(t, "sys") | 116 | w := newWire(t, "sys") |
| 115 | w.send(1, "session/list", `{}`) | 117 | w.send(1, "session/list", `{}`) |
| @@ -117,11 +119,6 @@ func TestListAndResumeRefusals(t *testing.T) { | |||
| 117 | if e, _ := resp["error"].(map[string]any); e == nil || e["code"].(float64) != -32601 { | 119 | if e, _ := resp["error"].(map[string]any); e == nil || e["code"].(float64) != -32601 { |
| 118 | t.Errorf("list, persistence off: %v, want method not found", resp["error"]) | 120 | t.Errorf("list, persistence off: %v, want method not found", resp["error"]) |
| 119 | } | 121 | } |
| 120 | - w.send(2, "session/resume", `{"sessionId":"nope","cwd":"`+t.TempDir()+`"}`) | ||
| 121 | - _, resp = w.until(2) | ||
| 122 | - if e, _ := resp["error"].(map[string]any); e == nil || e["code"].(float64) != -32601 { | ||
| 123 | - t.Errorf("resume, persistence off: %v, want method not found", resp["error"]) | ||
| 124 | - } | ||
| 125 | 122 | ||
| 126 | withSessions(t) | 123 | withSessions(t) |
| 127 | w = newWire(t, "sys") | 124 | w = newWire(t, "sys") |
added
internal/acpcreate/acpcreate.go +241 -0 | new file mode 100644 | ||
| @@ -0,0 +1,241 @@ | ||
| 1 | +// Package acpcreate is `mm acp-create` (and `mm --acp-create`, ticket 210): | |
| 2 | +// mm init's wizard served over the Agent Client Protocol, so that an ACP | |
| 3 | +// client — Zed, JetBrains, a Neovim plugin, any other — can create an | |
| 4 | +// agent.yaml without a terminal. | |
| 5 | +// | |
| 6 | +// It is not an LLM agent: no configuration is read and no model is called | |
| 7 | +// (the wizard does ask the model servers for their model lists, as mm init | |
| 8 | +// does). Each session drives one agentinit.Flow — the same state the line | |
| 9 | +// mode and the window drive — and writes the file in the session's cwd. | |
| 10 | +// | |
| 11 | +// How the questions are asked depends on what the client ADVERTISES at | |
| 12 | +// initialize, never on its name (ask.go): | |
| 13 | +// | |
| 14 | +// - clientCapabilities.elicitation.form: the questions go as | |
| 15 | +// elicitation/create forms, several independent questions per form; | |
| 16 | +// - otherwise, the conversation: each question is a chat message, the | |
| 17 | +// answer the next prompt. | |
| 18 | +// | |
| 19 | +// stdout carries JSON-RPC only; the [acp-create] trail goes to stderr. | |
| 20 | +package acpcreate | |
| 21 | + | |
| 22 | +import ( | |
| 23 | + "context" | |
| 24 | + "crypto/rand" | |
| 25 | + "encoding/hex" | |
| 26 | + "fmt" | |
| 27 | + "io" | |
| 28 | + "log/slog" | |
| 29 | + "strings" | |
| 30 | + "sync" | |
| 31 | + | |
| 32 | + "mm/internal/acpwire" | |
| 33 | + "mm/internal/agentinit" | |
| 34 | + "mm/internal/version" | |
| 35 | + | |
| 36 | + sdk "github.com/coder/acp-go-sdk" | |
| 37 | +) | |
| 38 | + | |
| 39 | +// Main is `mm acp-create [mm init's flags]`: it serves the wizard on stdin | |
| 40 | +// and stdout until the client closes the connection. Flags work as for mm | |
| 41 | +// init — a given answer is never asked — except --yes, --line and --check, | |
| 42 | +// which have no meaning here. Help and errors go to stderr: stdout is the | |
| 43 | +// protocol's. | |
| 44 | +func Main(args []string, stdin io.Reader, stdout, stderr io.Writer) int { | |
| 45 | + a, code, ok := agentinit.ParseArgs("mm acp-create", args, stderr, false) | |
| 46 | + if !ok { | |
| 47 | + return code | |
| 48 | + } | |
| 49 | + // A bad flag fails now, in the client's log, rather than at each session. | |
| 50 | + if _, err := a.Flow(context.Background()); err != nil { | |
| 51 | + fmt.Fprintln(stderr, "⚠️ "+err.Error()) | |
| 52 | + return 1 | |
| 53 | + } | |
| 54 | + ag := newAgent(a, stdout, stdin, stderr) | |
| 55 | + <-ag.conn.Done() | |
| 56 | + return 0 | |
| 57 | +} | |
| 58 | + | |
| 59 | +// agent is the wizard's side of one ACP connection. | |
| 60 | +type agent struct { | |
| 61 | + args agentinit.Args | |
| 62 | + conn *sdk.AgentSideConnection | |
| 63 | + out *acpwire.Writer | |
| 64 | + log io.Writer | |
| 65 | + | |
| 66 | + // elicitForm: the client advertised elicitation's form mode. | |
| 67 | + elicitForm bool | |
| 68 | + // client is the client's name from initialize, for the closing | |
| 69 | + // message's configuration snippet only — never to choose a behaviour. | |
| 70 | + client string | |
| 71 | + | |
| 72 | + mu sync.Mutex | |
| 73 | + sessions map[sdk.SessionId]*session | |
| 74 | +} | |
| 75 | + | |
| 76 | +var ( | |
| 77 | + _ sdk.Agent = (*agent)(nil) | |
| 78 | + _ sdk.AgentLoader = (*agent)(nil) | |
| 79 | +) | |
| 80 | + | |
| 81 | +// newAgent wires an agent on a pair of streams: stdio in Main, pipes in the | |
| 82 | +// tests. w is where the agent writes, r where it reads the client. | |
| 83 | +func newAgent(a agentinit.Args, w io.Writer, r io.Reader, log io.Writer) *agent { | |
| 84 | + ag := &agent{args: a, log: log, sessions: map[sdk.SessionId]*session{}} | |
| 85 | + ag.out = acpwire.NewWriter(w) | |
| 86 | + ag.conn = sdk.NewAgentSideConnection(ag, ag.out, r) | |
| 87 | + ag.conn.SetLogger(slog.New(slog.NewTextHandler(log, nil))) | |
| 88 | + return ag | |
| 89 | +} | |
| 90 | + | |
| 91 | +func (ag *agent) Initialize(_ context.Context, p sdk.InitializeRequest) (sdk.InitializeResponse, error) { | |
| 92 | + // Form support exists only when `form` is present: ACP requires each | |
| 93 | + // mode to be explicit. | |
| 94 | + if e := p.ClientCapabilities.Elicitation; e != nil && e.Form != nil { | |
| 95 | + ag.elicitForm = true | |
| 96 | + } | |
| 97 | + if p.ClientInfo != nil { | |
| 98 | + ag.client = p.ClientInfo.Name | |
| 99 | + } | |
| 100 | + fmt.Fprintf(ag.log, "[acp-create] initialize: client %q elicitation(form)=%v\n", ag.client, ag.elicitForm) | |
| 101 | + return sdk.InitializeResponse{ | |
| 102 | + ProtocolVersion: sdk.ProtocolVersionNumber, | |
| 103 | + // loadSession and session/resume, so that a client's "reload agent" | |
| 104 | + // works (it restarts mm, then asks for its thread back). Nothing is | |
| 105 | + // kept across a restart: the wizard starts over (restart.go). | |
| 106 | + AgentCapabilities: sdk.AgentCapabilities{ | |
| 107 | + LoadSession: true, | |
| 108 | + SessionCapabilities: sdk.SessionCapabilities{Resume: &sdk.SessionResumeCapabilities{}}, | |
| 109 | + }, | |
| 110 | + AgentInfo: &sdk.Implementation{Name: "mm-acp-create", Title: sdk.Ptr("mm init (agent.yaml wizard)"), Version: version.Short()}, | |
| 111 | + AuthMethods: []sdk.AuthMethod{}, | |
| 112 | + }, nil | |
| 113 | +} | |
| 114 | + | |
| 115 | +func (ag *agent) Authenticate(context.Context, sdk.AuthenticateRequest) (sdk.AuthenticateResponse, error) { | |
| 116 | + return sdk.AuthenticateResponse{}, nil | |
| 117 | +} | |
| 118 | + | |
| 119 | +// NewSession starts a flow whose files land in the session's cwd — the | |
| 120 | +// project the client opened. | |
| 121 | +func (ag *agent) NewSession(ctx context.Context, p sdk.NewSessionRequest) (sdk.NewSessionResponse, error) { | |
| 122 | + s, err := ag.newSession(p.Cwd) | |
| 123 | + if err != nil { | |
| 124 | + return sdk.NewSessionResponse{}, err | |
| 125 | + } | |
| 126 | + sid := newID() | |
| 127 | + ag.mu.Lock() | |
| 128 | + ag.sessions[sid] = s | |
| 129 | + ag.mu.Unlock() | |
| 130 | + fmt.Fprintf(ag.log, "[acp-create] session %s cwd=%s\n", sid, p.Cwd) | |
| 131 | + // The commands go right after the response: a client drops updates for | |
| 132 | + // a session id it has not received yet (see acpwire.Writer). | |
| 133 | + ag.out.AfterResponse(sid, func() { | |
| 134 | + _ = ag.conn.SessionUpdate(context.WithoutCancel(ctx), sdk.SessionNotification{ | |
| 135 | + SessionId: sid, | |
| 136 | + Update: sdk.SessionUpdate{AvailableCommandsUpdate: &sdk.SessionAvailableCommandsUpdate{AvailableCommands: availableCommands()}}, | |
| 137 | + }) | |
| 138 | + // Not every client shows a message outside a turn; /start, first | |
| 139 | + // in the command list, is the way in for those. | |
| 140 | + ag.say(context.WithoutCancel(ctx), sid, welcome(p.Cwd)) | |
| 141 | + }) | |
| 142 | + return sdk.NewSessionResponse{SessionId: sid}, nil | |
| 143 | +} | |
| 144 | + | |
| 145 | +// newSession builds a session on a fresh flow, rooted at cwd. | |
| 146 | +func (ag *agent) newSession(cwd string) (*session, error) { | |
| 147 | + flow, err := ag.args.Flow(context.Background()) | |
| 148 | + if err != nil { | |
| 149 | + return nil, err | |
| 150 | + } | |
| 151 | + flow.SetDir(cwd) | |
| 152 | + return &session{cwd: cwd, flow: flow}, nil | |
| 153 | +} | |
| 154 | + | |
| 155 | +// Prompt is one message from the user: an answer, a command, or — the | |
| 156 | +// first time — the start of the wizard. | |
| 157 | +func (ag *agent) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResponse, error) { | |
| 158 | + ag.mu.Lock() | |
| 159 | + s, ok := ag.sessions[p.SessionId] | |
| 160 | + ag.mu.Unlock() | |
| 161 | + if !ok { | |
| 162 | + return sdk.PromptResponse{}, sdk.NewInvalidParams(map[string]any{"sessionId": p.SessionId}) | |
| 163 | + } | |
| 164 | + s.mu.Lock() | |
| 165 | + defer s.mu.Unlock() | |
| 166 | + turnCtx, cancel := context.WithCancel(ctx) | |
| 167 | + s.setCancel(cancel) | |
| 168 | + defer func() { | |
| 169 | + s.setCancel(nil) | |
| 170 | + cancel() | |
| 171 | + }() | |
| 172 | + t := &turn{ag: ag, s: s, sid: p.SessionId, ctx: turnCtx} | |
| 173 | + t.handle(textOf(p.Prompt)) | |
| 174 | + if turnCtx.Err() != nil { | |
| 175 | + return sdk.PromptResponse{StopReason: sdk.StopReasonCancelled}, nil | |
| 176 | + } | |
| 177 | + return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}, nil | |
| 178 | +} | |
| 179 | + | |
| 180 | +// Cancel stops the turn in flight: a pending form falls with it. | |
| 181 | +func (ag *agent) Cancel(_ context.Context, p sdk.CancelNotification) error { | |
| 182 | + ag.mu.Lock() | |
| 183 | + s := ag.sessions[p.SessionId] | |
| 184 | + ag.mu.Unlock() | |
| 185 | + if s != nil { | |
| 186 | + s.cancelTurn() | |
| 187 | + } | |
| 188 | + return nil | |
| 189 | +} | |
| 190 | + | |
| 191 | +func (ag *agent) CloseSession(_ context.Context, p sdk.CloseSessionRequest) (sdk.CloseSessionResponse, error) { | |
| 192 | + ag.mu.Lock() | |
| 193 | + delete(ag.sessions, p.SessionId) | |
| 194 | + ag.mu.Unlock() | |
| 195 | + return sdk.CloseSessionResponse{}, nil | |
| 196 | +} | |
| 197 | + | |
| 198 | +func (ag *agent) ListSessions(context.Context, sdk.ListSessionsRequest) (sdk.ListSessionsResponse, error) { | |
| 199 | + return sdk.ListSessionsResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionList) | |
| 200 | +} | |
| 201 | + | |
| 202 | +func (ag *agent) SetSessionMode(context.Context, sdk.SetSessionModeRequest) (sdk.SetSessionModeResponse, error) { | |
| 203 | + return sdk.SetSessionModeResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionSetMode) | |
| 204 | +} | |
| 205 | + | |
| 206 | +func (ag *agent) SetSessionConfigOption(context.Context, sdk.SetSessionConfigOptionRequest) (sdk.SetSessionConfigOptionResponse, error) { | |
| 207 | + return sdk.SetSessionConfigOptionResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionSetConfigOption) | |
| 208 | +} | |
| 209 | + | |
| 210 | +func (ag *agent) Logout(context.Context, sdk.LogoutRequest) (sdk.LogoutResponse, error) { | |
| 211 | + return sdk.LogoutResponse{}, nil | |
| 212 | +} | |
| 213 | + | |
| 214 | +// say sends one agent message to the session. | |
| 215 | +func (ag *agent) say(ctx context.Context, sid sdk.SessionId, markdown string) { | |
| 216 | + _ = ag.conn.SessionUpdate(ctx, sdk.SessionNotification{SessionId: sid, Update: sdk.UpdateAgentMessageText(markdown)}) | |
| 217 | +} | |
| 218 | + | |
| 219 | +// textOf keeps the text of a prompt; attachments mean nothing here. | |
| 220 | +func textOf(blocks []sdk.ContentBlock) string { | |
| 221 | + var parts []string | |
| 222 | + for _, b := range blocks { | |
| 223 | + if b.Text != nil { | |
| 224 | + parts = append(parts, b.Text.Text) | |
| 225 | + } | |
| 226 | + } | |
| 227 | + return strings.Join(parts, "\n") | |
| 228 | +} | |
| 229 | + | |
| 230 | +// newID is a session id: random, unique enough for one connection. | |
| 231 | +func newID() sdk.SessionId { | |
| 232 | + b := make([]byte, 8) | |
| 233 | + _, _ = rand.Read(b) | |
| 234 | + return sdk.SessionId("create-" + hex.EncodeToString(b)) | |
| 235 | +} | |
| 236 | + | |
| 237 | +// Is reports whether mm's first argument asks for this mode: `mm | |
| 238 | +// acp-create`, `mm --acp-create` or `mm -acp-create`. | |
| 239 | +func Is(arg string) bool { | |
| 240 | + return arg == "acp-create" || arg == "--acp-create" || arg == "-acp-create" | |
| 241 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,241 @@ | |||
| 1 | +// Package acpcreate is `mm acp-create` (and `mm --acp-create`, ticket 210): | ||
| 2 | +// mm init's wizard served over the Agent Client Protocol, so that an ACP | ||
| 3 | +// client — Zed, JetBrains, a Neovim plugin, any other — can create an | ||
| 4 | +// agent.yaml without a terminal. | ||
| 5 | +// | ||
| 6 | +// It is not an LLM agent: no configuration is read and no model is called | ||
| 7 | +// (the wizard does ask the model servers for their model lists, as mm init | ||
| 8 | +// does). Each session drives one agentinit.Flow — the same state the line | ||
| 9 | +// mode and the window drive — and writes the file in the session's cwd. | ||
| 10 | +// | ||
| 11 | +// How the questions are asked depends on what the client ADVERTISES at | ||
| 12 | +// initialize, never on its name (ask.go): | ||
| 13 | +// | ||
| 14 | +// - clientCapabilities.elicitation.form: the questions go as | ||
| 15 | +// elicitation/create forms, several independent questions per form; | ||
| 16 | +// - otherwise, the conversation: each question is a chat message, the | ||
| 17 | +// answer the next prompt. | ||
| 18 | +// | ||
| 19 | +// stdout carries JSON-RPC only; the [acp-create] trail goes to stderr. | ||
| 20 | +package acpcreate | ||
| 21 | + | ||
| 22 | +import ( | ||
| 23 | + "context" | ||
| 24 | + "crypto/rand" | ||
| 25 | + "encoding/hex" | ||
| 26 | + "fmt" | ||
| 27 | + "io" | ||
| 28 | + "log/slog" | ||
| 29 | + "strings" | ||
| 30 | + "sync" | ||
| 31 | + | ||
| 32 | + "mm/internal/acpwire" | ||
| 33 | + "mm/internal/agentinit" | ||
| 34 | + "mm/internal/version" | ||
| 35 | + | ||
| 36 | + sdk "github.com/coder/acp-go-sdk" | ||
| 37 | +) | ||
| 38 | + | ||
| 39 | +// Main is `mm acp-create [mm init's flags]`: it serves the wizard on stdin | ||
| 40 | +// and stdout until the client closes the connection. Flags work as for mm | ||
| 41 | +// init — a given answer is never asked — except --yes, --line and --check, | ||
| 42 | +// which have no meaning here. Help and errors go to stderr: stdout is the | ||
| 43 | +// protocol's. | ||
| 44 | +func Main(args []string, stdin io.Reader, stdout, stderr io.Writer) int { | ||
| 45 | + a, code, ok := agentinit.ParseArgs("mm acp-create", args, stderr, false) | ||
| 46 | + if !ok { | ||
| 47 | + return code | ||
| 48 | + } | ||
| 49 | + // A bad flag fails now, in the client's log, rather than at each session. | ||
| 50 | + if _, err := a.Flow(context.Background()); err != nil { | ||
| 51 | + fmt.Fprintln(stderr, "⚠️ "+err.Error()) | ||
| 52 | + return 1 | ||
| 53 | + } | ||
| 54 | + ag := newAgent(a, stdout, stdin, stderr) | ||
| 55 | + <-ag.conn.Done() | ||
| 56 | + return 0 | ||
| 57 | +} | ||
| 58 | + | ||
| 59 | +// agent is the wizard's side of one ACP connection. | ||
| 60 | +type agent struct { | ||
| 61 | + args agentinit.Args | ||
| 62 | + conn *sdk.AgentSideConnection | ||
| 63 | + out *acpwire.Writer | ||
| 64 | + log io.Writer | ||
| 65 | + | ||
| 66 | + // elicitForm: the client advertised elicitation's form mode. | ||
| 67 | + elicitForm bool | ||
| 68 | + // client is the client's name from initialize, for the closing | ||
| 69 | + // message's configuration snippet only — never to choose a behaviour. | ||
| 70 | + client string | ||
| 71 | + | ||
| 72 | + mu sync.Mutex | ||
| 73 | + sessions map[sdk.SessionId]*session | ||
| 74 | +} | ||
| 75 | + | ||
| 76 | +var ( | ||
| 77 | + _ sdk.Agent = (*agent)(nil) | ||
| 78 | + _ sdk.AgentLoader = (*agent)(nil) | ||
| 79 | +) | ||
| 80 | + | ||
| 81 | +// newAgent wires an agent on a pair of streams: stdio in Main, pipes in the | ||
| 82 | +// tests. w is where the agent writes, r where it reads the client. | ||
| 83 | +func newAgent(a agentinit.Args, w io.Writer, r io.Reader, log io.Writer) *agent { | ||
| 84 | + ag := &agent{args: a, log: log, sessions: map[sdk.SessionId]*session{}} | ||
| 85 | + ag.out = acpwire.NewWriter(w) | ||
| 86 | + ag.conn = sdk.NewAgentSideConnection(ag, ag.out, r) | ||
| 87 | + ag.conn.SetLogger(slog.New(slog.NewTextHandler(log, nil))) | ||
| 88 | + return ag | ||
| 89 | +} | ||
| 90 | + | ||
| 91 | +func (ag *agent) Initialize(_ context.Context, p sdk.InitializeRequest) (sdk.InitializeResponse, error) { | ||
| 92 | + // Form support exists only when `form` is present: ACP requires each | ||
| 93 | + // mode to be explicit. | ||
| 94 | + if e := p.ClientCapabilities.Elicitation; e != nil && e.Form != nil { | ||
| 95 | + ag.elicitForm = true | ||
| 96 | + } | ||
| 97 | + if p.ClientInfo != nil { | ||
| 98 | + ag.client = p.ClientInfo.Name | ||
| 99 | + } | ||
| 100 | + fmt.Fprintf(ag.log, "[acp-create] initialize: client %q elicitation(form)=%v\n", ag.client, ag.elicitForm) | ||
| 101 | + return sdk.InitializeResponse{ | ||
| 102 | + ProtocolVersion: sdk.ProtocolVersionNumber, | ||
| 103 | + // loadSession and session/resume, so that a client's "reload agent" | ||
| 104 | + // works (it restarts mm, then asks for its thread back). Nothing is | ||
| 105 | + // kept across a restart: the wizard starts over (restart.go). | ||
| 106 | + AgentCapabilities: sdk.AgentCapabilities{ | ||
| 107 | + LoadSession: true, | ||
| 108 | + SessionCapabilities: sdk.SessionCapabilities{Resume: &sdk.SessionResumeCapabilities{}}, | ||
| 109 | + }, | ||
| 110 | + AgentInfo: &sdk.Implementation{Name: "mm-acp-create", Title: sdk.Ptr("mm init (agent.yaml wizard)"), Version: version.Short()}, | ||
| 111 | + AuthMethods: []sdk.AuthMethod{}, | ||
| 112 | + }, nil | ||
| 113 | +} | ||
| 114 | + | ||
| 115 | +func (ag *agent) Authenticate(context.Context, sdk.AuthenticateRequest) (sdk.AuthenticateResponse, error) { | ||
| 116 | + return sdk.AuthenticateResponse{}, nil | ||
| 117 | +} | ||
| 118 | + | ||
| 119 | +// NewSession starts a flow whose files land in the session's cwd — the | ||
| 120 | +// project the client opened. | ||
| 121 | +func (ag *agent) NewSession(ctx context.Context, p sdk.NewSessionRequest) (sdk.NewSessionResponse, error) { | ||
| 122 | + s, err := ag.newSession(p.Cwd) | ||
| 123 | + if err != nil { | ||
| 124 | + return sdk.NewSessionResponse{}, err | ||
| 125 | + } | ||
| 126 | + sid := newID() | ||
| 127 | + ag.mu.Lock() | ||
| 128 | + ag.sessions[sid] = s | ||
| 129 | + ag.mu.Unlock() | ||
| 130 | + fmt.Fprintf(ag.log, "[acp-create] session %s cwd=%s\n", sid, p.Cwd) | ||
| 131 | + // The commands go right after the response: a client drops updates for | ||
| 132 | + // a session id it has not received yet (see acpwire.Writer). | ||
| 133 | + ag.out.AfterResponse(sid, func() { | ||
| 134 | + _ = ag.conn.SessionUpdate(context.WithoutCancel(ctx), sdk.SessionNotification{ | ||
| 135 | + SessionId: sid, | ||
| 136 | + Update: sdk.SessionUpdate{AvailableCommandsUpdate: &sdk.SessionAvailableCommandsUpdate{AvailableCommands: availableCommands()}}, | ||
| 137 | + }) | ||
| 138 | + // Not every client shows a message outside a turn; /start, first | ||
| 139 | + // in the command list, is the way in for those. | ||
| 140 | + ag.say(context.WithoutCancel(ctx), sid, welcome(p.Cwd)) | ||
| 141 | + }) | ||
| 142 | + return sdk.NewSessionResponse{SessionId: sid}, nil | ||
| 143 | +} | ||
| 144 | + | ||
| 145 | +// newSession builds a session on a fresh flow, rooted at cwd. | ||
| 146 | +func (ag *agent) newSession(cwd string) (*session, error) { | ||
| 147 | + flow, err := ag.args.Flow(context.Background()) | ||
| 148 | + if err != nil { | ||
| 149 | + return nil, err | ||
| 150 | + } | ||
| 151 | + flow.SetDir(cwd) | ||
| 152 | + return &session{cwd: cwd, flow: flow}, nil | ||
| 153 | +} | ||
| 154 | + | ||
| 155 | +// Prompt is one message from the user: an answer, a command, or — the | ||
| 156 | +// first time — the start of the wizard. | ||
| 157 | +func (ag *agent) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResponse, error) { | ||
| 158 | + ag.mu.Lock() | ||
| 159 | + s, ok := ag.sessions[p.SessionId] | ||
| 160 | + ag.mu.Unlock() | ||
| 161 | + if !ok { | ||
| 162 | + return sdk.PromptResponse{}, sdk.NewInvalidParams(map[string]any{"sessionId": p.SessionId}) | ||
| 163 | + } | ||
| 164 | + s.mu.Lock() | ||
| 165 | + defer s.mu.Unlock() | ||
| 166 | + turnCtx, cancel := context.WithCancel(ctx) | ||
| 167 | + s.setCancel(cancel) | ||
| 168 | + defer func() { | ||
| 169 | + s.setCancel(nil) | ||
| 170 | + cancel() | ||
| 171 | + }() | ||
| 172 | + t := &turn{ag: ag, s: s, sid: p.SessionId, ctx: turnCtx} | ||
| 173 | + t.handle(textOf(p.Prompt)) | ||
| 174 | + if turnCtx.Err() != nil { | ||
| 175 | + return sdk.PromptResponse{StopReason: sdk.StopReasonCancelled}, nil | ||
| 176 | + } | ||
| 177 | + return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}, nil | ||
| 178 | +} | ||
| 179 | + | ||
| 180 | +// Cancel stops the turn in flight: a pending form falls with it. | ||
| 181 | +func (ag *agent) Cancel(_ context.Context, p sdk.CancelNotification) error { | ||
| 182 | + ag.mu.Lock() | ||
| 183 | + s := ag.sessions[p.SessionId] | ||
| 184 | + ag.mu.Unlock() | ||
| 185 | + if s != nil { | ||
| 186 | + s.cancelTurn() | ||
| 187 | + } | ||
| 188 | + return nil | ||
| 189 | +} | ||
| 190 | + | ||
| 191 | +func (ag *agent) CloseSession(_ context.Context, p sdk.CloseSessionRequest) (sdk.CloseSessionResponse, error) { | ||
| 192 | + ag.mu.Lock() | ||
| 193 | + delete(ag.sessions, p.SessionId) | ||
| 194 | + ag.mu.Unlock() | ||
| 195 | + return sdk.CloseSessionResponse{}, nil | ||
| 196 | +} | ||
| 197 | + | ||
| 198 | +func (ag *agent) ListSessions(context.Context, sdk.ListSessionsRequest) (sdk.ListSessionsResponse, error) { | ||
| 199 | + return sdk.ListSessionsResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionList) | ||
| 200 | +} | ||
| 201 | + | ||
| 202 | +func (ag *agent) SetSessionMode(context.Context, sdk.SetSessionModeRequest) (sdk.SetSessionModeResponse, error) { | ||
| 203 | + return sdk.SetSessionModeResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionSetMode) | ||
| 204 | +} | ||
| 205 | + | ||
| 206 | +func (ag *agent) SetSessionConfigOption(context.Context, sdk.SetSessionConfigOptionRequest) (sdk.SetSessionConfigOptionResponse, error) { | ||
| 207 | + return sdk.SetSessionConfigOptionResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionSetConfigOption) | ||
| 208 | +} | ||
| 209 | + | ||
| 210 | +func (ag *agent) Logout(context.Context, sdk.LogoutRequest) (sdk.LogoutResponse, error) { | ||
| 211 | + return sdk.LogoutResponse{}, nil | ||
| 212 | +} | ||
| 213 | + | ||
| 214 | +// say sends one agent message to the session. | ||
| 215 | +func (ag *agent) say(ctx context.Context, sid sdk.SessionId, markdown string) { | ||
| 216 | + _ = ag.conn.SessionUpdate(ctx, sdk.SessionNotification{SessionId: sid, Update: sdk.UpdateAgentMessageText(markdown)}) | ||
| 217 | +} | ||
| 218 | + | ||
| 219 | +// textOf keeps the text of a prompt; attachments mean nothing here. | ||
| 220 | +func textOf(blocks []sdk.ContentBlock) string { | ||
| 221 | + var parts []string | ||
| 222 | + for _, b := range blocks { | ||
| 223 | + if b.Text != nil { | ||
| 224 | + parts = append(parts, b.Text.Text) | ||
| 225 | + } | ||
| 226 | + } | ||
| 227 | + return strings.Join(parts, "\n") | ||
| 228 | +} | ||
| 229 | + | ||
| 230 | +// newID is a session id: random, unique enough for one connection. | ||
| 231 | +func newID() sdk.SessionId { | ||
| 232 | + b := make([]byte, 8) | ||
| 233 | + _, _ = rand.Read(b) | ||
| 234 | + return sdk.SessionId("create-" + hex.EncodeToString(b)) | ||
| 235 | +} | ||
| 236 | + | ||
| 237 | +// Is reports whether mm's first argument asks for this mode: `mm | ||
| 238 | +// acp-create`, `mm --acp-create` or `mm -acp-create`. | ||
| 239 | +func Is(arg string) bool { | ||
| 240 | + return arg == "acp-create" || arg == "--acp-create" || arg == "-acp-create" | ||
| 241 | +} | ||
added
internal/acpcreate/acpcreate_test.go +581 -0 | new file mode 100644 | ||
| @@ -0,0 +1,581 @@ | ||
| 1 | +package acpcreate | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "bytes" | |
| 5 | + "context" | |
| 6 | + "encoding/json" | |
| 7 | + "io" | |
| 8 | + "log/slog" | |
| 9 | + "net/http" | |
| 10 | + "net/http/httptest" | |
| 11 | + "os" | |
| 12 | + "path/filepath" | |
| 13 | + "sort" | |
| 14 | + "strings" | |
| 15 | + "sync" | |
| 16 | + "testing" | |
| 17 | + "time" | |
| 18 | + | |
| 19 | + "mm/internal/agentinit" | |
| 20 | + "mm/internal/config" | |
| 21 | + "mm/internal/sbxinit" | |
| 22 | + | |
| 23 | + sdk "github.com/coder/acp-go-sdk" | |
| 24 | +) | |
| 25 | + | |
| 26 | +// llmmanStub is an llmman server with one model, as mm init's tests use. | |
| 27 | +func llmmanStub(t *testing.T) string { | |
| 28 | + t.Helper() | |
| 29 | + routes := map[string]any{ | |
| 30 | + "GET /api/tags": map[string]any{"models": []any{map[string]any{"name": "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M"}}}, | |
| 31 | + "POST /api/show": map[string]any{"model_info": map[string]any{"general.architecture": "qwen35", "qwen35.context_length": 32768}, "template": "{{ enable_thinking | default(false) }}"}, | |
| 32 | + } | |
| 33 | + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 34 | + body, ok := routes[r.Method+" "+r.URL.Path] | |
| 35 | + if !ok { | |
| 36 | + http.NotFound(w, r) | |
| 37 | + return | |
| 38 | + } | |
| 39 | + _ = json.NewEncoder(w).Encode(body) | |
| 40 | + })) | |
| 41 | + t.Cleanup(s.Close) | |
| 42 | + return s.URL + "/v1" | |
| 43 | +} | |
| 44 | + | |
| 45 | +func keepConfig(t *testing.T) { | |
| 46 | + saved := config.Cfg | |
| 47 | + t.Cleanup(func() { config.Cfg = saved }) | |
| 48 | +} | |
| 49 | + | |
| 50 | +// fakeClient plays the ACP client: it keeps the agent's messages and, when | |
| 51 | +// forms is set, answers each form with answer(key, property). | |
| 52 | +type fakeClient struct { | |
| 53 | + mu sync.Mutex | |
| 54 | + messages []string | |
| 55 | + commands []sdk.AvailableCommand | |
| 56 | + forms [][]string // the property names of each form, in order | |
| 57 | + answer func(key string, property map[string]any) any | |
| 58 | + decline bool | |
| 59 | +} | |
| 60 | + | |
| 61 | +func (c *fakeClient) SessionUpdate(_ context.Context, n sdk.SessionNotification) error { | |
| 62 | + c.mu.Lock() | |
| 63 | + defer c.mu.Unlock() | |
| 64 | + if m := n.Update.AgentMessageChunk; m != nil && m.Content.Text != nil { | |
| 65 | + c.messages = append(c.messages, m.Content.Text.Text) | |
| 66 | + } | |
| 67 | + if a := n.Update.AvailableCommandsUpdate; a != nil { | |
| 68 | + c.commands = a.AvailableCommands | |
| 69 | + } | |
| 70 | + return nil | |
| 71 | +} | |
| 72 | + | |
| 73 | +func (c *fakeClient) UnstableCreateElicitation(_ context.Context, p sdk.UnstableCreateElicitationRequest) (sdk.UnstableCreateElicitationResponse, error) { | |
| 74 | + c.mu.Lock() | |
| 75 | + defer c.mu.Unlock() | |
| 76 | + props := p.Form.RequestedSchema.Properties | |
| 77 | + var names []string | |
| 78 | + for name := range props { | |
| 79 | + names = append(names, name) | |
| 80 | + } | |
| 81 | + sort.Strings(names) | |
| 82 | + c.forms = append(c.forms, names) | |
| 83 | + if c.decline { | |
| 84 | + return sdk.UnstableCreateElicitationResponse{Decline: &sdk.UnstableCreateElicitationDecline{Action: "decline"}}, nil | |
| 85 | + } | |
| 86 | + content := map[string]any{} | |
| 87 | + for _, name := range names { | |
| 88 | + prop, _ := props[name].(map[string]any) | |
| 89 | + content[name] = c.answer(name, prop) | |
| 90 | + } | |
| 91 | + return sdk.UnstableCreateElicitationResponse{Accept: &sdk.UnstableCreateElicitationAccept{Action: "accept", Content: content}}, nil | |
| 92 | +} | |
| 93 | + | |
| 94 | +func (c *fakeClient) RequestPermission(context.Context, sdk.RequestPermissionRequest) (sdk.RequestPermissionResponse, error) { | |
| 95 | + return sdk.RequestPermissionResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodSessionRequestPermission) | |
| 96 | +} | |
| 97 | +func (c *fakeClient) ReadTextFile(context.Context, sdk.ReadTextFileRequest) (sdk.ReadTextFileResponse, error) { | |
| 98 | + return sdk.ReadTextFileResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodFsReadTextFile) | |
| 99 | +} | |
| 100 | +func (c *fakeClient) WriteTextFile(context.Context, sdk.WriteTextFileRequest) (sdk.WriteTextFileResponse, error) { | |
| 101 | + return sdk.WriteTextFileResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodFsWriteTextFile) | |
| 102 | +} | |
| 103 | +func (c *fakeClient) CreateTerminal(context.Context, sdk.CreateTerminalRequest) (sdk.CreateTerminalResponse, error) { | |
| 104 | + return sdk.CreateTerminalResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalCreate) | |
| 105 | +} | |
| 106 | +func (c *fakeClient) KillTerminal(context.Context, sdk.KillTerminalRequest) (sdk.KillTerminalResponse, error) { | |
| 107 | + return sdk.KillTerminalResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalKill) | |
| 108 | +} | |
| 109 | +func (c *fakeClient) TerminalOutput(context.Context, sdk.TerminalOutputRequest) (sdk.TerminalOutputResponse, error) { | |
| 110 | + return sdk.TerminalOutputResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalOutput) | |
| 111 | +} | |
| 112 | +func (c *fakeClient) ReleaseTerminal(context.Context, sdk.ReleaseTerminalRequest) (sdk.ReleaseTerminalResponse, error) { | |
| 113 | + return sdk.ReleaseTerminalResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalRelease) | |
| 114 | +} | |
| 115 | +func (c *fakeClient) WaitForTerminalExit(context.Context, sdk.WaitForTerminalExitRequest) (sdk.WaitForTerminalExitResponse, error) { | |
| 116 | + return sdk.WaitForTerminalExitResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalWaitForExit) | |
| 117 | +} | |
| 118 | + | |
| 119 | +// last is the agent's last message. | |
| 120 | +func (c *fakeClient) last() string { | |
| 121 | + c.mu.Lock() | |
| 122 | + defer c.mu.Unlock() | |
| 123 | + if len(c.messages) == 0 { | |
| 124 | + return "" | |
| 125 | + } | |
| 126 | + return c.messages[len(c.messages)-1] | |
| 127 | +} | |
| 128 | + | |
| 129 | +func (c *fakeClient) all() string { | |
| 130 | + c.mu.Lock() | |
| 131 | + defer c.mu.Unlock() | |
| 132 | + return strings.Join(c.messages, "\n---\n") | |
| 133 | +} | |
| 134 | + | |
| 135 | +// harness is an agent and a client connected by pipes, one session open | |
| 136 | +// in cwd. | |
| 137 | +type harness struct { | |
| 138 | + t *testing.T | |
| 139 | + client *fakeClient | |
| 140 | + conn *sdk.ClientSideConnection | |
| 141 | + sid sdk.SessionId | |
| 142 | + cwd string | |
| 143 | +} | |
| 144 | + | |
| 145 | +func start(t *testing.T, args []string, client *fakeClient, name string, forms bool) *harness { | |
| 146 | + t.Helper() | |
| 147 | + keepConfig(t) | |
| 148 | + a, code, ok := agentinit.ParseArgs("mm acp-create", args, io.Discard, false) | |
| 149 | + if !ok { | |
| 150 | + t.Fatalf("flags refused (%d): %v", code, args) | |
| 151 | + } | |
| 152 | + c2aR, c2aW := io.Pipe() | |
| 153 | + a2cR, a2cW := io.Pipe() | |
| 154 | + t.Cleanup(func() { _ = c2aW.Close(); _ = a2cW.Close() }) | |
| 155 | + newAgent(a, a2cW, c2aR, io.Discard) | |
| 156 | + conn := sdk.NewClientSideConnection(client, c2aW, a2cR) | |
| 157 | + conn.SetLogger(slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 158 | + ctx := context.Background() | |
| 159 | + caps := sdk.ClientCapabilities{} | |
| 160 | + if forms { | |
| 161 | + caps.Elicitation = &sdk.ElicitationCapabilities{Form: &sdk.ElicitationFormCapabilities{}} | |
| 162 | + } | |
| 163 | + if _, err := conn.Initialize(ctx, sdk.InitializeRequest{ProtocolVersion: sdk.ProtocolVersionNumber, ClientCapabilities: caps, ClientInfo: &sdk.Implementation{Name: name, Version: "1"}}); err != nil { | |
| 164 | + t.Fatal(err) | |
| 165 | + } | |
| 166 | + cwd := t.TempDir() | |
| 167 | + resp, err := conn.NewSession(ctx, sdk.NewSessionRequest{Cwd: cwd, McpServers: []sdk.McpServer{}}) | |
| 168 | + if err != nil { | |
| 169 | + t.Fatal(err) | |
| 170 | + } | |
| 171 | + // The welcome comes after the session/new response, in a goroutine: | |
| 172 | + // wait for it, so that it is never taken for the answer to a prompt. | |
| 173 | + for deadline := time.Now().Add(5 * time.Second); !strings.Contains(client.all(), "Send `/start`"); time.Sleep(5 * time.Millisecond) { | |
| 174 | + if time.Now().After(deadline) { | |
| 175 | + t.Fatal("no welcome message after session/new") | |
| 176 | + } | |
| 177 | + } | |
| 178 | + return &harness{t: t, client: client, conn: conn, sid: resp.SessionId, cwd: cwd} | |
| 179 | +} | |
| 180 | + | |
| 181 | +// say sends one prompt and returns the agent's last message. | |
| 182 | +func (h *harness) say(text string) string { | |
| 183 | + h.t.Helper() | |
| 184 | + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| 185 | + defer cancel() | |
| 186 | + if _, err := h.conn.Prompt(ctx, sdk.PromptRequest{SessionId: h.sid, Prompt: []sdk.ContentBlock{sdk.TextBlock(text)}}); err != nil { | |
| 187 | + h.t.Fatalf("prompt %q: %v", text, err) | |
| 188 | + } | |
| 189 | + return h.client.last() | |
| 190 | +} | |
| 191 | + | |
| 192 | +// golden is the file mm init's line mode writes with the same answers. | |
| 193 | +func golden(t *testing.T, base string) string { | |
| 194 | + t.Helper() | |
| 195 | + keepConfig(t) | |
| 196 | + out := filepath.Join(t.TempDir(), "agent.yaml") | |
| 197 | + answers := strings.Join([]string{"", "1", "", "1", "2", "n", "", "", "bob", out}, "\n") + "\n" | |
| 198 | + var stdout bytes.Buffer | |
| 199 | + if code := agentinit.Main([]string{"--provider", "llmman", "--base-url", base, "--line"}, strings.NewReader(answers), &stdout); code != 0 { | |
| 200 | + t.Fatalf("mm init: exit %d\n%s", code, stdout.String()) | |
| 201 | + } | |
| 202 | + data, err := os.ReadFile(out) | |
| 203 | + if err != nil { | |
| 204 | + t.Fatal(err) | |
| 205 | + } | |
| 206 | + return string(data) | |
| 207 | +} | |
| 208 | + | |
| 209 | +func read(t *testing.T, path string) string { | |
| 210 | + t.Helper() | |
| 211 | + data, err := os.ReadFile(path) | |
| 212 | + if err != nil { | |
| 213 | + t.Fatalf("%s: %v", path, err) | |
| 214 | + } | |
| 215 | + return string(data) | |
| 216 | +} | |
| 217 | + | |
| 218 | +// A client without forms gets the conversation: each question a message, | |
| 219 | +// each answer a prompt — and the file is the one mm init writes with the | |
| 220 | +// same answers, in the session's cwd. The client is called zed: the mode | |
| 221 | +// still comes from the capabilities, and only the closing snippet is Zed's. | |
| 222 | +func TestConversationWritesWhatMmInitWrites(t *testing.T) { | |
| 223 | + base := llmmanStub(t) | |
| 224 | + want := golden(t, base) | |
| 225 | + client := &fakeClient{} | |
| 226 | + h := start(t, []string{"--provider", "llmman", "--base-url", base}, client, "zed", false) | |
| 227 | + | |
| 228 | + first := h.say("hello") | |
| 229 | + if !strings.Contains(first, "Keep everything in a .mm folder?") || !strings.Contains(client.all(), "mm init") { | |
| 230 | + t.Fatalf("the first prompt did not start the wizard:\n%s", client.all()) | |
| 231 | + } | |
| 232 | + for _, answer := range []string{".", "1", ".", "1", "2", "n", ".", ".", "bob"} { | |
| 233 | + h.say(answer) | |
| 234 | + } | |
| 235 | + end := h.say("agent.yaml") | |
| 236 | + if len(client.forms) != 0 { | |
| 237 | + t.Errorf("a client without the form capability was sent %d form(s)", len(client.forms)) | |
| 238 | + } | |
| 239 | + path := filepath.Join(h.cwd, "agent.yaml") | |
| 240 | + if got := read(t, path); got != want { | |
| 241 | + t.Errorf("file differs from mm init's:\n--- acp-create\n%s\n--- mm init\n%s", got, want) | |
| 242 | + } | |
| 243 | + for _, s := range []string{"✅ wrote agent.yaml", "✅ mm loads it", "mm acp " + path, "agent_servers"} { | |
| 244 | + if !strings.Contains(end, s) { | |
| 245 | + t.Errorf("closing message lacks %q:\n%s", s, end) | |
| 246 | + } | |
| 247 | + } | |
| 248 | + if again := h.say("more"); !strings.Contains(again, "is written") { | |
| 249 | + t.Errorf("after writing: %q", again) | |
| 250 | + } | |
| 251 | +} | |
| 252 | + | |
| 253 | +// A client with forms gets a few forms; a form never holds a question | |
| 254 | +// with the one its options come from. The file is again mm init's. | |
| 255 | +func TestFormsWriteWhatMmInitWrites(t *testing.T) { | |
| 256 | + base := llmmanStub(t) | |
| 257 | + want := golden(t, base) | |
| 258 | + given := map[string]any{"project": false, "usage": "developer", "machine": "medium", "yolo": false, "sessions": false, "skills": false, "name": "bob", "file": "agent.yaml"} | |
| 259 | + client := &fakeClient{answer: func(key string, p map[string]any) any { | |
| 260 | + if v, ok := given[key]; ok { | |
| 261 | + return v | |
| 262 | + } | |
| 263 | + if enum, ok := p["enum"].([]any); ok && len(enum) > 0 { | |
| 264 | + if d, ok := p["default"]; ok { | |
| 265 | + return d | |
| 266 | + } | |
| 267 | + return enum[0] | |
| 268 | + } | |
| 269 | + return p["default"] | |
| 270 | + }} | |
| 271 | + h := start(t, []string{"--provider", "llmman", "--base-url", base}, client, "some-editor", true) | |
| 272 | + end := h.say("go") | |
| 273 | + | |
| 274 | + wantForms := [][]string{{"model", "project"}, {"family", "machine", "usage"}, {"file", "name", "sessions", "skills", "yolo"}} | |
| 275 | + if len(client.forms) != len(wantForms) { | |
| 276 | + t.Fatalf("forms = %v, want %v", client.forms, wantForms) | |
| 277 | + } | |
| 278 | + for i := range wantForms { | |
| 279 | + if strings.Join(client.forms[i], ",") != strings.Join(wantForms[i], ",") { | |
| 280 | + t.Errorf("form %d = %v, want %v", i+1, client.forms[i], wantForms[i]) | |
| 281 | + } | |
| 282 | + } | |
| 283 | + if got := read(t, filepath.Join(h.cwd, "agent.yaml")); got != want { | |
| 284 | + t.Errorf("file differs from mm init's:\n--- acp-create\n%s\n--- mm init\n%s", got, want) | |
| 285 | + } | |
| 286 | + if !strings.Contains(end, "✅ mm loads it") || strings.Contains(end, "agent_servers") { | |
| 287 | + t.Errorf("closing message (no snippet for an unknown client):\n%s", end) | |
| 288 | + } | |
| 289 | +} | |
| 290 | + | |
| 291 | +// Declining a form writes nothing; the next message asks again. | |
| 292 | +func TestDeclinedFormWritesNothing(t *testing.T) { | |
| 293 | + client := &fakeClient{decline: true} | |
| 294 | + h := start(t, []string{"--provider", "llmman", "--base-url", llmmanStub(t)}, client, "x", true) | |
| 295 | + if got := h.say("go"); !strings.Contains(got, "nothing written") { | |
| 296 | + t.Errorf("after a decline: %q", got) | |
| 297 | + } | |
| 298 | + if _, err := os.Stat(filepath.Join(h.cwd, "agent.yaml")); err == nil { | |
| 299 | + t.Error("a declined form wrote the file") | |
| 300 | + } | |
| 301 | + h.say("again") | |
| 302 | + if len(client.forms) != 2 { | |
| 303 | + t.Errorf("the next message did not ask again: %d form(s)", len(client.forms)) | |
| 304 | + } | |
| 305 | +} | |
| 306 | + | |
| 307 | +// /preview, /back, a refused answer, and /write with the Project layout. | |
| 308 | +func TestCommands(t *testing.T) { | |
| 309 | + client := &fakeClient{} | |
| 310 | + h := start(t, []string{"--provider", "llmman", "--base-url", llmmanStub(t)}, client, "x", false) | |
| 311 | + if got := h.say("/start"); !strings.Contains(got, ".mm folder?") { | |
| 312 | + t.Errorf("/start did not ask the first question: %q", got) | |
| 313 | + } | |
| 314 | + if got := h.say("/start"); !strings.Contains(got, ".mm folder?") { | |
| 315 | + t.Errorf("/start again did not ask the current question again: %q", got) | |
| 316 | + } | |
| 317 | + if strings.Count(client.all(), "let's create an agent") != 1 { | |
| 318 | + t.Errorf("the introduction was not sent exactly once:\n%s", client.all()) | |
| 319 | + } | |
| 320 | + // Announced right after the session/new response, in a goroutine. | |
| 321 | + announced := 0 | |
| 322 | + for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) { | |
| 323 | + client.mu.Lock() | |
| 324 | + announced = len(client.commands) | |
| 325 | + client.mu.Unlock() | |
| 326 | + if announced > 0 { | |
| 327 | + break | |
| 328 | + } | |
| 329 | + } | |
| 330 | + if announced != len(commands) { | |
| 331 | + t.Errorf("announced %d command(s), want %d", announced, len(commands)) | |
| 332 | + } | |
| 333 | + if got := h.say("maybe"); !strings.Contains(got, "answer yes or no") || !strings.Contains(got, ".mm folder?") { | |
| 334 | + t.Errorf("a non-answer to a yes/no question: %q", got) | |
| 335 | + } | |
| 336 | + if got := h.say("yes"); !strings.Contains(got, "Model?") { | |
| 337 | + t.Errorf("after the layout: %q", got) | |
| 338 | + } | |
| 339 | + if got := h.say("/back"); !strings.Contains(got, ".mm folder?") { | |
| 340 | + t.Errorf("/back did not ask the layout again: %q", got) | |
| 341 | + } | |
| 342 | + h.say("1") // yes, by number | |
| 343 | + if got := h.say("/preview"); !strings.Contains(got, "```yaml") || !strings.Contains(got, "provider: llmman") { | |
| 344 | + t.Errorf("/preview: %q", got) | |
| 345 | + } | |
| 346 | + end := h.say("/write") | |
| 347 | + path := filepath.Join(h.cwd, ".mm", "agent.yaml") | |
| 348 | + if !strings.Contains(read(t, path), "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M") { | |
| 349 | + t.Errorf("/write did not write %s", path) | |
| 350 | + } | |
| 351 | + if !strings.Contains(end, "mm acp "+path) { | |
| 352 | + t.Errorf("closing message: %q", end) | |
| 353 | + } | |
| 354 | + if got := h.say("/restart"); !strings.Contains(got, ".mm folder?") { | |
| 355 | + t.Errorf("/restart: %q", got) | |
| 356 | + } | |
| 357 | +} | |
| 358 | + | |
| 359 | +// /sbxinit writes sbxenv.yaml in the session's cwd, named after the agent | |
| 360 | +// just written; a second one keeps it unless --overwrite. | |
| 361 | +func TestSbxinit(t *testing.T) { | |
| 362 | + restore := sbxinitIn | |
| 363 | + sbxinitIn = func(dir string, args []string, out io.Writer, overwrite bool) int { | |
| 364 | + return sbxinit.InWith(dir, args, out, overwrite, func() (string, error) { return "9.9.9", nil }) | |
| 365 | + } | |
| 366 | + t.Cleanup(func() { sbxinitIn = restore }) | |
| 367 | + client := &fakeClient{} | |
| 368 | + h := start(t, []string{"--provider", "llmman", "--base-url", llmmanStub(t), "--name", "lucy"}, client, "x", false) | |
| 369 | + h.say("/start") | |
| 370 | + if end := h.say("/write"); !strings.Contains(end, "/sbxinit") { | |
| 371 | + t.Errorf("the closing message does not offer /sbxinit:\n%s", end) | |
| 372 | + } | |
| 373 | + got := h.say("/sbxinit") | |
| 374 | + path := filepath.Join(h.cwd, "sbxenv.yaml") | |
| 375 | + text := read(t, path) | |
| 376 | + for _, want := range []string{"name: lucy-sbx", "mini-me-kit:9.9.9", "workspace: " + h.cwd} { | |
| 377 | + if !strings.Contains(text, want) { | |
| 378 | + t.Errorf("sbxenv.yaml lacks %q:\n%s", want, text) | |
| 379 | + } | |
| 380 | + } | |
| 381 | + if !strings.Contains(got, "✅ wrote") || !strings.Contains(got, "sbx env create") { | |
| 382 | + t.Errorf("/sbxinit said:\n%s", got) | |
| 383 | + } | |
| 384 | + if got := h.say("/sbxinit --name max"); !strings.Contains(got, "/sbxinit --overwrite") || strings.Contains(read(t, path), "max-sbx") { | |
| 385 | + t.Errorf("an existing sbxenv.yaml was not kept: %q", got) | |
| 386 | + } | |
| 387 | + h.say("/sbxinit --name max --overwrite") | |
| 388 | + if !strings.Contains(read(t, path), "name: max-sbx") { | |
| 389 | + t.Errorf("--overwrite did not replace sbxenv.yaml:\n%s", read(t, path)) | |
| 390 | + } | |
| 391 | +} | |
| 392 | + | |
| 393 | +// /dockerinit writes Dockerfile and compose.yaml in the session's cwd; a | |
| 394 | +// .mm agent gets the read-only .mm mount (ticket 212). | |
| 395 | +func TestDockerinit(t *testing.T) { | |
| 396 | + client := &fakeClient{} | |
| 397 | + h := start(t, []string{"--provider", "llmman", "--base-url", llmmanStub(t), "--name", "lucy", "--project", "yes", "--sessions", "yes"}, client, "x", false) | |
| 398 | + h.say("/start") | |
| 399 | + if end := h.say("/write"); !strings.Contains(end, "/dockerinit") { | |
| 400 | + t.Errorf("the closing message does not offer /dockerinit:\n%s", end) | |
| 401 | + } | |
| 402 | + got := h.say("/dockerinit") | |
| 403 | + compose := read(t, filepath.Join(h.cwd, "compose.yaml")) | |
| 404 | + for _, want := range []string{"container_name: lucy-box", "./.mm:${PWD:-/workspace}/.mm:ro", "./.mm/sessions:"} { | |
| 405 | + if !strings.Contains(compose, want) { | |
| 406 | + t.Errorf("compose.yaml lacks %q:\n%s", want, compose) | |
| 407 | + } | |
| 408 | + } | |
| 409 | + if !strings.Contains(got, "exec lucy mm tui\n") || !strings.Contains(got, "`docker` commands from") { | |
| 410 | + t.Errorf("/dockerinit said:\n%s", got) | |
| 411 | + } | |
| 412 | + if got := h.say("/dockerinit"); !strings.Contains(got, "/dockerinit --overwrite") { | |
| 413 | + t.Errorf("existing files were not kept: %q", got) | |
| 414 | + } | |
| 415 | + if got := h.say("/dockerinit --overwrite --name max"); !strings.Contains(got, "✅ wrote compose.yaml") && !strings.Contains(got, "compose.yaml") { | |
| 416 | + t.Errorf("--overwrite: %q", got) | |
| 417 | + } | |
| 418 | + if !strings.Contains(read(t, filepath.Join(h.cwd, "compose.yaml")), "max-box") { | |
| 419 | + t.Error("--overwrite did not replace compose.yaml") | |
| 420 | + } | |
| 421 | +} | |
| 422 | + | |
| 423 | +// A client's "reload agent" (ticket 210): loadSession and session/resume | |
| 424 | +// are offered; both start the wizard over under the id the client asked | |
| 425 | +// for, and say so. | |
| 426 | +func TestReloadStartsOver(t *testing.T) { | |
| 427 | + base := llmmanStub(t) | |
| 428 | + client := &fakeClient{} | |
| 429 | + h := start(t, []string{"--provider", "llmman", "--base-url", base}, client, "zed", false) | |
| 430 | + h.say("/start") | |
| 431 | + h.say("yes") | |
| 432 | + | |
| 433 | + for _, method := range []string{"load", "resume"} { | |
| 434 | + // A new process: a new agent and connection, the same session id. | |
| 435 | + again := &fakeClient{} | |
| 436 | + h2 := start(t, []string{"--provider", "llmman", "--base-url", base}, again, "zed", false) | |
| 437 | + ctx := context.Background() | |
| 438 | + var err error | |
| 439 | + if method == "load" { | |
| 440 | + _, err = h2.conn.LoadSession(ctx, sdk.LoadSessionRequest{SessionId: h.sid, Cwd: h.cwd, McpServers: []sdk.McpServer{}}) | |
| 441 | + } else { | |
| 442 | + _, err = h2.conn.ResumeSession(ctx, sdk.ResumeSessionRequest{SessionId: h.sid, Cwd: h.cwd}) | |
| 443 | + } | |
| 444 | + if err != nil { | |
| 445 | + t.Fatalf("session/%s: %v", method, err) | |
| 446 | + } | |
| 447 | + if !strings.Contains(again.all(), "restarted") { | |
| 448 | + t.Errorf("session/%s: no restart message:\n%s", method, again.all()) | |
| 449 | + } | |
| 450 | + h2.sid, h2.cwd = h.sid, h.cwd | |
| 451 | + if got := h2.say("/start"); !strings.Contains(got, ".mm folder?") { | |
| 452 | + t.Errorf("session/%s: the wizard did not start over: %q", method, got) | |
| 453 | + } | |
| 454 | + } | |
| 455 | +} | |
| 456 | + | |
| 457 | +// /zedconfig shows one entry per way the project can start its agent: mm | |
| 458 | +// here always, the container with compose.yaml, the sandbox with | |
| 459 | +// sbxenv.yaml — and nothing but a pointer to /start without an agent. | |
| 460 | +func TestZedconfig(t *testing.T) { | |
| 461 | + restore := sbxinitIn | |
| 462 | + sbxinitIn = func(dir string, args []string, out io.Writer, overwrite bool) int { | |
| 463 | + return sbxinit.InWith(dir, args, out, overwrite, func() (string, error) { return "9.9.9", nil }) | |
| 464 | + } | |
| 465 | + t.Cleanup(func() { sbxinitIn = restore }) | |
| 466 | + client := &fakeClient{} | |
| 467 | + h := start(t, []string{"--provider", "llmman", "--base-url", llmmanStub(t), "--name", "lucy", "--project", "yes"}, client, "x", false) | |
| 468 | + if got := h.say("/zedconfig"); !strings.Contains(got, "`/start` creates one") { | |
| 469 | + t.Errorf("without an agent: %q", got) | |
| 470 | + } | |
| 471 | + h.say("/start") | |
| 472 | + h.say("/write") | |
| 473 | + agent := filepath.Join(h.cwd, ".mm", "agent.yaml") | |
| 474 | + project := "mm-" + filepath.Base(h.cwd) | |
| 475 | + | |
| 476 | + settings := func(text string) map[string]server { | |
| 477 | + t.Helper() | |
| 478 | + start := strings.Index(text, "```json\n") | |
| 479 | + end := strings.LastIndex(text, "```") | |
| 480 | + if start < 0 || end <= start { | |
| 481 | + t.Fatalf("no JSON block:\n%s", text) | |
| 482 | + } | |
| 483 | + var parsed struct { | |
| 484 | + Servers map[string]server `json:"agent_servers"` | |
| 485 | + } | |
| 486 | + if err := json.Unmarshal([]byte(text[start+len("```json\n"):end]), &parsed); err != nil { | |
| 487 | + t.Fatalf("not JSON: %v\n%s", err, text) | |
| 488 | + } | |
| 489 | + return parsed.Servers | |
| 490 | + } | |
| 491 | + | |
| 492 | + got := h.say("/zedconfig") | |
| 493 | + servers := settings(got) | |
| 494 | + if len(servers) != 1 || strings.Join(servers[project].Args, " ") != "acp "+agent || !strings.Contains(got, "/dockerinit") { | |
| 495 | + t.Errorf("agent only: %v\n%s", servers, got) | |
| 496 | + } | |
| 497 | + | |
| 498 | + h.say("/dockerinit") | |
| 499 | + h.say("/sbxinit") | |
| 500 | + got = h.say("/zedconfig") | |
| 501 | + servers = settings(got) | |
| 502 | + if c := servers[project+" (container)"]; c.Command != "docker" || strings.Join(c.Args, " ") != "exec -i lucy-box mm -acp -config "+agent { | |
| 503 | + t.Errorf("container entry: %+v\n%s", c, got) | |
| 504 | + } | |
| 505 | + if s := servers[project+" (sandbox)"]; s.Command != "sbx" || strings.Join(s.Args, " ") != "exec -i lucy-sbx mm -acp -config "+agent { | |
| 506 | + t.Errorf("sandbox entry: %+v\n%s", s, got) | |
| 507 | + } | |
| 508 | + for _, want := range []string{"docker compose up -d --wait", "sbx env create --force"} { | |
| 509 | + if !strings.Contains(got, want) { | |
| 510 | + t.Errorf("no %q in:\n%s", want, got) | |
| 511 | + } | |
| 512 | + } | |
| 513 | +} | |
| 514 | + | |
| 515 | +func TestParseAnswer(t *testing.T) { | |
| 516 | + choice := agentinit.Step{Kind: "choice", Default: "b", Options: []agentinit.Option{{Value: "a"}, {Value: "b"}}} | |
| 517 | + confirm := agentinit.Step{Kind: "confirm", Default: "no", Options: []agentinit.Option{{Value: "yes"}, {Value: "no"}}} | |
| 518 | + text := agentinit.Step{Kind: "text"} | |
| 519 | + cases := []struct { | |
| 520 | + st agentinit.Step | |
| 521 | + in, out string | |
| 522 | + bad bool | |
| 523 | + }{ | |
| 524 | + {choice, "2", "b", false}, | |
| 525 | + {choice, ".", "b", false}, | |
| 526 | + {choice, "a", "a", false}, | |
| 527 | + {choice, "9", "", true}, | |
| 528 | + {confirm, "1", "yes", false}, | |
| 529 | + {confirm, "n", "no", false}, | |
| 530 | + {confirm, "default", "no", false}, | |
| 531 | + {confirm, "perhaps", "", true}, | |
| 532 | + {text, "my-model", "my-model", false}, | |
| 533 | + {text, ".", "", true}, | |
| 534 | + } | |
| 535 | + for _, c := range cases { | |
| 536 | + got, err := parseAnswer(c.st, c.in) | |
| 537 | + if (err != nil) != c.bad || got != c.out { | |
| 538 | + t.Errorf("parseAnswer(%s, %q) = %q, %v", c.st.Kind, c.in, got, err) | |
| 539 | + } | |
| 540 | + } | |
| 541 | +} | |
| 542 | + | |
| 543 | +func TestBatchStopsAtADependency(t *testing.T) { | |
| 544 | + steps := func(keys ...string) []agentinit.Step { | |
| 545 | + var out []agentinit.Step | |
| 546 | + for _, k := range keys { | |
| 547 | + out = append(out, agentinit.Step{Key: k}) | |
| 548 | + } | |
| 549 | + return out | |
| 550 | + } | |
| 551 | + keys := func(st []agentinit.Step) string { | |
| 552 | + var out []string | |
| 553 | + for _, s := range st { | |
| 554 | + out = append(out, s.Key) | |
| 555 | + } | |
| 556 | + return strings.Join(out, ",") | |
| 557 | + } | |
| 558 | + for in, want := range map[string]string{ | |
| 559 | + "project,provider,model": "project,provider", | |
| 560 | + "endpoint,keyenv,model": "endpoint,keyenv", | |
| 561 | + "usage,machine,yolo": "usage,machine", | |
| 562 | + "yolo,sessions,skills,name,file": "yolo,sessions,skills,name,file", | |
| 563 | + "file,overwrite": "file", | |
| 564 | + "project,skills": "project", | |
| 565 | + } { | |
| 566 | + if got := keys(batch(steps(strings.Split(in, ",")...))); got != want { | |
| 567 | + t.Errorf("batch(%s) = %s, want %s", in, got, want) | |
| 568 | + } | |
| 569 | + } | |
| 570 | +} | |
| 571 | + | |
| 572 | +func TestIs(t *testing.T) { | |
| 573 | + for _, a := range []string{"acp-create", "--acp-create", "-acp-create"} { | |
| 574 | + if !Is(a) { | |
| 575 | + t.Errorf("Is(%q) = false", a) | |
| 576 | + } | |
| 577 | + } | |
| 578 | + if Is("acp") || Is("init") { | |
| 579 | + t.Error("Is accepts another mode") | |
| 580 | + } | |
| 581 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,581 @@ | |||
| 1 | +package acpcreate | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "bytes" | ||
| 5 | + "context" | ||
| 6 | + "encoding/json" | ||
| 7 | + "io" | ||
| 8 | + "log/slog" | ||
| 9 | + "net/http" | ||
| 10 | + "net/http/httptest" | ||
| 11 | + "os" | ||
| 12 | + "path/filepath" | ||
| 13 | + "sort" | ||
| 14 | + "strings" | ||
| 15 | + "sync" | ||
| 16 | + "testing" | ||
| 17 | + "time" | ||
| 18 | + | ||
| 19 | + "mm/internal/agentinit" | ||
| 20 | + "mm/internal/config" | ||
| 21 | + "mm/internal/sbxinit" | ||
| 22 | + | ||
| 23 | + sdk "github.com/coder/acp-go-sdk" | ||
| 24 | +) | ||
| 25 | + | ||
| 26 | +// llmmanStub is an llmman server with one model, as mm init's tests use. | ||
| 27 | +func llmmanStub(t *testing.T) string { | ||
| 28 | + t.Helper() | ||
| 29 | + routes := map[string]any{ | ||
| 30 | + "GET /api/tags": map[string]any{"models": []any{map[string]any{"name": "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M"}}}, | ||
| 31 | + "POST /api/show": map[string]any{"model_info": map[string]any{"general.architecture": "qwen35", "qwen35.context_length": 32768}, "template": "{{ enable_thinking | default(false) }}"}, | ||
| 32 | + } | ||
| 33 | + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 34 | + body, ok := routes[r.Method+" "+r.URL.Path] | ||
| 35 | + if !ok { | ||
| 36 | + http.NotFound(w, r) | ||
| 37 | + return | ||
| 38 | + } | ||
| 39 | + _ = json.NewEncoder(w).Encode(body) | ||
| 40 | + })) | ||
| 41 | + t.Cleanup(s.Close) | ||
| 42 | + return s.URL + "/v1" | ||
| 43 | +} | ||
| 44 | + | ||
| 45 | +func keepConfig(t *testing.T) { | ||
| 46 | + saved := config.Cfg | ||
| 47 | + t.Cleanup(func() { config.Cfg = saved }) | ||
| 48 | +} | ||
| 49 | + | ||
| 50 | +// fakeClient plays the ACP client: it keeps the agent's messages and, when | ||
| 51 | +// forms is set, answers each form with answer(key, property). | ||
| 52 | +type fakeClient struct { | ||
| 53 | + mu sync.Mutex | ||
| 54 | + messages []string | ||
| 55 | + commands []sdk.AvailableCommand | ||
| 56 | + forms [][]string // the property names of each form, in order | ||
| 57 | + answer func(key string, property map[string]any) any | ||
| 58 | + decline bool | ||
| 59 | +} | ||
| 60 | + | ||
| 61 | +func (c *fakeClient) SessionUpdate(_ context.Context, n sdk.SessionNotification) error { | ||
| 62 | + c.mu.Lock() | ||
| 63 | + defer c.mu.Unlock() | ||
| 64 | + if m := n.Update.AgentMessageChunk; m != nil && m.Content.Text != nil { | ||
| 65 | + c.messages = append(c.messages, m.Content.Text.Text) | ||
| 66 | + } | ||
| 67 | + if a := n.Update.AvailableCommandsUpdate; a != nil { | ||
| 68 | + c.commands = a.AvailableCommands | ||
| 69 | + } | ||
| 70 | + return nil | ||
| 71 | +} | ||
| 72 | + | ||
| 73 | +func (c *fakeClient) UnstableCreateElicitation(_ context.Context, p sdk.UnstableCreateElicitationRequest) (sdk.UnstableCreateElicitationResponse, error) { | ||
| 74 | + c.mu.Lock() | ||
| 75 | + defer c.mu.Unlock() | ||
| 76 | + props := p.Form.RequestedSchema.Properties | ||
| 77 | + var names []string | ||
| 78 | + for name := range props { | ||
| 79 | + names = append(names, name) | ||
| 80 | + } | ||
| 81 | + sort.Strings(names) | ||
| 82 | + c.forms = append(c.forms, names) | ||
| 83 | + if c.decline { | ||
| 84 | + return sdk.UnstableCreateElicitationResponse{Decline: &sdk.UnstableCreateElicitationDecline{Action: "decline"}}, nil | ||
| 85 | + } | ||
| 86 | + content := map[string]any{} | ||
| 87 | + for _, name := range names { | ||
| 88 | + prop, _ := props[name].(map[string]any) | ||
| 89 | + content[name] = c.answer(name, prop) | ||
| 90 | + } | ||
| 91 | + return sdk.UnstableCreateElicitationResponse{Accept: &sdk.UnstableCreateElicitationAccept{Action: "accept", Content: content}}, nil | ||
| 92 | +} | ||
| 93 | + | ||
| 94 | +func (c *fakeClient) RequestPermission(context.Context, sdk.RequestPermissionRequest) (sdk.RequestPermissionResponse, error) { | ||
| 95 | + return sdk.RequestPermissionResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodSessionRequestPermission) | ||
| 96 | +} | ||
| 97 | +func (c *fakeClient) ReadTextFile(context.Context, sdk.ReadTextFileRequest) (sdk.ReadTextFileResponse, error) { | ||
| 98 | + return sdk.ReadTextFileResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodFsReadTextFile) | ||
| 99 | +} | ||
| 100 | +func (c *fakeClient) WriteTextFile(context.Context, sdk.WriteTextFileRequest) (sdk.WriteTextFileResponse, error) { | ||
| 101 | + return sdk.WriteTextFileResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodFsWriteTextFile) | ||
| 102 | +} | ||
| 103 | +func (c *fakeClient) CreateTerminal(context.Context, sdk.CreateTerminalRequest) (sdk.CreateTerminalResponse, error) { | ||
| 104 | + return sdk.CreateTerminalResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalCreate) | ||
| 105 | +} | ||
| 106 | +func (c *fakeClient) KillTerminal(context.Context, sdk.KillTerminalRequest) (sdk.KillTerminalResponse, error) { | ||
| 107 | + return sdk.KillTerminalResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalKill) | ||
| 108 | +} | ||
| 109 | +func (c *fakeClient) TerminalOutput(context.Context, sdk.TerminalOutputRequest) (sdk.TerminalOutputResponse, error) { | ||
| 110 | + return sdk.TerminalOutputResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalOutput) | ||
| 111 | +} | ||
| 112 | +func (c *fakeClient) ReleaseTerminal(context.Context, sdk.ReleaseTerminalRequest) (sdk.ReleaseTerminalResponse, error) { | ||
| 113 | + return sdk.ReleaseTerminalResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalRelease) | ||
| 114 | +} | ||
| 115 | +func (c *fakeClient) WaitForTerminalExit(context.Context, sdk.WaitForTerminalExitRequest) (sdk.WaitForTerminalExitResponse, error) { | ||
| 116 | + return sdk.WaitForTerminalExitResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalWaitForExit) | ||
| 117 | +} | ||
| 118 | + | ||
| 119 | +// last is the agent's last message. | ||
| 120 | +func (c *fakeClient) last() string { | ||
| 121 | + c.mu.Lock() | ||
| 122 | + defer c.mu.Unlock() | ||
| 123 | + if len(c.messages) == 0 { | ||
| 124 | + return "" | ||
| 125 | + } | ||
| 126 | + return c.messages[len(c.messages)-1] | ||
| 127 | +} | ||
| 128 | + | ||
| 129 | +func (c *fakeClient) all() string { | ||
| 130 | + c.mu.Lock() | ||
| 131 | + defer c.mu.Unlock() | ||
| 132 | + return strings.Join(c.messages, "\n---\n") | ||
| 133 | +} | ||
| 134 | + | ||
| 135 | +// harness is an agent and a client connected by pipes, one session open | ||
| 136 | +// in cwd. | ||
| 137 | +type harness struct { | ||
| 138 | + t *testing.T | ||
| 139 | + client *fakeClient | ||
| 140 | + conn *sdk.ClientSideConnection | ||
| 141 | + sid sdk.SessionId | ||
| 142 | + cwd string | ||
| 143 | +} | ||
| 144 | + | ||
| 145 | +func start(t *testing.T, args []string, client *fakeClient, name string, forms bool) *harness { | ||
| 146 | + t.Helper() | ||
| 147 | + keepConfig(t) | ||
| 148 | + a, code, ok := agentinit.ParseArgs("mm acp-create", args, io.Discard, false) | ||
| 149 | + if !ok { | ||
| 150 | + t.Fatalf("flags refused (%d): %v", code, args) | ||
| 151 | + } | ||
| 152 | + c2aR, c2aW := io.Pipe() | ||
| 153 | + a2cR, a2cW := io.Pipe() | ||
| 154 | + t.Cleanup(func() { _ = c2aW.Close(); _ = a2cW.Close() }) | ||
| 155 | + newAgent(a, a2cW, c2aR, io.Discard) | ||
| 156 | + conn := sdk.NewClientSideConnection(client, c2aW, a2cR) | ||
| 157 | + conn.SetLogger(slog.New(slog.NewTextHandler(io.Discard, nil))) | ||
| 158 | + ctx := context.Background() | ||
| 159 | + caps := sdk.ClientCapabilities{} | ||
| 160 | + if forms { | ||
| 161 | + caps.Elicitation = &sdk.ElicitationCapabilities{Form: &sdk.ElicitationFormCapabilities{}} | ||
| 162 | + } | ||
| 163 | + if _, err := conn.Initialize(ctx, sdk.InitializeRequest{ProtocolVersion: sdk.ProtocolVersionNumber, ClientCapabilities: caps, ClientInfo: &sdk.Implementation{Name: name, Version: "1"}}); err != nil { | ||
| 164 | + t.Fatal(err) | ||
| 165 | + } | ||
| 166 | + cwd := t.TempDir() | ||
| 167 | + resp, err := conn.NewSession(ctx, sdk.NewSessionRequest{Cwd: cwd, McpServers: []sdk.McpServer{}}) | ||
| 168 | + if err != nil { | ||
| 169 | + t.Fatal(err) | ||
| 170 | + } | ||
| 171 | + // The welcome comes after the session/new response, in a goroutine: | ||
| 172 | + // wait for it, so that it is never taken for the answer to a prompt. | ||
| 173 | + for deadline := time.Now().Add(5 * time.Second); !strings.Contains(client.all(), "Send `/start`"); time.Sleep(5 * time.Millisecond) { | ||
| 174 | + if time.Now().After(deadline) { | ||
| 175 | + t.Fatal("no welcome message after session/new") | ||
| 176 | + } | ||
| 177 | + } | ||
| 178 | + return &harness{t: t, client: client, conn: conn, sid: resp.SessionId, cwd: cwd} | ||
| 179 | +} | ||
| 180 | + | ||
| 181 | +// say sends one prompt and returns the agent's last message. | ||
| 182 | +func (h *harness) say(text string) string { | ||
| 183 | + h.t.Helper() | ||
| 184 | + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | ||
| 185 | + defer cancel() | ||
| 186 | + if _, err := h.conn.Prompt(ctx, sdk.PromptRequest{SessionId: h.sid, Prompt: []sdk.ContentBlock{sdk.TextBlock(text)}}); err != nil { | ||
| 187 | + h.t.Fatalf("prompt %q: %v", text, err) | ||
| 188 | + } | ||
| 189 | + return h.client.last() | ||
| 190 | +} | ||
| 191 | + | ||
| 192 | +// golden is the file mm init's line mode writes with the same answers. | ||
| 193 | +func golden(t *testing.T, base string) string { | ||
| 194 | + t.Helper() | ||
| 195 | + keepConfig(t) | ||
| 196 | + out := filepath.Join(t.TempDir(), "agent.yaml") | ||
| 197 | + answers := strings.Join([]string{"", "1", "", "1", "2", "n", "", "", "bob", out}, "\n") + "\n" | ||
| 198 | + var stdout bytes.Buffer | ||
| 199 | + if code := agentinit.Main([]string{"--provider", "llmman", "--base-url", base, "--line"}, strings.NewReader(answers), &stdout); code != 0 { | ||
| 200 | + t.Fatalf("mm init: exit %d\n%s", code, stdout.String()) | ||
| 201 | + } | ||
| 202 | + data, err := os.ReadFile(out) | ||
| 203 | + if err != nil { | ||
| 204 | + t.Fatal(err) | ||
| 205 | + } | ||
| 206 | + return string(data) | ||
| 207 | +} | ||
| 208 | + | ||
| 209 | +func read(t *testing.T, path string) string { | ||
| 210 | + t.Helper() | ||
| 211 | + data, err := os.ReadFile(path) | ||
| 212 | + if err != nil { | ||
| 213 | + t.Fatalf("%s: %v", path, err) | ||
| 214 | + } | ||
| 215 | + return string(data) | ||
| 216 | +} | ||
| 217 | + | ||
| 218 | +// A client without forms gets the conversation: each question a message, | ||
| 219 | +// each answer a prompt — and the file is the one mm init writes with the | ||
| 220 | +// same answers, in the session's cwd. The client is called zed: the mode | ||
| 221 | +// still comes from the capabilities, and only the closing snippet is Zed's. | ||
| 222 | +func TestConversationWritesWhatMmInitWrites(t *testing.T) { | ||
| 223 | + base := llmmanStub(t) | ||
| 224 | + want := golden(t, base) | ||
| 225 | + client := &fakeClient{} | ||
| 226 | + h := start(t, []string{"--provider", "llmman", "--base-url", base}, client, "zed", false) | ||
| 227 | + | ||
| 228 | + first := h.say("hello") | ||
| 229 | + if !strings.Contains(first, "Keep everything in a .mm folder?") || !strings.Contains(client.all(), "mm init") { | ||
| 230 | + t.Fatalf("the first prompt did not start the wizard:\n%s", client.all()) | ||
| 231 | + } | ||
| 232 | + for _, answer := range []string{".", "1", ".", "1", "2", "n", ".", ".", "bob"} { | ||
| 233 | + h.say(answer) | ||
| 234 | + } | ||
| 235 | + end := h.say("agent.yaml") | ||
| 236 | + if len(client.forms) != 0 { | ||
| 237 | + t.Errorf("a client without the form capability was sent %d form(s)", len(client.forms)) | ||
| 238 | + } | ||
| 239 | + path := filepath.Join(h.cwd, "agent.yaml") | ||
| 240 | + if got := read(t, path); got != want { | ||
| 241 | + t.Errorf("file differs from mm init's:\n--- acp-create\n%s\n--- mm init\n%s", got, want) | ||
| 242 | + } | ||
| 243 | + for _, s := range []string{"✅ wrote agent.yaml", "✅ mm loads it", "mm acp " + path, "agent_servers"} { | ||
| 244 | + if !strings.Contains(end, s) { | ||
| 245 | + t.Errorf("closing message lacks %q:\n%s", s, end) | ||
| 246 | + } | ||
| 247 | + } | ||
| 248 | + if again := h.say("more"); !strings.Contains(again, "is written") { | ||
| 249 | + t.Errorf("after writing: %q", again) | ||
| 250 | + } | ||
| 251 | +} | ||
| 252 | + | ||
| 253 | +// A client with forms gets a few forms; a form never holds a question | ||
| 254 | +// with the one its options come from. The file is again mm init's. | ||
| 255 | +func TestFormsWriteWhatMmInitWrites(t *testing.T) { | ||
| 256 | + base := llmmanStub(t) | ||
| 257 | + want := golden(t, base) | ||
| 258 | + given := map[string]any{"project": false, "usage": "developer", "machine": "medium", "yolo": false, "sessions": false, "skills": false, "name": "bob", "file": "agent.yaml"} | ||
| 259 | + client := &fakeClient{answer: func(key string, p map[string]any) any { | ||
| 260 | + if v, ok := given[key]; ok { | ||
| 261 | + return v | ||
| 262 | + } | ||
| 263 | + if enum, ok := p["enum"].([]any); ok && len(enum) > 0 { | ||
| 264 | + if d, ok := p["default"]; ok { | ||
| 265 | + return d | ||
| 266 | + } | ||
| 267 | + return enum[0] | ||
| 268 | + } | ||
| 269 | + return p["default"] | ||
| 270 | + }} | ||
| 271 | + h := start(t, []string{"--provider", "llmman", "--base-url", base}, client, "some-editor", true) | ||
| 272 | + end := h.say("go") | ||
| 273 | + | ||
| 274 | + wantForms := [][]string{{"model", "project"}, {"family", "machine", "usage"}, {"file", "name", "sessions", "skills", "yolo"}} | ||
| 275 | + if len(client.forms) != len(wantForms) { | ||
| 276 | + t.Fatalf("forms = %v, want %v", client.forms, wantForms) | ||
| 277 | + } | ||
| 278 | + for i := range wantForms { | ||
| 279 | + if strings.Join(client.forms[i], ",") != strings.Join(wantForms[i], ",") { | ||
| 280 | + t.Errorf("form %d = %v, want %v", i+1, client.forms[i], wantForms[i]) | ||
| 281 | + } | ||
| 282 | + } | ||
| 283 | + if got := read(t, filepath.Join(h.cwd, "agent.yaml")); got != want { | ||
| 284 | + t.Errorf("file differs from mm init's:\n--- acp-create\n%s\n--- mm init\n%s", got, want) | ||
| 285 | + } | ||
| 286 | + if !strings.Contains(end, "✅ mm loads it") || strings.Contains(end, "agent_servers") { | ||
| 287 | + t.Errorf("closing message (no snippet for an unknown client):\n%s", end) | ||
| 288 | + } | ||
| 289 | +} | ||
| 290 | + | ||
| 291 | +// Declining a form writes nothing; the next message asks again. | ||
| 292 | +func TestDeclinedFormWritesNothing(t *testing.T) { | ||
| 293 | + client := &fakeClient{decline: true} | ||
| 294 | + h := start(t, []string{"--provider", "llmman", "--base-url", llmmanStub(t)}, client, "x", true) | ||
| 295 | + if got := h.say("go"); !strings.Contains(got, "nothing written") { | ||
| 296 | + t.Errorf("after a decline: %q", got) | ||
| 297 | + } | ||
| 298 | + if _, err := os.Stat(filepath.Join(h.cwd, "agent.yaml")); err == nil { | ||
| 299 | + t.Error("a declined form wrote the file") | ||
| 300 | + } | ||
| 301 | + h.say("again") | ||
| 302 | + if len(client.forms) != 2 { | ||
| 303 | + t.Errorf("the next message did not ask again: %d form(s)", len(client.forms)) | ||
| 304 | + } | ||
| 305 | +} | ||
| 306 | + | ||
| 307 | +// /preview, /back, a refused answer, and /write with the Project layout. | ||
| 308 | +func TestCommands(t *testing.T) { | ||
| 309 | + client := &fakeClient{} | ||
| 310 | + h := start(t, []string{"--provider", "llmman", "--base-url", llmmanStub(t)}, client, "x", false) | ||
| 311 | + if got := h.say("/start"); !strings.Contains(got, ".mm folder?") { | ||
| 312 | + t.Errorf("/start did not ask the first question: %q", got) | ||
| 313 | + } | ||
| 314 | + if got := h.say("/start"); !strings.Contains(got, ".mm folder?") { | ||
| 315 | + t.Errorf("/start again did not ask the current question again: %q", got) | ||
| 316 | + } | ||
| 317 | + if strings.Count(client.all(), "let's create an agent") != 1 { | ||
| 318 | + t.Errorf("the introduction was not sent exactly once:\n%s", client.all()) | ||
| 319 | + } | ||
| 320 | + // Announced right after the session/new response, in a goroutine. | ||
| 321 | + announced := 0 | ||
| 322 | + for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) { | ||
| 323 | + client.mu.Lock() | ||
| 324 | + announced = len(client.commands) | ||
| 325 | + client.mu.Unlock() | ||
| 326 | + if announced > 0 { | ||
| 327 | + break | ||
| 328 | + } | ||
| 329 | + } | ||
| 330 | + if announced != len(commands) { | ||
| 331 | + t.Errorf("announced %d command(s), want %d", announced, len(commands)) | ||
| 332 | + } | ||
| 333 | + if got := h.say("maybe"); !strings.Contains(got, "answer yes or no") || !strings.Contains(got, ".mm folder?") { | ||
| 334 | + t.Errorf("a non-answer to a yes/no question: %q", got) | ||
| 335 | + } | ||
| 336 | + if got := h.say("yes"); !strings.Contains(got, "Model?") { | ||
| 337 | + t.Errorf("after the layout: %q", got) | ||
| 338 | + } | ||
| 339 | + if got := h.say("/back"); !strings.Contains(got, ".mm folder?") { | ||
| 340 | + t.Errorf("/back did not ask the layout again: %q", got) | ||
| 341 | + } | ||
| 342 | + h.say("1") // yes, by number | ||
| 343 | + if got := h.say("/preview"); !strings.Contains(got, "```yaml") || !strings.Contains(got, "provider: llmman") { | ||
| 344 | + t.Errorf("/preview: %q", got) | ||
| 345 | + } | ||
| 346 | + end := h.say("/write") | ||
| 347 | + path := filepath.Join(h.cwd, ".mm", "agent.yaml") | ||
| 348 | + if !strings.Contains(read(t, path), "hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M") { | ||
| 349 | + t.Errorf("/write did not write %s", path) | ||
| 350 | + } | ||
| 351 | + if !strings.Contains(end, "mm acp "+path) { | ||
| 352 | + t.Errorf("closing message: %q", end) | ||
| 353 | + } | ||
| 354 | + if got := h.say("/restart"); !strings.Contains(got, ".mm folder?") { | ||
| 355 | + t.Errorf("/restart: %q", got) | ||
| 356 | + } | ||
| 357 | +} | ||
| 358 | + | ||
| 359 | +// /sbxinit writes sbxenv.yaml in the session's cwd, named after the agent | ||
| 360 | +// just written; a second one keeps it unless --overwrite. | ||
| 361 | +func TestSbxinit(t *testing.T) { | ||
| 362 | + restore := sbxinitIn | ||
| 363 | + sbxinitIn = func(dir string, args []string, out io.Writer, overwrite bool) int { | ||
| 364 | + return sbxinit.InWith(dir, args, out, overwrite, func() (string, error) { return "9.9.9", nil }) | ||
| 365 | + } | ||
| 366 | + t.Cleanup(func() { sbxinitIn = restore }) | ||
| 367 | + client := &fakeClient{} | ||
| 368 | + h := start(t, []string{"--provider", "llmman", "--base-url", llmmanStub(t), "--name", "lucy"}, client, "x", false) | ||
| 369 | + h.say("/start") | ||
| 370 | + if end := h.say("/write"); !strings.Contains(end, "/sbxinit") { | ||
| 371 | + t.Errorf("the closing message does not offer /sbxinit:\n%s", end) | ||
| 372 | + } | ||
| 373 | + got := h.say("/sbxinit") | ||
| 374 | + path := filepath.Join(h.cwd, "sbxenv.yaml") | ||
| 375 | + text := read(t, path) | ||
| 376 | + for _, want := range []string{"name: lucy-sbx", "mini-me-kit:9.9.9", "workspace: " + h.cwd} { | ||
| 377 | + if !strings.Contains(text, want) { | ||
| 378 | + t.Errorf("sbxenv.yaml lacks %q:\n%s", want, text) | ||
| 379 | + } | ||
| 380 | + } | ||
| 381 | + if !strings.Contains(got, "✅ wrote") || !strings.Contains(got, "sbx env create") { | ||
| 382 | + t.Errorf("/sbxinit said:\n%s", got) | ||
| 383 | + } | ||
| 384 | + if got := h.say("/sbxinit --name max"); !strings.Contains(got, "/sbxinit --overwrite") || strings.Contains(read(t, path), "max-sbx") { | ||
| 385 | + t.Errorf("an existing sbxenv.yaml was not kept: %q", got) | ||
| 386 | + } | ||
| 387 | + h.say("/sbxinit --name max --overwrite") | ||
| 388 | + if !strings.Contains(read(t, path), "name: max-sbx") { | ||
| 389 | + t.Errorf("--overwrite did not replace sbxenv.yaml:\n%s", read(t, path)) | ||
| 390 | + } | ||
| 391 | +} | ||
| 392 | + | ||
| 393 | +// /dockerinit writes Dockerfile and compose.yaml in the session's cwd; a | ||
| 394 | +// .mm agent gets the read-only .mm mount (ticket 212). | ||
| 395 | +func TestDockerinit(t *testing.T) { | ||
| 396 | + client := &fakeClient{} | ||
| 397 | + h := start(t, []string{"--provider", "llmman", "--base-url", llmmanStub(t), "--name", "lucy", "--project", "yes", "--sessions", "yes"}, client, "x", false) | ||
| 398 | + h.say("/start") | ||
| 399 | + if end := h.say("/write"); !strings.Contains(end, "/dockerinit") { | ||
| 400 | + t.Errorf("the closing message does not offer /dockerinit:\n%s", end) | ||
| 401 | + } | ||
| 402 | + got := h.say("/dockerinit") | ||
| 403 | + compose := read(t, filepath.Join(h.cwd, "compose.yaml")) | ||
| 404 | + for _, want := range []string{"container_name: lucy-box", "./.mm:${PWD:-/workspace}/.mm:ro", "./.mm/sessions:"} { | ||
| 405 | + if !strings.Contains(compose, want) { | ||
| 406 | + t.Errorf("compose.yaml lacks %q:\n%s", want, compose) | ||
| 407 | + } | ||
| 408 | + } | ||
| 409 | + if !strings.Contains(got, "exec lucy mm tui\n") || !strings.Contains(got, "`docker` commands from") { | ||
| 410 | + t.Errorf("/dockerinit said:\n%s", got) | ||
| 411 | + } | ||
| 412 | + if got := h.say("/dockerinit"); !strings.Contains(got, "/dockerinit --overwrite") { | ||
| 413 | + t.Errorf("existing files were not kept: %q", got) | ||
| 414 | + } | ||
| 415 | + if got := h.say("/dockerinit --overwrite --name max"); !strings.Contains(got, "✅ wrote compose.yaml") && !strings.Contains(got, "compose.yaml") { | ||
| 416 | + t.Errorf("--overwrite: %q", got) | ||
| 417 | + } | ||
| 418 | + if !strings.Contains(read(t, filepath.Join(h.cwd, "compose.yaml")), "max-box") { | ||
| 419 | + t.Error("--overwrite did not replace compose.yaml") | ||
| 420 | + } | ||
| 421 | +} | ||
| 422 | + | ||
| 423 | +// A client's "reload agent" (ticket 210): loadSession and session/resume | ||
| 424 | +// are offered; both start the wizard over under the id the client asked | ||
| 425 | +// for, and say so. | ||
| 426 | +func TestReloadStartsOver(t *testing.T) { | ||
| 427 | + base := llmmanStub(t) | ||
| 428 | + client := &fakeClient{} | ||
| 429 | + h := start(t, []string{"--provider", "llmman", "--base-url", base}, client, "zed", false) | ||
| 430 | + h.say("/start") | ||
| 431 | + h.say("yes") | ||
| 432 | + | ||
| 433 | + for _, method := range []string{"load", "resume"} { | ||
| 434 | + // A new process: a new agent and connection, the same session id. | ||
| 435 | + again := &fakeClient{} | ||
| 436 | + h2 := start(t, []string{"--provider", "llmman", "--base-url", base}, again, "zed", false) | ||
| 437 | + ctx := context.Background() | ||
| 438 | + var err error | ||
| 439 | + if method == "load" { | ||
| 440 | + _, err = h2.conn.LoadSession(ctx, sdk.LoadSessionRequest{SessionId: h.sid, Cwd: h.cwd, McpServers: []sdk.McpServer{}}) | ||
| 441 | + } else { | ||
| 442 | + _, err = h2.conn.ResumeSession(ctx, sdk.ResumeSessionRequest{SessionId: h.sid, Cwd: h.cwd}) | ||
| 443 | + } | ||
| 444 | + if err != nil { | ||
| 445 | + t.Fatalf("session/%s: %v", method, err) | ||
| 446 | + } | ||
| 447 | + if !strings.Contains(again.all(), "restarted") { | ||
| 448 | + t.Errorf("session/%s: no restart message:\n%s", method, again.all()) | ||
| 449 | + } | ||
| 450 | + h2.sid, h2.cwd = h.sid, h.cwd | ||
| 451 | + if got := h2.say("/start"); !strings.Contains(got, ".mm folder?") { | ||
| 452 | + t.Errorf("session/%s: the wizard did not start over: %q", method, got) | ||
| 453 | + } | ||
| 454 | + } | ||
| 455 | +} | ||
| 456 | + | ||
| 457 | +// /zedconfig shows one entry per way the project can start its agent: mm | ||
| 458 | +// here always, the container with compose.yaml, the sandbox with | ||
| 459 | +// sbxenv.yaml — and nothing but a pointer to /start without an agent. | ||
| 460 | +func TestZedconfig(t *testing.T) { | ||
| 461 | + restore := sbxinitIn | ||
| 462 | + sbxinitIn = func(dir string, args []string, out io.Writer, overwrite bool) int { | ||
| 463 | + return sbxinit.InWith(dir, args, out, overwrite, func() (string, error) { return "9.9.9", nil }) | ||
| 464 | + } | ||
| 465 | + t.Cleanup(func() { sbxinitIn = restore }) | ||
| 466 | + client := &fakeClient{} | ||
| 467 | + h := start(t, []string{"--provider", "llmman", "--base-url", llmmanStub(t), "--name", "lucy", "--project", "yes"}, client, "x", false) | ||
| 468 | + if got := h.say("/zedconfig"); !strings.Contains(got, "`/start` creates one") { | ||
| 469 | + t.Errorf("without an agent: %q", got) | ||
| 470 | + } | ||
| 471 | + h.say("/start") | ||
| 472 | + h.say("/write") | ||
| 473 | + agent := filepath.Join(h.cwd, ".mm", "agent.yaml") | ||
| 474 | + project := "mm-" + filepath.Base(h.cwd) | ||
| 475 | + | ||
| 476 | + settings := func(text string) map[string]server { | ||
| 477 | + t.Helper() | ||
| 478 | + start := strings.Index(text, "```json\n") | ||
| 479 | + end := strings.LastIndex(text, "```") | ||
| 480 | + if start < 0 || end <= start { | ||
| 481 | + t.Fatalf("no JSON block:\n%s", text) | ||
| 482 | + } | ||
| 483 | + var parsed struct { | ||
| 484 | + Servers map[string]server `json:"agent_servers"` | ||
| 485 | + } | ||
| 486 | + if err := json.Unmarshal([]byte(text[start+len("```json\n"):end]), &parsed); err != nil { | ||
| 487 | + t.Fatalf("not JSON: %v\n%s", err, text) | ||
| 488 | + } | ||
| 489 | + return parsed.Servers | ||
| 490 | + } | ||
| 491 | + | ||
| 492 | + got := h.say("/zedconfig") | ||
| 493 | + servers := settings(got) | ||
| 494 | + if len(servers) != 1 || strings.Join(servers[project].Args, " ") != "acp "+agent || !strings.Contains(got, "/dockerinit") { | ||
| 495 | + t.Errorf("agent only: %v\n%s", servers, got) | ||
| 496 | + } | ||
| 497 | + | ||
| 498 | + h.say("/dockerinit") | ||
| 499 | + h.say("/sbxinit") | ||
| 500 | + got = h.say("/zedconfig") | ||
| 501 | + servers = settings(got) | ||
| 502 | + if c := servers[project+" (container)"]; c.Command != "docker" || strings.Join(c.Args, " ") != "exec -i lucy-box mm -acp -config "+agent { | ||
| 503 | + t.Errorf("container entry: %+v\n%s", c, got) | ||
| 504 | + } | ||
| 505 | + if s := servers[project+" (sandbox)"]; s.Command != "sbx" || strings.Join(s.Args, " ") != "exec -i lucy-sbx mm -acp -config "+agent { | ||
| 506 | + t.Errorf("sandbox entry: %+v\n%s", s, got) | ||
| 507 | + } | ||
| 508 | + for _, want := range []string{"docker compose up -d --wait", "sbx env create --force"} { | ||
| 509 | + if !strings.Contains(got, want) { | ||
| 510 | + t.Errorf("no %q in:\n%s", want, got) | ||
| 511 | + } | ||
| 512 | + } | ||
| 513 | +} | ||
| 514 | + | ||
| 515 | +func TestParseAnswer(t *testing.T) { | ||
| 516 | + choice := agentinit.Step{Kind: "choice", Default: "b", Options: []agentinit.Option{{Value: "a"}, {Value: "b"}}} | ||
| 517 | + confirm := agentinit.Step{Kind: "confirm", Default: "no", Options: []agentinit.Option{{Value: "yes"}, {Value: "no"}}} | ||
| 518 | + text := agentinit.Step{Kind: "text"} | ||
| 519 | + cases := []struct { | ||
| 520 | + st agentinit.Step | ||
| 521 | + in, out string | ||
| 522 | + bad bool | ||
| 523 | + }{ | ||
| 524 | + {choice, "2", "b", false}, | ||
| 525 | + {choice, ".", "b", false}, | ||
| 526 | + {choice, "a", "a", false}, | ||
| 527 | + {choice, "9", "", true}, | ||
| 528 | + {confirm, "1", "yes", false}, | ||
| 529 | + {confirm, "n", "no", false}, | ||
| 530 | + {confirm, "default", "no", false}, | ||
| 531 | + {confirm, "perhaps", "", true}, | ||
| 532 | + {text, "my-model", "my-model", false}, | ||
| 533 | + {text, ".", "", true}, | ||
| 534 | + } | ||
| 535 | + for _, c := range cases { | ||
| 536 | + got, err := parseAnswer(c.st, c.in) | ||
| 537 | + if (err != nil) != c.bad || got != c.out { | ||
| 538 | + t.Errorf("parseAnswer(%s, %q) = %q, %v", c.st.Kind, c.in, got, err) | ||
| 539 | + } | ||
| 540 | + } | ||
| 541 | +} | ||
| 542 | + | ||
| 543 | +func TestBatchStopsAtADependency(t *testing.T) { | ||
| 544 | + steps := func(keys ...string) []agentinit.Step { | ||
| 545 | + var out []agentinit.Step | ||
| 546 | + for _, k := range keys { | ||
| 547 | + out = append(out, agentinit.Step{Key: k}) | ||
| 548 | + } | ||
| 549 | + return out | ||
| 550 | + } | ||
| 551 | + keys := func(st []agentinit.Step) string { | ||
| 552 | + var out []string | ||
| 553 | + for _, s := range st { | ||
| 554 | + out = append(out, s.Key) | ||
| 555 | + } | ||
| 556 | + return strings.Join(out, ",") | ||
| 557 | + } | ||
| 558 | + for in, want := range map[string]string{ | ||
| 559 | + "project,provider,model": "project,provider", | ||
| 560 | + "endpoint,keyenv,model": "endpoint,keyenv", | ||
| 561 | + "usage,machine,yolo": "usage,machine", | ||
| 562 | + "yolo,sessions,skills,name,file": "yolo,sessions,skills,name,file", | ||
| 563 | + "file,overwrite": "file", | ||
| 564 | + "project,skills": "project", | ||
| 565 | + } { | ||
| 566 | + if got := keys(batch(steps(strings.Split(in, ",")...))); got != want { | ||
| 567 | + t.Errorf("batch(%s) = %s, want %s", in, got, want) | ||
| 568 | + } | ||
| 569 | + } | ||
| 570 | +} | ||
| 571 | + | ||
| 572 | +func TestIs(t *testing.T) { | ||
| 573 | + for _, a := range []string{"acp-create", "--acp-create", "-acp-create"} { | ||
| 574 | + if !Is(a) { | ||
| 575 | + t.Errorf("Is(%q) = false", a) | ||
| 576 | + } | ||
| 577 | + } | ||
| 578 | + if Is("acp") || Is("init") { | ||
| 579 | + t.Error("Is accepts another mode") | ||
| 580 | + } | ||
| 581 | +} | ||
added
internal/acpcreate/ask.go +266 -0 | new file mode 100644 | ||
| @@ -0,0 +1,266 @@ | ||
| 1 | +package acpcreate | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "errors" | |
| 5 | + "fmt" | |
| 6 | + "strconv" | |
| 7 | + "strings" | |
| 8 | + | |
| 9 | + "mm/internal/agentinit" | |
| 10 | + "mm/internal/elicit" | |
| 11 | + | |
| 12 | + sdk "github.com/coder/acp-go-sdk" | |
| 13 | +) | |
| 14 | + | |
| 15 | +// --- the conversation: one question per message --------------------------------- | |
| 16 | + | |
| 17 | +// question is one step as a chat message: the prompt, the numbered options | |
| 18 | +// with what each means, and how to answer. | |
| 19 | +func question(st agentinit.Step, position string) string { | |
| 20 | + var b strings.Builder | |
| 21 | + fmt.Fprintf(&b, "**%s**", st.Prompt) | |
| 22 | + if position != "" { | |
| 23 | + fmt.Fprintf(&b, " — *%s, question %s*", st.Title, position) | |
| 24 | + } | |
| 25 | + b.WriteString("\n\n") | |
| 26 | + for i, o := range st.Options { | |
| 27 | + fmt.Fprintf(&b, "%d. `%s`", i+1, o.Label) | |
| 28 | + if o.Detail != "" { | |
| 29 | + b.WriteString(" — " + o.Detail) | |
| 30 | + } | |
| 31 | + if o.Value == st.Default { | |
| 32 | + b.WriteString(" **(default)**") | |
| 33 | + } | |
| 34 | + b.WriteString("\n") | |
| 35 | + } | |
| 36 | + if len(st.Options) > 0 { | |
| 37 | + b.WriteString("\n") | |
| 38 | + } | |
| 39 | + how := "Type your answer" | |
| 40 | + if len(st.Options) > 0 { | |
| 41 | + how = "Reply with a number or a value" | |
| 42 | + } | |
| 43 | + if st.Default != "" { | |
| 44 | + how += fmt.Sprintf(" · `.` for the default (`%s`)", st.Default) | |
| 45 | + } | |
| 46 | + b.WriteString(how + ".") | |
| 47 | + return b.String() | |
| 48 | +} | |
| 49 | + | |
| 50 | +// isDefault: the answers that take the default — a client may not send an | |
| 51 | +// empty message, so "." does it. | |
| 52 | +func isDefault(text string) bool { | |
| 53 | + switch strings.ToLower(strings.TrimSpace(text)) { | |
| 54 | + case "", ".", "default", "/skip": | |
| 55 | + return true | |
| 56 | + } | |
| 57 | + return false | |
| 58 | +} | |
| 59 | + | |
| 60 | +// parseAnswer reads a message as the answer to st. A yes/no question takes | |
| 61 | +// yes, no (y, n, oui…) or the option's number; nothing else. | |
| 62 | +func parseAnswer(st agentinit.Step, text string) (string, error) { | |
| 63 | + text = strings.TrimSpace(text) | |
| 64 | + if isDefault(text) { | |
| 65 | + if st.Default == "" { | |
| 66 | + return "", errors.New("this question has no default: type an answer") | |
| 67 | + } | |
| 68 | + return st.Default, nil | |
| 69 | + } | |
| 70 | + q := elicit.Question{Prompt: st.Prompt, Kind: st.Kind, Default: st.Default} | |
| 71 | + for _, o := range st.Options { | |
| 72 | + q.Options = append(q.Options, o.Value) | |
| 73 | + } | |
| 74 | + if st.Kind == elicit.Confirm { | |
| 75 | + return parseConfirm(q, text) | |
| 76 | + } | |
| 77 | + a, ok := elicit.ParseLine(q, text) | |
| 78 | + if !ok || len(a.Values) == 0 { | |
| 79 | + return "", fmt.Errorf("%q is not one of the %d options", text, len(q.Options)) | |
| 80 | + } | |
| 81 | + return a.Values[0], nil | |
| 82 | +} | |
| 83 | + | |
| 84 | +// parseConfirm reads a yes/no answer: the option's number, or a word | |
| 85 | +// elicit reads as yes or no. Anything else is refused — the flow would take | |
| 86 | +// it for no. | |
| 87 | +func parseConfirm(q elicit.Question, text string) (string, error) { | |
| 88 | + if n, err := strconv.Atoi(text); err == nil && n >= 1 && n <= len(q.Options) { | |
| 89 | + return q.Options[n-1], nil | |
| 90 | + } | |
| 91 | + if a, ok := elicit.ParseLine(q, text); ok && len(a.Values) > 0 { | |
| 92 | + return a.Values[0], nil | |
| 93 | + } | |
| 94 | + return "", fmt.Errorf("answer yes or no, not %q", text) | |
| 95 | +} | |
| 96 | + | |
| 97 | +// --- forms: several questions at once ------------------------------------------- | |
| 98 | + | |
| 99 | +// 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 | |
| 101 | +// depends on: its options would be stale by the time the form is answered. | |
| 102 | +var dependsOn = map[string][]string{ | |
| 103 | + "endpoint": {"provider"}, | |
| 104 | + "keyenv": {"provider"}, | |
| 105 | + "model": {"provider", "endpoint", "keyenv"}, | |
| 106 | + "family": {"model"}, | |
| 107 | + "yolo": {"usage"}, | |
| 108 | + "name": {"usage"}, | |
| 109 | + "skills": {"project"}, | |
| 110 | + "file": {"project"}, | |
| 111 | + "overwrite": {"file"}, | |
| 112 | +} | |
| 113 | + | |
| 114 | +// 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 { | |
| 117 | + in := map[string]bool{} | |
| 118 | + var out []agentinit.Step | |
| 119 | + for _, st := range pending { | |
| 120 | + for _, dep := range dependsOn[st.Key] { | |
| 121 | + if in[dep] { | |
| 122 | + return out | |
| 123 | + } | |
| 124 | + } | |
| 125 | + in[st.Key] = true | |
| 126 | + out = append(out, st) | |
| 127 | + } | |
| 128 | + return out | |
| 129 | +} | |
| 130 | + | |
| 131 | +// maxIdleForms is how many forms in a row may answer nothing before the | |
| 132 | +// wizard stops asking: a client that keeps sending invalid answers must | |
| 133 | +// not loop forever. | |
| 134 | +const maxIdleForms = 3 | |
| 135 | + | |
| 136 | +// askByForms asks the pending questions form after form, then writes. | |
| 137 | +func (t *turn) askByForms() { | |
| 138 | + s := t.s | |
| 139 | + idle := 0 | |
| 140 | + for { | |
| 141 | + pending := s.flow.Pending() | |
| 142 | + if len(pending) == 0 { | |
| 143 | + t.write() | |
| 144 | + return | |
| 145 | + } | |
| 146 | + steps := batch(pending) | |
| 147 | + answers, status := t.form(steps) | |
| 148 | + switch status { | |
| 149 | + case elicit.Declined: | |
| 150 | + t.say("Stopped: nothing written. Send any message to go on, `/restart` to start again.") | |
| 151 | + return | |
| 152 | + case elicit.Cancelled, elicit.Unavailable: | |
| 153 | + t.say("The form was closed: nothing written. Send any message to go on.") | |
| 154 | + return | |
| 155 | + } | |
| 156 | + if t.apply(steps, answers) == 0 { | |
| 157 | + idle++ | |
| 158 | + if idle >= maxIdleForms { | |
| 159 | + t.say("No answer could be used: stopping here. Send any message to try again.") | |
| 160 | + return | |
| 161 | + } | |
| 162 | + } else { | |
| 163 | + idle = 0 | |
| 164 | + } | |
| 165 | + } | |
| 166 | +} | |
| 167 | + | |
| 168 | +// apply answers the questions of a form; it reports what was refused and | |
| 169 | +// returns how many answers were taken. | |
| 170 | +func (t *turn) apply(steps []agentinit.Step, answers []elicit.Answer) int { | |
| 171 | + s := t.s | |
| 172 | + taken := 0 | |
| 173 | + var refused []string | |
| 174 | + for i, st := range steps { | |
| 175 | + value := "" | |
| 176 | + if i < len(answers) && len(answers[i].Values) > 0 { | |
| 177 | + value = answers[i].Values[0] | |
| 178 | + } | |
| 179 | + if strings.TrimSpace(value) == "" { | |
| 180 | + value = st.Default | |
| 181 | + } | |
| 182 | + if err := s.flow.Set(st.Key, value); err != nil { | |
| 183 | + refused = append(refused, "⚠️ "+err.Error()) | |
| 184 | + continue | |
| 185 | + } | |
| 186 | + s.answered = append(s.answered, st.Key) | |
| 187 | + taken++ | |
| 188 | + } | |
| 189 | + if len(refused) > 0 { | |
| 190 | + t.say(strings.Join(refused, "\n") + "\n\nAsking again.") | |
| 191 | + } | |
| 192 | + return taken | |
| 193 | +} | |
| 194 | + | |
| 195 | +// form sends steps as one elicitation/create form. | |
| 196 | +func (t *turn) form(steps []agentinit.Step) ([]elicit.Answer, elicit.Status) { | |
| 197 | + f := formOf(steps) | |
| 198 | + schema := elicit.Schema(f) | |
| 199 | + names := make([]string, len(f.Questions)) | |
| 200 | + for i, q := range f.Questions { | |
| 201 | + names[i] = q.Name | |
| 202 | + } | |
| 203 | + req := sdk.NewUnstableCreateElicitationRequestForm(sdk.UnstableElicitationSchema{ | |
| 204 | + Type: sdk.UnstableElicitationSchemaTypeObject, | |
| 205 | + Properties: schema["properties"].(map[string]any), | |
| 206 | + Required: names, | |
| 207 | + }) | |
| 208 | + req.Form.Message = formMessage(steps) | |
| 209 | + // The session scope rides in _meta; acpwire.Writer puts it where the | |
| 210 | + // specification wants it. | |
| 211 | + req.Form.Meta = map[string]any{elicit.ScopeMetaKey: map[string]any{"sessionId": string(t.sid)}} | |
| 212 | + resp, err := t.ag.conn.UnstableCreateElicitation(t.ctx, req) | |
| 213 | + switch { | |
| 214 | + case err != nil && t.ctx.Err() != nil: | |
| 215 | + return nil, elicit.Cancelled | |
| 216 | + case err != nil: | |
| 217 | + return nil, elicit.Unavailable | |
| 218 | + case resp.Accept != nil: | |
| 219 | + return elicit.FromContent(f, resp.Accept.Content).Answers, elicit.Answered | |
| 220 | + case resp.Decline != nil: | |
| 221 | + return nil, elicit.Declined | |
| 222 | + } | |
| 223 | + return nil, elicit.Cancelled | |
| 224 | +} | |
| 225 | + | |
| 226 | +// formOf is the steps as an elicit form: one question per step, named by | |
| 227 | +// its key, its options' values as the choices. | |
| 228 | +func formOf(steps []agentinit.Step) elicit.Form { | |
| 229 | + var f elicit.Form | |
| 230 | + for _, st := range steps { | |
| 231 | + q := elicit.Question{Name: st.Key, Prompt: st.Prompt, Kind: st.Kind, Default: st.Default} | |
| 232 | + for _, o := range st.Options { | |
| 233 | + q.Options = append(q.Options, o.Value) | |
| 234 | + } | |
| 235 | + f.Questions = append(f.Questions, q) | |
| 236 | + } | |
| 237 | + return f | |
| 238 | +} | |
| 239 | + | |
| 240 | +// formMessage is the text above a form: what is being made, and what the | |
| 241 | +// options of its choices mean — a schema's enum carries values only. | |
| 242 | +func formMessage(steps []agentinit.Step) string { | |
| 243 | + var b strings.Builder | |
| 244 | + b.WriteString("mm init — create an agent") | |
| 245 | + for _, st := range steps { | |
| 246 | + if st.Kind != elicit.Choice || !hasDetails(st) { | |
| 247 | + continue | |
| 248 | + } | |
| 249 | + fmt.Fprintf(&b, "\n\n%s", st.Title) | |
| 250 | + for _, o := range st.Options { | |
| 251 | + if o.Detail != "" { | |
| 252 | + fmt.Fprintf(&b, "\n %s — %s", o.Label, o.Detail) | |
| 253 | + } | |
| 254 | + } | |
| 255 | + } | |
| 256 | + return b.String() | |
| 257 | +} | |
| 258 | + | |
| 259 | +func hasDetails(st agentinit.Step) bool { | |
| 260 | + for _, o := range st.Options { | |
| 261 | + if o.Detail != "" { | |
| 262 | + return true | |
| 263 | + } | |
| 264 | + } | |
| 265 | + return false | |
| 266 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,266 @@ | |||
| 1 | +package acpcreate | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "errors" | ||
| 5 | + "fmt" | ||
| 6 | + "strconv" | ||
| 7 | + "strings" | ||
| 8 | + | ||
| 9 | + "mm/internal/agentinit" | ||
| 10 | + "mm/internal/elicit" | ||
| 11 | + | ||
| 12 | + sdk "github.com/coder/acp-go-sdk" | ||
| 13 | +) | ||
| 14 | + | ||
| 15 | +// --- the conversation: one question per message --------------------------------- | ||
| 16 | + | ||
| 17 | +// question is one step as a chat message: the prompt, the numbered options | ||
| 18 | +// with what each means, and how to answer. | ||
| 19 | +func question(st agentinit.Step, position string) string { | ||
| 20 | + var b strings.Builder | ||
| 21 | + fmt.Fprintf(&b, "**%s**", st.Prompt) | ||
| 22 | + if position != "" { | ||
| 23 | + fmt.Fprintf(&b, " — *%s, question %s*", st.Title, position) | ||
| 24 | + } | ||
| 25 | + b.WriteString("\n\n") | ||
| 26 | + for i, o := range st.Options { | ||
| 27 | + fmt.Fprintf(&b, "%d. `%s`", i+1, o.Label) | ||
| 28 | + if o.Detail != "" { | ||
| 29 | + b.WriteString(" — " + o.Detail) | ||
| 30 | + } | ||
| 31 | + if o.Value == st.Default { | ||
| 32 | + b.WriteString(" **(default)**") | ||
| 33 | + } | ||
| 34 | + b.WriteString("\n") | ||
| 35 | + } | ||
| 36 | + if len(st.Options) > 0 { | ||
| 37 | + b.WriteString("\n") | ||
| 38 | + } | ||
| 39 | + how := "Type your answer" | ||
| 40 | + if len(st.Options) > 0 { | ||
| 41 | + how = "Reply with a number or a value" | ||
| 42 | + } | ||
| 43 | + if st.Default != "" { | ||
| 44 | + how += fmt.Sprintf(" · `.` for the default (`%s`)", st.Default) | ||
| 45 | + } | ||
| 46 | + b.WriteString(how + ".") | ||
| 47 | + return b.String() | ||
| 48 | +} | ||
| 49 | + | ||
| 50 | +// isDefault: the answers that take the default — a client may not send an | ||
| 51 | +// empty message, so "." does it. | ||
| 52 | +func isDefault(text string) bool { | ||
| 53 | + switch strings.ToLower(strings.TrimSpace(text)) { | ||
| 54 | + case "", ".", "default", "/skip": | ||
| 55 | + return true | ||
| 56 | + } | ||
| 57 | + return false | ||
| 58 | +} | ||
| 59 | + | ||
| 60 | +// parseAnswer reads a message as the answer to st. A yes/no question takes | ||
| 61 | +// yes, no (y, n, oui…) or the option's number; nothing else. | ||
| 62 | +func parseAnswer(st agentinit.Step, text string) (string, error) { | ||
| 63 | + text = strings.TrimSpace(text) | ||
| 64 | + if isDefault(text) { | ||
| 65 | + if st.Default == "" { | ||
| 66 | + return "", errors.New("this question has no default: type an answer") | ||
| 67 | + } | ||
| 68 | + return st.Default, nil | ||
| 69 | + } | ||
| 70 | + q := elicit.Question{Prompt: st.Prompt, Kind: st.Kind, Default: st.Default} | ||
| 71 | + for _, o := range st.Options { | ||
| 72 | + q.Options = append(q.Options, o.Value) | ||
| 73 | + } | ||
| 74 | + if st.Kind == elicit.Confirm { | ||
| 75 | + return parseConfirm(q, text) | ||
| 76 | + } | ||
| 77 | + a, ok := elicit.ParseLine(q, text) | ||
| 78 | + if !ok || len(a.Values) == 0 { | ||
| 79 | + return "", fmt.Errorf("%q is not one of the %d options", text, len(q.Options)) | ||
| 80 | + } | ||
| 81 | + return a.Values[0], nil | ||
| 82 | +} | ||
| 83 | + | ||
| 84 | +// parseConfirm reads a yes/no answer: the option's number, or a word | ||
| 85 | +// elicit reads as yes or no. Anything else is refused — the flow would take | ||
| 86 | +// it for no. | ||
| 87 | +func parseConfirm(q elicit.Question, text string) (string, error) { | ||
| 88 | + if n, err := strconv.Atoi(text); err == nil && n >= 1 && n <= len(q.Options) { | ||
| 89 | + return q.Options[n-1], nil | ||
| 90 | + } | ||
| 91 | + if a, ok := elicit.ParseLine(q, text); ok && len(a.Values) > 0 { | ||
| 92 | + return a.Values[0], nil | ||
| 93 | + } | ||
| 94 | + return "", fmt.Errorf("answer yes or no, not %q", text) | ||
| 95 | +} | ||
| 96 | + | ||
| 97 | +// --- forms: several questions at once ------------------------------------------- | ||
| 98 | + | ||
| 99 | +// 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 | ||
| 101 | +// depends on: its options would be stale by the time the form is answered. | ||
| 102 | +var dependsOn = map[string][]string{ | ||
| 103 | + "endpoint": {"provider"}, | ||
| 104 | + "keyenv": {"provider"}, | ||
| 105 | + "model": {"provider", "endpoint", "keyenv"}, | ||
| 106 | + "family": {"model"}, | ||
| 107 | + "yolo": {"usage"}, | ||
| 108 | + "name": {"usage"}, | ||
| 109 | + "skills": {"project"}, | ||
| 110 | + "file": {"project"}, | ||
| 111 | + "overwrite": {"file"}, | ||
| 112 | +} | ||
| 113 | + | ||
| 114 | +// 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 { | ||
| 117 | + in := map[string]bool{} | ||
| 118 | + var out []agentinit.Step | ||
| 119 | + for _, st := range pending { | ||
| 120 | + for _, dep := range dependsOn[st.Key] { | ||
| 121 | + if in[dep] { | ||
| 122 | + return out | ||
| 123 | + } | ||
| 124 | + } | ||
| 125 | + in[st.Key] = true | ||
| 126 | + out = append(out, st) | ||
| 127 | + } | ||
| 128 | + return out | ||
| 129 | +} | ||
| 130 | + | ||
| 131 | +// maxIdleForms is how many forms in a row may answer nothing before the | ||
| 132 | +// wizard stops asking: a client that keeps sending invalid answers must | ||
| 133 | +// not loop forever. | ||
| 134 | +const maxIdleForms = 3 | ||
| 135 | + | ||
| 136 | +// askByForms asks the pending questions form after form, then writes. | ||
| 137 | +func (t *turn) askByForms() { | ||
| 138 | + s := t.s | ||
| 139 | + idle := 0 | ||
| 140 | + for { | ||
| 141 | + pending := s.flow.Pending() | ||
| 142 | + if len(pending) == 0 { | ||
| 143 | + t.write() | ||
| 144 | + return | ||
| 145 | + } | ||
| 146 | + steps := batch(pending) | ||
| 147 | + answers, status := t.form(steps) | ||
| 148 | + switch status { | ||
| 149 | + case elicit.Declined: | ||
| 150 | + t.say("Stopped: nothing written. Send any message to go on, `/restart` to start again.") | ||
| 151 | + return | ||
| 152 | + case elicit.Cancelled, elicit.Unavailable: | ||
| 153 | + t.say("The form was closed: nothing written. Send any message to go on.") | ||
| 154 | + return | ||
| 155 | + } | ||
| 156 | + if t.apply(steps, answers) == 0 { | ||
| 157 | + idle++ | ||
| 158 | + if idle >= maxIdleForms { | ||
| 159 | + t.say("No answer could be used: stopping here. Send any message to try again.") | ||
| 160 | + return | ||
| 161 | + } | ||
| 162 | + } else { | ||
| 163 | + idle = 0 | ||
| 164 | + } | ||
| 165 | + } | ||
| 166 | +} | ||
| 167 | + | ||
| 168 | +// apply answers the questions of a form; it reports what was refused and | ||
| 169 | +// returns how many answers were taken. | ||
| 170 | +func (t *turn) apply(steps []agentinit.Step, answers []elicit.Answer) int { | ||
| 171 | + s := t.s | ||
| 172 | + taken := 0 | ||
| 173 | + var refused []string | ||
| 174 | + for i, st := range steps { | ||
| 175 | + value := "" | ||
| 176 | + if i < len(answers) && len(answers[i].Values) > 0 { | ||
| 177 | + value = answers[i].Values[0] | ||
| 178 | + } | ||
| 179 | + if strings.TrimSpace(value) == "" { | ||
| 180 | + value = st.Default | ||
| 181 | + } | ||
| 182 | + if err := s.flow.Set(st.Key, value); err != nil { | ||
| 183 | + refused = append(refused, "⚠️ "+err.Error()) | ||
| 184 | + continue | ||
| 185 | + } | ||
| 186 | + s.answered = append(s.answered, st.Key) | ||
| 187 | + taken++ | ||
| 188 | + } | ||
| 189 | + if len(refused) > 0 { | ||
| 190 | + t.say(strings.Join(refused, "\n") + "\n\nAsking again.") | ||
| 191 | + } | ||
| 192 | + return taken | ||
| 193 | +} | ||
| 194 | + | ||
| 195 | +// form sends steps as one elicitation/create form. | ||
| 196 | +func (t *turn) form(steps []agentinit.Step) ([]elicit.Answer, elicit.Status) { | ||
| 197 | + f := formOf(steps) | ||
| 198 | + schema := elicit.Schema(f) | ||
| 199 | + names := make([]string, len(f.Questions)) | ||
| 200 | + for i, q := range f.Questions { | ||
| 201 | + names[i] = q.Name | ||
| 202 | + } | ||
| 203 | + req := sdk.NewUnstableCreateElicitationRequestForm(sdk.UnstableElicitationSchema{ | ||
| 204 | + Type: sdk.UnstableElicitationSchemaTypeObject, | ||
| 205 | + Properties: schema["properties"].(map[string]any), | ||
| 206 | + Required: names, | ||
| 207 | + }) | ||
| 208 | + req.Form.Message = formMessage(steps) | ||
| 209 | + // The session scope rides in _meta; acpwire.Writer puts it where the | ||
| 210 | + // specification wants it. | ||
| 211 | + req.Form.Meta = map[string]any{elicit.ScopeMetaKey: map[string]any{"sessionId": string(t.sid)}} | ||
| 212 | + resp, err := t.ag.conn.UnstableCreateElicitation(t.ctx, req) | ||
| 213 | + switch { | ||
| 214 | + case err != nil && t.ctx.Err() != nil: | ||
| 215 | + return nil, elicit.Cancelled | ||
| 216 | + case err != nil: | ||
| 217 | + return nil, elicit.Unavailable | ||
| 218 | + case resp.Accept != nil: | ||
| 219 | + return elicit.FromContent(f, resp.Accept.Content).Answers, elicit.Answered | ||
| 220 | + case resp.Decline != nil: | ||
| 221 | + return nil, elicit.Declined | ||
| 222 | + } | ||
| 223 | + return nil, elicit.Cancelled | ||
| 224 | +} | ||
| 225 | + | ||
| 226 | +// formOf is the steps as an elicit form: one question per step, named by | ||
| 227 | +// its key, its options' values as the choices. | ||
| 228 | +func formOf(steps []agentinit.Step) elicit.Form { | ||
| 229 | + var f elicit.Form | ||
| 230 | + for _, st := range steps { | ||
| 231 | + q := elicit.Question{Name: st.Key, Prompt: st.Prompt, Kind: st.Kind, Default: st.Default} | ||
| 232 | + for _, o := range st.Options { | ||
| 233 | + q.Options = append(q.Options, o.Value) | ||
| 234 | + } | ||
| 235 | + f.Questions = append(f.Questions, q) | ||
| 236 | + } | ||
| 237 | + return f | ||
| 238 | +} | ||
| 239 | + | ||
| 240 | +// formMessage is the text above a form: what is being made, and what the | ||
| 241 | +// options of its choices mean — a schema's enum carries values only. | ||
| 242 | +func formMessage(steps []agentinit.Step) string { | ||
| 243 | + var b strings.Builder | ||
| 244 | + b.WriteString("mm init — create an agent") | ||
| 245 | + for _, st := range steps { | ||
| 246 | + if st.Kind != elicit.Choice || !hasDetails(st) { | ||
| 247 | + continue | ||
| 248 | + } | ||
| 249 | + fmt.Fprintf(&b, "\n\n%s", st.Title) | ||
| 250 | + for _, o := range st.Options { | ||
| 251 | + if o.Detail != "" { | ||
| 252 | + fmt.Fprintf(&b, "\n %s — %s", o.Label, o.Detail) | ||
| 253 | + } | ||
| 254 | + } | ||
| 255 | + } | ||
| 256 | + return b.String() | ||
| 257 | +} | ||
| 258 | + | ||
| 259 | +func hasDetails(st agentinit.Step) bool { | ||
| 260 | + for _, o := range st.Options { | ||
| 261 | + if o.Detail != "" { | ||
| 262 | + return true | ||
| 263 | + } | ||
| 264 | + } | ||
| 265 | + return false | ||
| 266 | +} | ||
added
internal/acpcreate/restart.go +56 -0 | new file mode 100644 | ||
| @@ -0,0 +1,56 @@ | ||
| 1 | +package acpcreate | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "fmt" | |
| 6 | + | |
| 7 | + sdk "github.com/coder/acp-go-sdk" | |
| 8 | +) | |
| 9 | + | |
| 10 | +// A client's "reload agent" restarts mm, then asks the new process for the | |
| 11 | +// thread it shows: session/load, or session/resume when the agent offers | |
| 12 | +// it. The wizard keeps nothing on disk (user's choice, 2026-11-14): both | |
| 13 | +// start a fresh wizard under the id the client asked for, and say so. The | |
| 14 | +// file already written, if any, is untouched. | |
| 15 | + | |
| 16 | +// LoadSession implements session/load. There is no conversation to replay: | |
| 17 | +// the one message sent says the wizard starts over. | |
| 18 | +func (ag *agent) LoadSession(ctx context.Context, p sdk.LoadSessionRequest) (sdk.LoadSessionResponse, error) { | |
| 19 | + if err := ag.restart(ctx, "session/load", p.SessionId, p.Cwd); err != nil { | |
| 20 | + return sdk.LoadSessionResponse{}, err | |
| 21 | + } | |
| 22 | + return sdk.LoadSessionResponse{}, nil | |
| 23 | +} | |
| 24 | + | |
| 25 | +// ResumeSession implements session/resume: the same, the client keeping the | |
| 26 | +// thread on screen. | |
| 27 | +func (ag *agent) ResumeSession(ctx context.Context, p sdk.ResumeSessionRequest) (sdk.ResumeSessionResponse, error) { | |
| 28 | + if err := ag.restart(ctx, "session/resume", p.SessionId, p.Cwd); err != nil { | |
| 29 | + return sdk.ResumeSessionResponse{}, err | |
| 30 | + } | |
| 31 | + return sdk.ResumeSessionResponse{}, nil | |
| 32 | +} | |
| 33 | + | |
| 34 | +// restart registers a fresh wizard as session sid, then sends the commands | |
| 35 | +// and the restart message. The client asked for this id, so it accepts | |
| 36 | +// updates for it before the response — no need to wait for it. | |
| 37 | +func (ag *agent) restart(ctx context.Context, method string, sid sdk.SessionId, cwd string) error { | |
| 38 | + s, err := ag.newSession(cwd) | |
| 39 | + if err != nil { | |
| 40 | + return err | |
| 41 | + } | |
| 42 | + ag.mu.Lock() | |
| 43 | + ag.sessions[sid] = s | |
| 44 | + ag.mu.Unlock() | |
| 45 | + fmt.Fprintf(ag.log, "[acp-create] %s %s cwd=%s: the wizard starts over\n", method, sid, cwd) | |
| 46 | + _ = ag.conn.SessionUpdate(ctx, sdk.SessionNotification{ | |
| 47 | + SessionId: sid, | |
| 48 | + Update: sdk.SessionUpdate{AvailableCommandsUpdate: &sdk.SessionAvailableCommandsUpdate{AvailableCommands: availableCommands()}}, | |
| 49 | + }) | |
| 50 | + ag.say(ctx, sid, restarted(cwd)) | |
| 51 | + return nil | |
| 52 | +} | |
| 53 | + | |
| 54 | +func restarted(cwd string) string { | |
| 55 | + return fmt.Sprintf("**mm init** restarted: the answers given before are gone, the wizard starts over in `%s` (a file already written is untouched). Send `/start` (or any message) to begin.", cwd) | |
| 56 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,56 @@ | |||
| 1 | +package acpcreate | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "fmt" | ||
| 6 | + | ||
| 7 | + sdk "github.com/coder/acp-go-sdk" | ||
| 8 | +) | ||
| 9 | + | ||
| 10 | +// A client's "reload agent" restarts mm, then asks the new process for the | ||
| 11 | +// thread it shows: session/load, or session/resume when the agent offers | ||
| 12 | +// it. The wizard keeps nothing on disk (user's choice, 2026-11-14): both | ||
| 13 | +// start a fresh wizard under the id the client asked for, and say so. The | ||
| 14 | +// file already written, if any, is untouched. | ||
| 15 | + | ||
| 16 | +// LoadSession implements session/load. There is no conversation to replay: | ||
| 17 | +// the one message sent says the wizard starts over. | ||
| 18 | +func (ag *agent) LoadSession(ctx context.Context, p sdk.LoadSessionRequest) (sdk.LoadSessionResponse, error) { | ||
| 19 | + if err := ag.restart(ctx, "session/load", p.SessionId, p.Cwd); err != nil { | ||
| 20 | + return sdk.LoadSessionResponse{}, err | ||
| 21 | + } | ||
| 22 | + return sdk.LoadSessionResponse{}, nil | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +// ResumeSession implements session/resume: the same, the client keeping the | ||
| 26 | +// thread on screen. | ||
| 27 | +func (ag *agent) ResumeSession(ctx context.Context, p sdk.ResumeSessionRequest) (sdk.ResumeSessionResponse, error) { | ||
| 28 | + if err := ag.restart(ctx, "session/resume", p.SessionId, p.Cwd); err != nil { | ||
| 29 | + return sdk.ResumeSessionResponse{}, err | ||
| 30 | + } | ||
| 31 | + return sdk.ResumeSessionResponse{}, nil | ||
| 32 | +} | ||
| 33 | + | ||
| 34 | +// restart registers a fresh wizard as session sid, then sends the commands | ||
| 35 | +// and the restart message. The client asked for this id, so it accepts | ||
| 36 | +// updates for it before the response — no need to wait for it. | ||
| 37 | +func (ag *agent) restart(ctx context.Context, method string, sid sdk.SessionId, cwd string) error { | ||
| 38 | + s, err := ag.newSession(cwd) | ||
| 39 | + if err != nil { | ||
| 40 | + return err | ||
| 41 | + } | ||
| 42 | + ag.mu.Lock() | ||
| 43 | + ag.sessions[sid] = s | ||
| 44 | + ag.mu.Unlock() | ||
| 45 | + fmt.Fprintf(ag.log, "[acp-create] %s %s cwd=%s: the wizard starts over\n", method, sid, cwd) | ||
| 46 | + _ = ag.conn.SessionUpdate(ctx, sdk.SessionNotification{ | ||
| 47 | + SessionId: sid, | ||
| 48 | + Update: sdk.SessionUpdate{AvailableCommandsUpdate: &sdk.SessionAvailableCommandsUpdate{AvailableCommands: availableCommands()}}, | ||
| 49 | + }) | ||
| 50 | + ag.say(ctx, sid, restarted(cwd)) | ||
| 51 | + return nil | ||
| 52 | +} | ||
| 53 | + | ||
| 54 | +func restarted(cwd string) string { | ||
| 55 | + return fmt.Sprintf("**mm init** restarted: the answers given before are gone, the wizard starts over in `%s` (a file already written is untouched). Send `/start` (or any message) to begin.", cwd) | ||
| 56 | +} | ||
added
internal/acpcreate/session.go +379 -0 | new file mode 100644 | ||
| @@ -0,0 +1,379 @@ | ||
| 1 | +package acpcreate | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "bytes" | |
| 5 | + "context" | |
| 6 | + "fmt" | |
| 7 | + "io" | |
| 8 | + "os" | |
| 9 | + "path/filepath" | |
| 10 | + "strings" | |
| 11 | + "sync" | |
| 12 | + | |
| 13 | + "mm/internal/agentinit" | |
| 14 | + "mm/internal/dockerinit" | |
| 15 | + "mm/internal/sbxinit" | |
| 16 | + | |
| 17 | + sdk "github.com/coder/acp-go-sdk" | |
| 18 | +) | |
| 19 | + | |
| 20 | +// session is one run of the wizard. Its mutex serialises the prompts: a | |
| 21 | +// flow is not safe for two answers at once. | |
| 22 | +type session struct { | |
| 23 | + mu sync.Mutex | |
| 24 | + cwd string | |
| 25 | + flow *agentinit.Flow | |
| 26 | + | |
| 27 | + 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. | |
| 35 | + written string | |
| 36 | + | |
| 37 | + cancelMu sync.Mutex | |
| 38 | + cancel context.CancelFunc | |
| 39 | +} | |
| 40 | + | |
| 41 | +func (s *session) setCancel(c context.CancelFunc) { | |
| 42 | + s.cancelMu.Lock() | |
| 43 | + s.cancel = c | |
| 44 | + s.cancelMu.Unlock() | |
| 45 | +} | |
| 46 | + | |
| 47 | +func (s *session) cancelTurn() { | |
| 48 | + s.cancelMu.Lock() | |
| 49 | + c := s.cancel | |
| 50 | + s.cancelMu.Unlock() | |
| 51 | + if c != nil { | |
| 52 | + c() | |
| 53 | + } | |
| 54 | +} | |
| 55 | + | |
| 56 | +// commands are the slash commands this mode handles, in the order shown. | |
| 57 | +var commands = []struct{ name, description, hint string }{ | |
| 58 | + {"start", "Start creating an agent (any message does too)", ""}, | |
| 59 | + {"preview", "Show the agent.yaml as it would be written now", ""}, | |
| 60 | + {"back", "Take back the last answer", ""}, | |
| 61 | + {"write", "Take the default for every question left, then write the file", ""}, | |
| 62 | + {"restart", "Forget every answer and start again", ""}, | |
| 63 | + {"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", ""}, | |
| 65 | + {"sbxinit", "Write a sbxenv.yaml to run the agent in a Docker Sandbox (mm sbxinit)", "[--name <name>] [--workspace <dir>] [--agent <file>] [--overwrite]"}, | |
| 66 | + {"help", "How this wizard works", ""}, | |
| 67 | +} | |
| 68 | + | |
| 69 | +func availableCommands() []sdk.AvailableCommand { | |
| 70 | + out := make([]sdk.AvailableCommand, 0, len(commands)) | |
| 71 | + for _, c := range commands { | |
| 72 | + cmd := sdk.AvailableCommand{Name: c.name, Description: c.description} | |
| 73 | + if c.hint != "" { | |
| 74 | + cmd.Input = &sdk.AvailableCommandInput{Unstructured: &sdk.UnstructuredCommandInput{Hint: c.hint}} | |
| 75 | + } | |
| 76 | + out = append(out, cmd) | |
| 77 | + } | |
| 78 | + return out | |
| 79 | +} | |
| 80 | + | |
| 81 | +// turn is one prompt being answered. | |
| 82 | +type turn struct { | |
| 83 | + ag *agent | |
| 84 | + s *session | |
| 85 | + sid sdk.SessionId | |
| 86 | + ctx context.Context | |
| 87 | +} | |
| 88 | + | |
| 89 | +func (t *turn) say(markdown string) { t.ag.say(t.ctx, t.sid, markdown) } | |
| 90 | + | |
| 91 | +// handle answers one prompt. | |
| 92 | +func (t *turn) handle(text string) { | |
| 93 | + if name, args, ok := slashCommand(text); ok { | |
| 94 | + t.command(name, args) | |
| 95 | + return | |
| 96 | + } | |
| 97 | + s := t.s | |
| 98 | + switch { | |
| 99 | + case s.written != "": | |
| 100 | + t.say(fmt.Sprintf("`%s` is written. `/restart` creates another one.", s.written)) | |
| 101 | + return | |
| 102 | + case !s.started: | |
| 103 | + t.start() | |
| 104 | + return | |
| 105 | + case s.asked != "": | |
| 106 | + if !t.answer(text) { | |
| 107 | + return | |
| 108 | + } | |
| 109 | + } | |
| 110 | + t.next() | |
| 111 | +} | |
| 112 | + | |
| 113 | +// slashCommand recognises one of this mode's commands, and the words | |
| 114 | +// typed after it. | |
| 115 | +func slashCommand(text string) (string, []string, bool) { | |
| 116 | + text = strings.TrimSpace(text) | |
| 117 | + if !strings.HasPrefix(text, "/") { | |
| 118 | + return "", nil, false | |
| 119 | + } | |
| 120 | + words := strings.Fields(text[1:]) | |
| 121 | + if len(words) == 0 { | |
| 122 | + return "", nil, false | |
| 123 | + } | |
| 124 | + for _, c := range commands { | |
| 125 | + if c.name == words[0] { | |
| 126 | + return c.name, words[1:], true | |
| 127 | + } | |
| 128 | + } | |
| 129 | + return "", nil, false | |
| 130 | +} | |
| 131 | + | |
| 132 | +func (t *turn) command(name string, args []string) { | |
| 133 | + s := t.s | |
| 134 | + switch name { | |
| 135 | + case "sbxinit": | |
| 136 | + t.sbxinit(args) | |
| 137 | + case "dockerinit": | |
| 138 | + t.dockerinit(args) | |
| 139 | + case "zedconfig": | |
| 140 | + t.say(zedConfig(s.cwd)) | |
| 141 | + case "start": | |
| 142 | + t.start() | |
| 143 | + case "help": | |
| 144 | + t.say(help()) | |
| 145 | + case "preview": | |
| 146 | + t.say("```yaml\n" + s.flow.Preview("", "") + "```") | |
| 147 | + case "restart": | |
| 148 | + fresh, err := t.ag.newSession(s.cwd) | |
| 149 | + if err != nil { | |
| 150 | + t.say("⚠️ " + err.Error()) | |
| 151 | + return | |
| 152 | + } | |
| 153 | + s.flow, s.asked, s.answered, s.written, s.started = fresh.flow, "", nil, "", true | |
| 154 | + t.say("Starting again.") | |
| 155 | + t.next() | |
| 156 | + case "back": | |
| 157 | + t.back() | |
| 158 | + case "write": | |
| 159 | + if s.written != "" { | |
| 160 | + t.say(fmt.Sprintf("`%s` is already written.", s.written)) | |
| 161 | + return | |
| 162 | + } | |
| 163 | + s.started = true | |
| 164 | + t.takeDefaults() | |
| 165 | + t.next() | |
| 166 | + } | |
| 167 | +} | |
| 168 | + | |
| 169 | +// scaffolder is a command that writes files in the session's project the | |
| 170 | +// way mm sbxinit or mm dockerinit does: in is that command run for a | |
| 171 | +// directory (sbxinit.In, dockerinit.In). | |
| 172 | +type scaffolder struct { | |
| 173 | + name string // the slash command | |
| 174 | + files []string // what it writes, relative to the project | |
| 175 | + run func(dir string, args []string, out io.Writer, overwrite bool) int | |
| 176 | + after string // said when it wrote something | |
| 177 | +} | |
| 178 | + | |
| 179 | +// sbxinit is mm sbxinit for the session's project: sbxenv.yaml in its cwd. | |
| 180 | +func (t *turn) sbxinit(args []string) { | |
| 181 | + t.scaffold(scaffolder{name: "sbxinit", files: []string{"sbxenv.yaml"}, run: sbxinitIn, after: "Run the `sbx` commands from `%s`."}, args) | |
| 182 | +} | |
| 183 | + | |
| 184 | +// dockerinit is mm dockerinit for the session's project: Dockerfile and | |
| 185 | +// compose.yaml in its cwd (ticket 212). | |
| 186 | +func (t *turn) dockerinit(args []string) { | |
| 187 | + t.scaffold(scaffolder{name: "dockerinit", files: []string{"Dockerfile", "compose.yaml"}, run: dockerinitIn, after: "Run the `docker` commands from `%s`."}, args) | |
| 188 | +} | |
| 189 | + | |
| 190 | +// scaffold runs c for the session's project. It needs no answer from the | |
| 191 | +// wizard and works at any time — after the agent file is written, it names | |
| 192 | +// what it writes after it. Nobody can answer "Overwrite?" here: existing | |
| 193 | +// files are kept unless --overwrite is given. | |
| 194 | +func (t *turn) scaffold(c scaffolder, args []string) { | |
| 195 | + cwd := t.s.cwd | |
| 196 | + overwrite := false | |
| 197 | + var flags []string | |
| 198 | + for _, a := range args { | |
| 199 | + if a == "--overwrite" || a == "-overwrite" { | |
| 200 | + overwrite = true | |
| 201 | + continue | |
| 202 | + } | |
| 203 | + flags = append(flags, a) | |
| 204 | + } | |
| 205 | + if !overwrite { | |
| 206 | + var existing []string | |
| 207 | + for _, f := range c.files { | |
| 208 | + if _, err := os.Stat(filepath.Join(cwd, f)); err == nil { | |
| 209 | + existing = append(existing, "`"+f+"`") | |
| 210 | + } | |
| 211 | + } | |
| 212 | + if len(existing) > 0 { | |
| 213 | + t.say(fmt.Sprintf("%s already in `%s`: `/%s --overwrite` replaces it.", strings.Join(existing, " and "), cwd, c.name)) | |
| 214 | + return | |
| 215 | + } | |
| 216 | + } | |
| 217 | + var report bytes.Buffer | |
| 218 | + code := c.run(cwd, flags, &report, overwrite) | |
| 219 | + msg := "```\n" + strings.TrimRight(report.String(), "\n") + "\n```" | |
| 220 | + if code == 0 && strings.Contains(report.String(), "✅ wrote") { | |
| 221 | + msg += "\n\n" + fmt.Sprintf(c.after, cwd) | |
| 222 | + } | |
| 223 | + t.say(msg) | |
| 224 | +} | |
| 225 | + | |
| 226 | +// dockerinitIn is dockerinit.In, beside sbxinitIn. | |
| 227 | +var dockerinitIn = dockerinit.In | |
| 228 | + | |
| 229 | +// sbxinitIn is sbxinit.In; the tests replace it to read no release. | |
| 230 | +var sbxinitIn = sbxinit.In | |
| 231 | + | |
| 232 | +// start begins the wizard, or — already begun — asks the current question | |
| 233 | +// again. | |
| 234 | +func (t *turn) start() { | |
| 235 | + s := t.s | |
| 236 | + if s.written != "" { | |
| 237 | + t.say(fmt.Sprintf("`%s` is written. `/restart` creates another one.", s.written)) | |
| 238 | + return | |
| 239 | + } | |
| 240 | + if !s.started { | |
| 241 | + s.started = true | |
| 242 | + t.say(intro(s.cwd, t.ag.elicitForm)) | |
| 243 | + } | |
| 244 | + t.next() | |
| 245 | +} | |
| 246 | + | |
| 247 | +// welcome is sent once the session exists, before any prompt: a client | |
| 248 | +// that shows it tells the user how to begin. | |
| 249 | +func welcome(cwd string) string { | |
| 250 | + return fmt.Sprintf("**mm init** — this creates an agent.yaml in `%s`. Send `/start` (or any message) to begin.", cwd) | |
| 251 | +} | |
| 252 | + | |
| 253 | +// back takes back the last answer given here and asks it again. | |
| 254 | +func (t *turn) back() { | |
| 255 | + s := t.s | |
| 256 | + if len(s.answered) == 0 { | |
| 257 | + t.say("Nothing to take back.") | |
| 258 | + return | |
| 259 | + } | |
| 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 = "" | |
| 264 | + t.next() | |
| 265 | +} | |
| 266 | + | |
| 267 | +// 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. | |
| 269 | +func (t *turn) takeDefaults() { | |
| 270 | + s := t.s | |
| 271 | + for { | |
| 272 | + pending := s.flow.Pending() | |
| 273 | + if len(pending) == 0 { | |
| 274 | + return | |
| 275 | + } | |
| 276 | + st := pending[0] | |
| 277 | + if st.Key == "overwrite" || st.Default == "" { | |
| 278 | + return | |
| 279 | + } | |
| 280 | + if err := s.flow.Set(st.Key, st.Default); err != nil { | |
| 281 | + t.say("⚠️ " + err.Error()) | |
| 282 | + return | |
| 283 | + } | |
| 284 | + s.answered = append(s.answered, st.Key) | |
| 285 | + } | |
| 286 | +} | |
| 287 | + | |
| 288 | +// next asks what is still to ask, or writes the file when nothing is. | |
| 289 | +func (t *turn) next() { | |
| 290 | + s := t.s | |
| 291 | + s.asked = "" | |
| 292 | + if t.ag.elicitForm { | |
| 293 | + t.askByForms() | |
| 294 | + return | |
| 295 | + } | |
| 296 | + pending := s.flow.Pending() | |
| 297 | + if len(pending) == 0 { | |
| 298 | + t.write() | |
| 299 | + return | |
| 300 | + } | |
| 301 | + st := pending[0] | |
| 302 | + s.asked = st.Key | |
| 303 | + t.say(question(st, t.position(st.Key))) | |
| 304 | +} | |
| 305 | + | |
| 306 | +// position is "n of N", the question's place among all of them. | |
| 307 | +func (t *turn) position(key string) string { | |
| 308 | + steps := t.s.flow.Steps() | |
| 309 | + for i, st := range steps { | |
| 310 | + if st.Key == key { | |
| 311 | + return fmt.Sprintf("%d of %d", i+1, len(steps)) | |
| 312 | + } | |
| 313 | + } | |
| 314 | + return "" | |
| 315 | +} | |
| 316 | + | |
| 317 | +// answer reads the prompt as the answer to the question asked; false when | |
| 318 | +// it does not answer it (the question is shown again). | |
| 319 | +func (t *turn) answer(text string) bool { | |
| 320 | + s := t.s | |
| 321 | + st := s.flow.Step(s.asked) | |
| 322 | + value, err := parseAnswer(st, text) | |
| 323 | + if err == nil { | |
| 324 | + err = s.flow.Set(st.Key, value) | |
| 325 | + } | |
| 326 | + if err != nil { | |
| 327 | + t.say("⚠️ " + err.Error() + "\n\n" + question(st, t.position(st.Key))) | |
| 328 | + return false | |
| 329 | + } | |
| 330 | + s.answered = append(s.answered, st.Key) | |
| 331 | + return true | |
| 332 | +} | |
| 333 | + | |
| 334 | +// write writes the file and says how to use it. | |
| 335 | +func (t *turn) write() { | |
| 336 | + s := t.s | |
| 337 | + var report bytes.Buffer | |
| 338 | + output, ok := agentinit.WriteFile(s.flow, &report) | |
| 339 | + if output == "-" { | |
| 340 | + t.say("```yaml\n" + report.String() + "```") | |
| 341 | + return | |
| 342 | + } | |
| 343 | + if !ok { | |
| 344 | + t.say(report.String() + "\nNothing more was done: `/back` changes an answer, `/restart` starts again.") | |
| 345 | + return | |
| 346 | + } | |
| 347 | + 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.") | |
| 349 | +} | |
| 350 | + | |
| 351 | +func intro(cwd string, forms bool) string { | |
| 352 | + how := "I ask the questions one by one here: answer each with its number or its value, `.` for the default." | |
| 353 | + if forms { | |
| 354 | + how = "Your client shows forms: I ask the questions in a few of them." | |
| 355 | + } | |
| 356 | + return fmt.Sprintf("**mm init** — let's create an agent in `%s`.\n\n%s `/preview` shows the file as it stands, `/back` takes back an answer, `/write` takes every default left and writes it.", cwd, how) | |
| 357 | +} | |
| 358 | + | |
| 359 | +func help() string { | |
| 360 | + var b strings.Builder | |
| 361 | + b.WriteString("This is `mm init` over ACP: it writes an agent.yaml (or `.mm/agent.yaml`) in this project, then says how to start the agent.\n\n") | |
| 362 | + for _, c := range commands { | |
| 363 | + fmt.Fprintf(&b, "- `/%s` — %s\n", c.name, c.description) | |
| 364 | + } | |
| 365 | + return b.String() | |
| 366 | +} | |
| 367 | + | |
| 368 | +// howToUse is the closing message: the command line every client can use, | |
| 369 | +// plus a ready snippet for a client mm knows. The snippet is a convenience; | |
| 370 | +// nothing else depends on the client's name. | |
| 371 | +func howToUse(path, client string) string { | |
| 372 | + var b strings.Builder | |
| 373 | + fmt.Fprintf(&b, "Start the agent from your ACP client with the command `mm acp %s`.\n", path) | |
| 374 | + if strings.Contains(strings.ToLower(client), "zed") { | |
| 375 | + fmt.Fprintf(&b, "\nIn Zed's settings.json:\n\n```json\n\"agent_servers\": {\n \"%s\": {\n \"type\": \"custom\",\n \"command\": \"mm\",\n \"args\": [\"acp\", %q]\n }\n}\n```\n", entryName(path), path) | |
| 376 | + } | |
| 377 | + b.WriteString("\n`/zedconfig` shows every way to start it from Zed — here, in a container, in a Docker Sandbox.\n") | |
| 378 | + return b.String() | |
| 379 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,379 @@ | |||
| 1 | +package acpcreate | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "bytes" | ||
| 5 | + "context" | ||
| 6 | + "fmt" | ||
| 7 | + "io" | ||
| 8 | + "os" | ||
| 9 | + "path/filepath" | ||
| 10 | + "strings" | ||
| 11 | + "sync" | ||
| 12 | + | ||
| 13 | + "mm/internal/agentinit" | ||
| 14 | + "mm/internal/dockerinit" | ||
| 15 | + "mm/internal/sbxinit" | ||
| 16 | + | ||
| 17 | + sdk "github.com/coder/acp-go-sdk" | ||
| 18 | +) | ||
| 19 | + | ||
| 20 | +// session is one run of the wizard. Its mutex serialises the prompts: a | ||
| 21 | +// flow is not safe for two answers at once. | ||
| 22 | +type session struct { | ||
| 23 | + mu sync.Mutex | ||
| 24 | + cwd string | ||
| 25 | + flow *agentinit.Flow | ||
| 26 | + | ||
| 27 | + 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. | ||
| 35 | + written string | ||
| 36 | + | ||
| 37 | + cancelMu sync.Mutex | ||
| 38 | + cancel context.CancelFunc | ||
| 39 | +} | ||
| 40 | + | ||
| 41 | +func (s *session) setCancel(c context.CancelFunc) { | ||
| 42 | + s.cancelMu.Lock() | ||
| 43 | + s.cancel = c | ||
| 44 | + s.cancelMu.Unlock() | ||
| 45 | +} | ||
| 46 | + | ||
| 47 | +func (s *session) cancelTurn() { | ||
| 48 | + s.cancelMu.Lock() | ||
| 49 | + c := s.cancel | ||
| 50 | + s.cancelMu.Unlock() | ||
| 51 | + if c != nil { | ||
| 52 | + c() | ||
| 53 | + } | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +// commands are the slash commands this mode handles, in the order shown. | ||
| 57 | +var commands = []struct{ name, description, hint string }{ | ||
| 58 | + {"start", "Start creating an agent (any message does too)", ""}, | ||
| 59 | + {"preview", "Show the agent.yaml as it would be written now", ""}, | ||
| 60 | + {"back", "Take back the last answer", ""}, | ||
| 61 | + {"write", "Take the default for every question left, then write the file", ""}, | ||
| 62 | + {"restart", "Forget every answer and start again", ""}, | ||
| 63 | + {"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", ""}, | ||
| 65 | + {"sbxinit", "Write a sbxenv.yaml to run the agent in a Docker Sandbox (mm sbxinit)", "[--name <name>] [--workspace <dir>] [--agent <file>] [--overwrite]"}, | ||
| 66 | + {"help", "How this wizard works", ""}, | ||
| 67 | +} | ||
| 68 | + | ||
| 69 | +func availableCommands() []sdk.AvailableCommand { | ||
| 70 | + out := make([]sdk.AvailableCommand, 0, len(commands)) | ||
| 71 | + for _, c := range commands { | ||
| 72 | + cmd := sdk.AvailableCommand{Name: c.name, Description: c.description} | ||
| 73 | + if c.hint != "" { | ||
| 74 | + cmd.Input = &sdk.AvailableCommandInput{Unstructured: &sdk.UnstructuredCommandInput{Hint: c.hint}} | ||
| 75 | + } | ||
| 76 | + out = append(out, cmd) | ||
| 77 | + } | ||
| 78 | + return out | ||
| 79 | +} | ||
| 80 | + | ||
| 81 | +// turn is one prompt being answered. | ||
| 82 | +type turn struct { | ||
| 83 | + ag *agent | ||
| 84 | + s *session | ||
| 85 | + sid sdk.SessionId | ||
| 86 | + ctx context.Context | ||
| 87 | +} | ||
| 88 | + | ||
| 89 | +func (t *turn) say(markdown string) { t.ag.say(t.ctx, t.sid, markdown) } | ||
| 90 | + | ||
| 91 | +// handle answers one prompt. | ||
| 92 | +func (t *turn) handle(text string) { | ||
| 93 | + if name, args, ok := slashCommand(text); ok { | ||
| 94 | + t.command(name, args) | ||
| 95 | + return | ||
| 96 | + } | ||
| 97 | + s := t.s | ||
| 98 | + switch { | ||
| 99 | + case s.written != "": | ||
| 100 | + t.say(fmt.Sprintf("`%s` is written. `/restart` creates another one.", s.written)) | ||
| 101 | + return | ||
| 102 | + case !s.started: | ||
| 103 | + t.start() | ||
| 104 | + return | ||
| 105 | + case s.asked != "": | ||
| 106 | + if !t.answer(text) { | ||
| 107 | + return | ||
| 108 | + } | ||
| 109 | + } | ||
| 110 | + t.next() | ||
| 111 | +} | ||
| 112 | + | ||
| 113 | +// slashCommand recognises one of this mode's commands, and the words | ||
| 114 | +// typed after it. | ||
| 115 | +func slashCommand(text string) (string, []string, bool) { | ||
| 116 | + text = strings.TrimSpace(text) | ||
| 117 | + if !strings.HasPrefix(text, "/") { | ||
| 118 | + return "", nil, false | ||
| 119 | + } | ||
| 120 | + words := strings.Fields(text[1:]) | ||
| 121 | + if len(words) == 0 { | ||
| 122 | + return "", nil, false | ||
| 123 | + } | ||
| 124 | + for _, c := range commands { | ||
| 125 | + if c.name == words[0] { | ||
| 126 | + return c.name, words[1:], true | ||
| 127 | + } | ||
| 128 | + } | ||
| 129 | + return "", nil, false | ||
| 130 | +} | ||
| 131 | + | ||
| 132 | +func (t *turn) command(name string, args []string) { | ||
| 133 | + s := t.s | ||
| 134 | + switch name { | ||
| 135 | + case "sbxinit": | ||
| 136 | + t.sbxinit(args) | ||
| 137 | + case "dockerinit": | ||
| 138 | + t.dockerinit(args) | ||
| 139 | + case "zedconfig": | ||
| 140 | + t.say(zedConfig(s.cwd)) | ||
| 141 | + case "start": | ||
| 142 | + t.start() | ||
| 143 | + case "help": | ||
| 144 | + t.say(help()) | ||
| 145 | + case "preview": | ||
| 146 | + t.say("```yaml\n" + s.flow.Preview("", "") + "```") | ||
| 147 | + case "restart": | ||
| 148 | + fresh, err := t.ag.newSession(s.cwd) | ||
| 149 | + if err != nil { | ||
| 150 | + t.say("⚠️ " + err.Error()) | ||
| 151 | + return | ||
| 152 | + } | ||
| 153 | + s.flow, s.asked, s.answered, s.written, s.started = fresh.flow, "", nil, "", true | ||
| 154 | + t.say("Starting again.") | ||
| 155 | + t.next() | ||
| 156 | + case "back": | ||
| 157 | + t.back() | ||
| 158 | + case "write": | ||
| 159 | + if s.written != "" { | ||
| 160 | + t.say(fmt.Sprintf("`%s` is already written.", s.written)) | ||
| 161 | + return | ||
| 162 | + } | ||
| 163 | + s.started = true | ||
| 164 | + t.takeDefaults() | ||
| 165 | + t.next() | ||
| 166 | + } | ||
| 167 | +} | ||
| 168 | + | ||
| 169 | +// scaffolder is a command that writes files in the session's project the | ||
| 170 | +// way mm sbxinit or mm dockerinit does: in is that command run for a | ||
| 171 | +// directory (sbxinit.In, dockerinit.In). | ||
| 172 | +type scaffolder struct { | ||
| 173 | + name string // the slash command | ||
| 174 | + files []string // what it writes, relative to the project | ||
| 175 | + run func(dir string, args []string, out io.Writer, overwrite bool) int | ||
| 176 | + after string // said when it wrote something | ||
| 177 | +} | ||
| 178 | + | ||
| 179 | +// sbxinit is mm sbxinit for the session's project: sbxenv.yaml in its cwd. | ||
| 180 | +func (t *turn) sbxinit(args []string) { | ||
| 181 | + t.scaffold(scaffolder{name: "sbxinit", files: []string{"sbxenv.yaml"}, run: sbxinitIn, after: "Run the `sbx` commands from `%s`."}, args) | ||
| 182 | +} | ||
| 183 | + | ||
| 184 | +// dockerinit is mm dockerinit for the session's project: Dockerfile and | ||
| 185 | +// compose.yaml in its cwd (ticket 212). | ||
| 186 | +func (t *turn) dockerinit(args []string) { | ||
| 187 | + t.scaffold(scaffolder{name: "dockerinit", files: []string{"Dockerfile", "compose.yaml"}, run: dockerinitIn, after: "Run the `docker` commands from `%s`."}, args) | ||
| 188 | +} | ||
| 189 | + | ||
| 190 | +// scaffold runs c for the session's project. It needs no answer from the | ||
| 191 | +// wizard and works at any time — after the agent file is written, it names | ||
| 192 | +// what it writes after it. Nobody can answer "Overwrite?" here: existing | ||
| 193 | +// files are kept unless --overwrite is given. | ||
| 194 | +func (t *turn) scaffold(c scaffolder, args []string) { | ||
| 195 | + cwd := t.s.cwd | ||
| 196 | + overwrite := false | ||
| 197 | + var flags []string | ||
| 198 | + for _, a := range args { | ||
| 199 | + if a == "--overwrite" || a == "-overwrite" { | ||
| 200 | + overwrite = true | ||
| 201 | + continue | ||
| 202 | + } | ||
| 203 | + flags = append(flags, a) | ||
| 204 | + } | ||
| 205 | + if !overwrite { | ||
| 206 | + var existing []string | ||
| 207 | + for _, f := range c.files { | ||
| 208 | + if _, err := os.Stat(filepath.Join(cwd, f)); err == nil { | ||
| 209 | + existing = append(existing, "`"+f+"`") | ||
| 210 | + } | ||
| 211 | + } | ||
| 212 | + if len(existing) > 0 { | ||
| 213 | + t.say(fmt.Sprintf("%s already in `%s`: `/%s --overwrite` replaces it.", strings.Join(existing, " and "), cwd, c.name)) | ||
| 214 | + return | ||
| 215 | + } | ||
| 216 | + } | ||
| 217 | + var report bytes.Buffer | ||
| 218 | + code := c.run(cwd, flags, &report, overwrite) | ||
| 219 | + msg := "```\n" + strings.TrimRight(report.String(), "\n") + "\n```" | ||
| 220 | + if code == 0 && strings.Contains(report.String(), "✅ wrote") { | ||
| 221 | + msg += "\n\n" + fmt.Sprintf(c.after, cwd) | ||
| 222 | + } | ||
| 223 | + t.say(msg) | ||
| 224 | +} | ||
| 225 | + | ||
| 226 | +// dockerinitIn is dockerinit.In, beside sbxinitIn. | ||
| 227 | +var dockerinitIn = dockerinit.In | ||
| 228 | + | ||
| 229 | +// sbxinitIn is sbxinit.In; the tests replace it to read no release. | ||
| 230 | +var sbxinitIn = sbxinit.In | ||
| 231 | + | ||
| 232 | +// start begins the wizard, or — already begun — asks the current question | ||
| 233 | +// again. | ||
| 234 | +func (t *turn) start() { | ||
| 235 | + s := t.s | ||
| 236 | + if s.written != "" { | ||
| 237 | + t.say(fmt.Sprintf("`%s` is written. `/restart` creates another one.", s.written)) | ||
| 238 | + return | ||
| 239 | + } | ||
| 240 | + if !s.started { | ||
| 241 | + s.started = true | ||
| 242 | + t.say(intro(s.cwd, t.ag.elicitForm)) | ||
| 243 | + } | ||
| 244 | + t.next() | ||
| 245 | +} | ||
| 246 | + | ||
| 247 | +// welcome is sent once the session exists, before any prompt: a client | ||
| 248 | +// that shows it tells the user how to begin. | ||
| 249 | +func welcome(cwd string) string { | ||
| 250 | + return fmt.Sprintf("**mm init** — this creates an agent.yaml in `%s`. Send `/start` (or any message) to begin.", cwd) | ||
| 251 | +} | ||
| 252 | + | ||
| 253 | +// back takes back the last answer given here and asks it again. | ||
| 254 | +func (t *turn) back() { | ||
| 255 | + s := t.s | ||
| 256 | + if len(s.answered) == 0 { | ||
| 257 | + t.say("Nothing to take back.") | ||
| 258 | + return | ||
| 259 | + } | ||
| 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 = "" | ||
| 264 | + t.next() | ||
| 265 | +} | ||
| 266 | + | ||
| 267 | +// 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. | ||
| 269 | +func (t *turn) takeDefaults() { | ||
| 270 | + s := t.s | ||
| 271 | + for { | ||
| 272 | + pending := s.flow.Pending() | ||
| 273 | + if len(pending) == 0 { | ||
| 274 | + return | ||
| 275 | + } | ||
| 276 | + st := pending[0] | ||
| 277 | + if st.Key == "overwrite" || st.Default == "" { | ||
| 278 | + return | ||
| 279 | + } | ||
| 280 | + if err := s.flow.Set(st.Key, st.Default); err != nil { | ||
| 281 | + t.say("⚠️ " + err.Error()) | ||
| 282 | + return | ||
| 283 | + } | ||
| 284 | + s.answered = append(s.answered, st.Key) | ||
| 285 | + } | ||
| 286 | +} | ||
| 287 | + | ||
| 288 | +// next asks what is still to ask, or writes the file when nothing is. | ||
| 289 | +func (t *turn) next() { | ||
| 290 | + s := t.s | ||
| 291 | + s.asked = "" | ||
| 292 | + if t.ag.elicitForm { | ||
| 293 | + t.askByForms() | ||
| 294 | + return | ||
| 295 | + } | ||
| 296 | + pending := s.flow.Pending() | ||
| 297 | + if len(pending) == 0 { | ||
| 298 | + t.write() | ||
| 299 | + return | ||
| 300 | + } | ||
| 301 | + st := pending[0] | ||
| 302 | + s.asked = st.Key | ||
| 303 | + t.say(question(st, t.position(st.Key))) | ||
| 304 | +} | ||
| 305 | + | ||
| 306 | +// position is "n of N", the question's place among all of them. | ||
| 307 | +func (t *turn) position(key string) string { | ||
| 308 | + steps := t.s.flow.Steps() | ||
| 309 | + for i, st := range steps { | ||
| 310 | + if st.Key == key { | ||
| 311 | + return fmt.Sprintf("%d of %d", i+1, len(steps)) | ||
| 312 | + } | ||
| 313 | + } | ||
| 314 | + return "" | ||
| 315 | +} | ||
| 316 | + | ||
| 317 | +// answer reads the prompt as the answer to the question asked; false when | ||
| 318 | +// it does not answer it (the question is shown again). | ||
| 319 | +func (t *turn) answer(text string) bool { | ||
| 320 | + s := t.s | ||
| 321 | + st := s.flow.Step(s.asked) | ||
| 322 | + value, err := parseAnswer(st, text) | ||
| 323 | + if err == nil { | ||
| 324 | + err = s.flow.Set(st.Key, value) | ||
| 325 | + } | ||
| 326 | + if err != nil { | ||
| 327 | + t.say("⚠️ " + err.Error() + "\n\n" + question(st, t.position(st.Key))) | ||
| 328 | + return false | ||
| 329 | + } | ||
| 330 | + s.answered = append(s.answered, st.Key) | ||
| 331 | + return true | ||
| 332 | +} | ||
| 333 | + | ||
| 334 | +// write writes the file and says how to use it. | ||
| 335 | +func (t *turn) write() { | ||
| 336 | + s := t.s | ||
| 337 | + var report bytes.Buffer | ||
| 338 | + output, ok := agentinit.WriteFile(s.flow, &report) | ||
| 339 | + if output == "-" { | ||
| 340 | + t.say("```yaml\n" + report.String() + "```") | ||
| 341 | + return | ||
| 342 | + } | ||
| 343 | + if !ok { | ||
| 344 | + t.say(report.String() + "\nNothing more was done: `/back` changes an answer, `/restart` starts again.") | ||
| 345 | + return | ||
| 346 | + } | ||
| 347 | + 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.") | ||
| 349 | +} | ||
| 350 | + | ||
| 351 | +func intro(cwd string, forms bool) string { | ||
| 352 | + how := "I ask the questions one by one here: answer each with its number or its value, `.` for the default." | ||
| 353 | + if forms { | ||
| 354 | + how = "Your client shows forms: I ask the questions in a few of them." | ||
| 355 | + } | ||
| 356 | + return fmt.Sprintf("**mm init** — let's create an agent in `%s`.\n\n%s `/preview` shows the file as it stands, `/back` takes back an answer, `/write` takes every default left and writes it.", cwd, how) | ||
| 357 | +} | ||
| 358 | + | ||
| 359 | +func help() string { | ||
| 360 | + var b strings.Builder | ||
| 361 | + b.WriteString("This is `mm init` over ACP: it writes an agent.yaml (or `.mm/agent.yaml`) in this project, then says how to start the agent.\n\n") | ||
| 362 | + for _, c := range commands { | ||
| 363 | + fmt.Fprintf(&b, "- `/%s` — %s\n", c.name, c.description) | ||
| 364 | + } | ||
| 365 | + return b.String() | ||
| 366 | +} | ||
| 367 | + | ||
| 368 | +// howToUse is the closing message: the command line every client can use, | ||
| 369 | +// plus a ready snippet for a client mm knows. The snippet is a convenience; | ||
| 370 | +// nothing else depends on the client's name. | ||
| 371 | +func howToUse(path, client string) string { | ||
| 372 | + var b strings.Builder | ||
| 373 | + fmt.Fprintf(&b, "Start the agent from your ACP client with the command `mm acp %s`.\n", path) | ||
| 374 | + if strings.Contains(strings.ToLower(client), "zed") { | ||
| 375 | + fmt.Fprintf(&b, "\nIn Zed's settings.json:\n\n```json\n\"agent_servers\": {\n \"%s\": {\n \"type\": \"custom\",\n \"command\": \"mm\",\n \"args\": [\"acp\", %q]\n }\n}\n```\n", entryName(path), path) | ||
| 376 | + } | ||
| 377 | + b.WriteString("\n`/zedconfig` shows every way to start it from Zed — here, in a container, in a Docker Sandbox.\n") | ||
| 378 | + return b.String() | ||
| 379 | +} | ||
added
internal/acpcreate/zedconfig.go +109 -0 | new file mode 100644 | ||
| @@ -0,0 +1,109 @@ | ||
| 1 | +package acpcreate | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "encoding/json" | |
| 5 | + "fmt" | |
| 6 | + "os" | |
| 7 | + "path/filepath" | |
| 8 | + "sort" | |
| 9 | + "strings" | |
| 10 | + | |
| 11 | + "mm/internal/scaffold" | |
| 12 | + | |
| 13 | + "gopkg.in/yaml.v3" | |
| 14 | +) | |
| 15 | + | |
| 16 | +// /zedconfig: the Zed agent_servers entries that start the project's agent, | |
| 17 | +// from what the project holds — the agent file always (mm on this machine), | |
| 18 | +// compose.yaml (mm dockerinit: in its container), sbxenv.yaml (mm sbxinit: | |
| 19 | +// in its Docker Sandbox). The commands are those of the how-to guides: | |
| 20 | +// docker exec and sbx exec name the agent file by its absolute path, which | |
| 21 | +// is the same inside, where the project is mounted at the same path. | |
| 22 | + | |
| 23 | +// server is one agent_servers entry. | |
| 24 | +type server struct { | |
| 25 | + Type string `json:"type"` | |
| 26 | + Command string `json:"command"` | |
| 27 | + Args []string `json:"args"` | |
| 28 | +} | |
| 29 | + | |
| 30 | +// zedConfig is the /zedconfig answer for the project in cwd. | |
| 31 | +func zedConfig(cwd string) string { | |
| 32 | + agent := scaffold.FindAgentFile("agent.yaml", cwd, false, func(p string) string { return filepath.Join(cwd, p) }) | |
| 33 | + if _, err := os.Stat(agent); err != nil { | |
| 34 | + return fmt.Sprintf("No agent file in `%s` (neither `agent.yaml` nor `.mm/agent.yaml`): `/start` creates one.", cwd) | |
| 35 | + } | |
| 36 | + name := entryName(agent) | |
| 37 | + servers := map[string]server{name: {Type: "custom", Command: "mm", Args: []string{"acp", agent}}} | |
| 38 | + notes := []string{fmt.Sprintf("- `%s` — mm on this machine.", name)} | |
| 39 | + | |
| 40 | + if container, ok := composeContainer(filepath.Join(cwd, "compose.yaml")); ok { | |
| 41 | + key := name + " (container)" | |
| 42 | + servers[key] = server{Type: "custom", Command: "docker", Args: []string{"exec", "-i", container, "mm", "-acp", "-config", agent}} | |
| 43 | + notes = append(notes, fmt.Sprintf("- `%s` — in the container `%s` (compose.yaml): start it first, from `%s`: `docker compose up -d --wait`.", key, container, cwd)) | |
| 44 | + } | |
| 45 | + if sandbox, ok := sbxName(filepath.Join(cwd, "sbxenv.yaml")); ok { | |
| 46 | + key := name + " (sandbox)" | |
| 47 | + servers[key] = server{Type: "custom", Command: "sbx", Args: []string{"exec", "-i", sandbox, "mm", "-acp", "-config", agent}} | |
| 48 | + notes = append(notes, fmt.Sprintf("- `%s` — in the Docker Sandbox `%s` (sbxenv.yaml): create it first, from `%s`: `sbx env create --force`.", key, sandbox, cwd)) | |
| 49 | + } | |
| 50 | + | |
| 51 | + data, _ := json.MarshalIndent(map[string]any{"agent_servers": servers}, "", " ") | |
| 52 | + var b strings.Builder | |
| 53 | + fmt.Fprintf(&b, "Zed `settings.json` for the agent `%s` — keep the entries you want:\n\n```json\n%s\n```\n\n%s\n", agent, data, strings.Join(notes, "\n")) | |
| 54 | + if len(servers) == 1 { | |
| 55 | + b.WriteString("\n`/dockerinit` adds a container, `/sbxinit` a Docker Sandbox: `/zedconfig` then shows their entries too.\n") | |
| 56 | + } | |
| 57 | + return b.String() | |
| 58 | +} | |
| 59 | + | |
| 60 | +// entryName names the entries after the project: the agent file's folder, | |
| 61 | +// or the one holding .mm/ — as the closing message does. | |
| 62 | +func entryName(agent string) string { | |
| 63 | + dir := filepath.Dir(agent) | |
| 64 | + if filepath.Base(dir) == ".mm" { | |
| 65 | + dir = filepath.Dir(dir) | |
| 66 | + } | |
| 67 | + return "mm-" + filepath.Base(dir) | |
| 68 | +} | |
| 69 | + | |
| 70 | +// composeContainer reads the container name of compose.yaml's first service | |
| 71 | +// (mm dockerinit writes one): container_name, else the service's name. | |
| 72 | +func composeContainer(path string) (string, bool) { | |
| 73 | + data, err := os.ReadFile(path) | |
| 74 | + if err != nil { | |
| 75 | + return "", false | |
| 76 | + } | |
| 77 | + var file struct { | |
| 78 | + Services map[string]struct { | |
| 79 | + ContainerName string `yaml:"container_name"` | |
| 80 | + } `yaml:"services"` | |
| 81 | + } | |
| 82 | + if yaml.Unmarshal(data, &file) != nil || len(file.Services) == 0 { | |
| 83 | + return "", false | |
| 84 | + } | |
| 85 | + names := make([]string, 0, len(file.Services)) | |
| 86 | + for n := range file.Services { | |
| 87 | + names = append(names, n) | |
| 88 | + } | |
| 89 | + sort.Strings(names) | |
| 90 | + if c := file.Services[names[0]].ContainerName; c != "" { | |
| 91 | + return c, true | |
| 92 | + } | |
| 93 | + return names[0], true | |
| 94 | +} | |
| 95 | + | |
| 96 | +// sbxName reads the sandbox's name from sbxenv.yaml. | |
| 97 | +func sbxName(path string) (string, bool) { | |
| 98 | + data, err := os.ReadFile(path) | |
| 99 | + if err != nil { | |
| 100 | + return "", false | |
| 101 | + } | |
| 102 | + var file struct { | |
| 103 | + Name string `yaml:"name"` | |
| 104 | + } | |
| 105 | + if yaml.Unmarshal(data, &file) != nil || file.Name == "" { | |
| 106 | + return "", false | |
| 107 | + } | |
| 108 | + return file.Name, true | |
| 109 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,109 @@ | |||
| 1 | +package acpcreate | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "encoding/json" | ||
| 5 | + "fmt" | ||
| 6 | + "os" | ||
| 7 | + "path/filepath" | ||
| 8 | + "sort" | ||
| 9 | + "strings" | ||
| 10 | + | ||
| 11 | + "mm/internal/scaffold" | ||
| 12 | + | ||
| 13 | + "gopkg.in/yaml.v3" | ||
| 14 | +) | ||
| 15 | + | ||
| 16 | +// /zedconfig: the Zed agent_servers entries that start the project's agent, | ||
| 17 | +// from what the project holds — the agent file always (mm on this machine), | ||
| 18 | +// compose.yaml (mm dockerinit: in its container), sbxenv.yaml (mm sbxinit: | ||
| 19 | +// in its Docker Sandbox). The commands are those of the how-to guides: | ||
| 20 | +// docker exec and sbx exec name the agent file by its absolute path, which | ||
| 21 | +// is the same inside, where the project is mounted at the same path. | ||
| 22 | + | ||
| 23 | +// server is one agent_servers entry. | ||
| 24 | +type server struct { | ||
| 25 | + Type string `json:"type"` | ||
| 26 | + Command string `json:"command"` | ||
| 27 | + Args []string `json:"args"` | ||
| 28 | +} | ||
| 29 | + | ||
| 30 | +// zedConfig is the /zedconfig answer for the project in cwd. | ||
| 31 | +func zedConfig(cwd string) string { | ||
| 32 | + agent := scaffold.FindAgentFile("agent.yaml", cwd, false, func(p string) string { return filepath.Join(cwd, p) }) | ||
| 33 | + if _, err := os.Stat(agent); err != nil { | ||
| 34 | + return fmt.Sprintf("No agent file in `%s` (neither `agent.yaml` nor `.mm/agent.yaml`): `/start` creates one.", cwd) | ||
| 35 | + } | ||
| 36 | + name := entryName(agent) | ||
| 37 | + servers := map[string]server{name: {Type: "custom", Command: "mm", Args: []string{"acp", agent}}} | ||
| 38 | + notes := []string{fmt.Sprintf("- `%s` — mm on this machine.", name)} | ||
| 39 | + | ||
| 40 | + if container, ok := composeContainer(filepath.Join(cwd, "compose.yaml")); ok { | ||
| 41 | + key := name + " (container)" | ||
| 42 | + servers[key] = server{Type: "custom", Command: "docker", Args: []string{"exec", "-i", container, "mm", "-acp", "-config", agent}} | ||
| 43 | + notes = append(notes, fmt.Sprintf("- `%s` — in the container `%s` (compose.yaml): start it first, from `%s`: `docker compose up -d --wait`.", key, container, cwd)) | ||
| 44 | + } | ||
| 45 | + if sandbox, ok := sbxName(filepath.Join(cwd, "sbxenv.yaml")); ok { | ||
| 46 | + key := name + " (sandbox)" | ||
| 47 | + servers[key] = server{Type: "custom", Command: "sbx", Args: []string{"exec", "-i", sandbox, "mm", "-acp", "-config", agent}} | ||
| 48 | + notes = append(notes, fmt.Sprintf("- `%s` — in the Docker Sandbox `%s` (sbxenv.yaml): create it first, from `%s`: `sbx env create --force`.", key, sandbox, cwd)) | ||
| 49 | + } | ||
| 50 | + | ||
| 51 | + data, _ := json.MarshalIndent(map[string]any{"agent_servers": servers}, "", " ") | ||
| 52 | + var b strings.Builder | ||
| 53 | + fmt.Fprintf(&b, "Zed `settings.json` for the agent `%s` — keep the entries you want:\n\n```json\n%s\n```\n\n%s\n", agent, data, strings.Join(notes, "\n")) | ||
| 54 | + if len(servers) == 1 { | ||
| 55 | + b.WriteString("\n`/dockerinit` adds a container, `/sbxinit` a Docker Sandbox: `/zedconfig` then shows their entries too.\n") | ||
| 56 | + } | ||
| 57 | + return b.String() | ||
| 58 | +} | ||
| 59 | + | ||
| 60 | +// entryName names the entries after the project: the agent file's folder, | ||
| 61 | +// or the one holding .mm/ — as the closing message does. | ||
| 62 | +func entryName(agent string) string { | ||
| 63 | + dir := filepath.Dir(agent) | ||
| 64 | + if filepath.Base(dir) == ".mm" { | ||
| 65 | + dir = filepath.Dir(dir) | ||
| 66 | + } | ||
| 67 | + return "mm-" + filepath.Base(dir) | ||
| 68 | +} | ||
| 69 | + | ||
| 70 | +// composeContainer reads the container name of compose.yaml's first service | ||
| 71 | +// (mm dockerinit writes one): container_name, else the service's name. | ||
| 72 | +func composeContainer(path string) (string, bool) { | ||
| 73 | + data, err := os.ReadFile(path) | ||
| 74 | + if err != nil { | ||
| 75 | + return "", false | ||
| 76 | + } | ||
| 77 | + var file struct { | ||
| 78 | + Services map[string]struct { | ||
| 79 | + ContainerName string `yaml:"container_name"` | ||
| 80 | + } `yaml:"services"` | ||
| 81 | + } | ||
| 82 | + if yaml.Unmarshal(data, &file) != nil || len(file.Services) == 0 { | ||
| 83 | + return "", false | ||
| 84 | + } | ||
| 85 | + names := make([]string, 0, len(file.Services)) | ||
| 86 | + for n := range file.Services { | ||
| 87 | + names = append(names, n) | ||
| 88 | + } | ||
| 89 | + sort.Strings(names) | ||
| 90 | + if c := file.Services[names[0]].ContainerName; c != "" { | ||
| 91 | + return c, true | ||
| 92 | + } | ||
| 93 | + return names[0], true | ||
| 94 | +} | ||
| 95 | + | ||
| 96 | +// sbxName reads the sandbox's name from sbxenv.yaml. | ||
| 97 | +func sbxName(path string) (string, bool) { | ||
| 98 | + data, err := os.ReadFile(path) | ||
| 99 | + if err != nil { | ||
| 100 | + return "", false | ||
| 101 | + } | ||
| 102 | + var file struct { | ||
| 103 | + Name string `yaml:"name"` | ||
| 104 | + } | ||
| 105 | + if yaml.Unmarshal(data, &file) != nil || file.Name == "" { | ||
| 106 | + return "", false | ||
| 107 | + } | ||
| 108 | + return file.Name, true | ||
| 109 | +} | ||
added
internal/acpwire/acpwire.go +122 -0 | new file mode 100644 | ||
| @@ -0,0 +1,122 @@ | ||
| 1 | +// Package acpwire holds the two things mm's ACP agents (internal/acp, the | |
| 2 | +// agent front end, and internal/acpcreate, the agent.yaml wizard) need on | |
| 3 | +// the bytes they write, which acp-go-sdk v0.13.5 does not offer: a hook run | |
| 4 | +// right after a session/new response, and the elicitation scope moved from | |
| 5 | +// `_meta` to where the specification puts it. | |
| 6 | +package acpwire | |
| 7 | + | |
| 8 | +import ( | |
| 9 | + "bytes" | |
| 10 | + "encoding/json" | |
| 11 | + "io" | |
| 12 | + "sync" | |
| 13 | + | |
| 14 | + "mm/internal/elicit" | |
| 15 | + | |
| 16 | + sdk "github.com/coder/acp-go-sdk" | |
| 17 | +) | |
| 18 | + | |
| 19 | +// NewWriter wraps w, the stream the SDK writes JSON-RPC lines to. | |
| 20 | +func NewWriter(w io.Writer) *Writer { | |
| 21 | + return &Writer{Writer: w, hooks: map[sdk.SessionId]func(){}} | |
| 22 | +} | |
| 23 | + | |
| 24 | +// Writer is the io.Writer handed to the SDK: the real output plus one | |
| 25 | +// hook per session, run once the session/new response carrying that session | |
| 26 | +// id has been written. It exists for available_commands_update. The spec says | |
| 27 | +// to announce the commands right after the session is created, and the client | |
| 28 | +// (Zed, verified 2026-09-15) drops any session/update for a session whose | |
| 29 | +// session/new response it has not received yet — "Available commands: none". | |
| 30 | +// But the SDK writes the response only when NewSession returns, after anything | |
| 31 | +// the method itself sent, and offers no "after the response" callback. Watching | |
| 32 | +// the bytes is the one place where the order is certain. | |
| 33 | +type Writer struct { | |
| 34 | + io.Writer | |
| 35 | + mu sync.Mutex | |
| 36 | + hooks map[sdk.SessionId]func() | |
| 37 | +} | |
| 38 | + | |
| 39 | +// AfterResponse registers hook to run — once, in its own goroutine — after the | |
| 40 | +// response that carries sid has been written. | |
| 41 | +func (w *Writer) AfterResponse(sid sdk.SessionId, hook func()) { | |
| 42 | + w.mu.Lock() | |
| 43 | + defer w.mu.Unlock() | |
| 44 | + w.hooks[sid] = hook | |
| 45 | +} | |
| 46 | + | |
| 47 | +// Write forwards to the real output, then fires the hook of the session whose | |
| 48 | +// response this line is, if any. The hook runs in a goroutine: the SDK holds | |
| 49 | +// its write lock here, and the hook is about to write a notification. | |
| 50 | +func (w *Writer) Write(p []byte) (int, error) { | |
| 51 | + // An elicitation's scope rides in _meta until the SDK can carry it | |
| 52 | + // (internal/acp/elicit.go); the caller is told the length it gave. | |
| 53 | + if _, err := w.Writer.Write(LiftElicitationScope(p)); err != nil { | |
| 54 | + return 0, err | |
| 55 | + } | |
| 56 | + n := len(p) | |
| 57 | + if sid, ok := ResponseSessionID(p); ok { | |
| 58 | + w.mu.Lock() | |
| 59 | + hook := w.hooks[sid] | |
| 60 | + delete(w.hooks, sid) | |
| 61 | + w.mu.Unlock() | |
| 62 | + if hook != nil { | |
| 63 | + go hook() | |
| 64 | + } | |
| 65 | + } | |
| 66 | + return n, nil | |
| 67 | +} | |
| 68 | + | |
| 69 | +// ResponseSessionID recognises a JSON-RPC response whose result carries a | |
| 70 | +// sessionId — session/new (and session/load, which this agent does not | |
| 71 | +// offer). Notifications have no id and prompt responses no sessionId, so | |
| 72 | +// neither matches. | |
| 73 | +func ResponseSessionID(line []byte) (sdk.SessionId, bool) { | |
| 74 | + var m struct { | |
| 75 | + ID json.RawMessage `json:"id"` | |
| 76 | + Result struct { | |
| 77 | + SessionId sdk.SessionId `json:"sessionId"` | |
| 78 | + } `json:"result"` | |
| 79 | + } | |
| 80 | + if json.Unmarshal(line, &m) != nil || len(m.ID) == 0 || m.Result.SessionId == "" { | |
| 81 | + return "", false | |
| 82 | + } | |
| 83 | + return m.Result.SessionId, true | |
| 84 | +} | |
| 85 | + | |
| 86 | +// --- the scope the SDK cannot carry ------------------------------------------------ | |
| 87 | + | |
| 88 | +// LiftElicitationScope rewrites one outgoing JSON-RPC line. ACP ties a | |
| 89 | +// session-scoped elicitation to its session (and tool call) with `sessionId` | |
| 90 | +// and `toolCallId` at the top level of the params, but acp-go-sdk v0.13.5's | |
| 91 | +// UnstableCreateElicitationForm has no such fields — only `_meta`. So | |
| 92 | +// the agents' form requests park the scope in `_meta` (elicit.ScopeMetaKey), and this | |
| 93 | +// copies it where the specification puts it as the line is written out. The | |
| 94 | +// `_meta` copy stays: an mm client relaying the question reads its tool call | |
| 95 | +// there, the SDK dropping the top-level fields on its side too. Any other | |
| 96 | +// line comes back untouched. Remove both when the SDK carries the fields. | |
| 97 | +func LiftElicitationScope(line []byte) []byte { | |
| 98 | + if !bytes.Contains(line, []byte(elicit.ScopeMetaKey)) { | |
| 99 | + return line | |
| 100 | + } | |
| 101 | + var msg map[string]any | |
| 102 | + if json.Unmarshal(line, &msg) != nil || msg["method"] != sdk.ClientMethodElicitationCreate { | |
| 103 | + return line | |
| 104 | + } | |
| 105 | + params, _ := msg["params"].(map[string]any) | |
| 106 | + meta, _ := params["_meta"].(map[string]any) | |
| 107 | + scope, ok := meta[elicit.ScopeMetaKey].(map[string]any) | |
| 108 | + if !ok { | |
| 109 | + return line | |
| 110 | + } | |
| 111 | + for key, value := range scope { | |
| 112 | + params[key] = value | |
| 113 | + } | |
| 114 | + out, err := json.Marshal(msg) | |
| 115 | + if err != nil { | |
| 116 | + return line | |
| 117 | + } | |
| 118 | + if bytes.HasSuffix(line, []byte("\n")) { | |
| 119 | + out = append(out, '\n') | |
| 120 | + } | |
| 121 | + return out | |
| 122 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,122 @@ | |||
| 1 | +// Package acpwire holds the two things mm's ACP agents (internal/acp, the | ||
| 2 | +// agent front end, and internal/acpcreate, the agent.yaml wizard) need on | ||
| 3 | +// the bytes they write, which acp-go-sdk v0.13.5 does not offer: a hook run | ||
| 4 | +// right after a session/new response, and the elicitation scope moved from | ||
| 5 | +// `_meta` to where the specification puts it. | ||
| 6 | +package acpwire | ||
| 7 | + | ||
| 8 | +import ( | ||
| 9 | + "bytes" | ||
| 10 | + "encoding/json" | ||
| 11 | + "io" | ||
| 12 | + "sync" | ||
| 13 | + | ||
| 14 | + "mm/internal/elicit" | ||
| 15 | + | ||
| 16 | + sdk "github.com/coder/acp-go-sdk" | ||
| 17 | +) | ||
| 18 | + | ||
| 19 | +// NewWriter wraps w, the stream the SDK writes JSON-RPC lines to. | ||
| 20 | +func NewWriter(w io.Writer) *Writer { | ||
| 21 | + return &Writer{Writer: w, hooks: map[sdk.SessionId]func(){}} | ||
| 22 | +} | ||
| 23 | + | ||
| 24 | +// Writer is the io.Writer handed to the SDK: the real output plus one | ||
| 25 | +// hook per session, run once the session/new response carrying that session | ||
| 26 | +// id has been written. It exists for available_commands_update. The spec says | ||
| 27 | +// to announce the commands right after the session is created, and the client | ||
| 28 | +// (Zed, verified 2026-09-15) drops any session/update for a session whose | ||
| 29 | +// session/new response it has not received yet — "Available commands: none". | ||
| 30 | +// But the SDK writes the response only when NewSession returns, after anything | ||
| 31 | +// the method itself sent, and offers no "after the response" callback. Watching | ||
| 32 | +// the bytes is the one place where the order is certain. | ||
| 33 | +type Writer struct { | ||
| 34 | + io.Writer | ||
| 35 | + mu sync.Mutex | ||
| 36 | + hooks map[sdk.SessionId]func() | ||
| 37 | +} | ||
| 38 | + | ||
| 39 | +// AfterResponse registers hook to run — once, in its own goroutine — after the | ||
| 40 | +// response that carries sid has been written. | ||
| 41 | +func (w *Writer) AfterResponse(sid sdk.SessionId, hook func()) { | ||
| 42 | + w.mu.Lock() | ||
| 43 | + defer w.mu.Unlock() | ||
| 44 | + w.hooks[sid] = hook | ||
| 45 | +} | ||
| 46 | + | ||
| 47 | +// Write forwards to the real output, then fires the hook of the session whose | ||
| 48 | +// response this line is, if any. The hook runs in a goroutine: the SDK holds | ||
| 49 | +// its write lock here, and the hook is about to write a notification. | ||
| 50 | +func (w *Writer) Write(p []byte) (int, error) { | ||
| 51 | + // An elicitation's scope rides in _meta until the SDK can carry it | ||
| 52 | + // (internal/acp/elicit.go); the caller is told the length it gave. | ||
| 53 | + if _, err := w.Writer.Write(LiftElicitationScope(p)); err != nil { | ||
| 54 | + return 0, err | ||
| 55 | + } | ||
| 56 | + n := len(p) | ||
| 57 | + if sid, ok := ResponseSessionID(p); ok { | ||
| 58 | + w.mu.Lock() | ||
| 59 | + hook := w.hooks[sid] | ||
| 60 | + delete(w.hooks, sid) | ||
| 61 | + w.mu.Unlock() | ||
| 62 | + if hook != nil { | ||
| 63 | + go hook() | ||
| 64 | + } | ||
| 65 | + } | ||
| 66 | + return n, nil | ||
| 67 | +} | ||
| 68 | + | ||
| 69 | +// ResponseSessionID recognises a JSON-RPC response whose result carries a | ||
| 70 | +// sessionId — session/new (and session/load, which this agent does not | ||
| 71 | +// offer). Notifications have no id and prompt responses no sessionId, so | ||
| 72 | +// neither matches. | ||
| 73 | +func ResponseSessionID(line []byte) (sdk.SessionId, bool) { | ||
| 74 | + var m struct { | ||
| 75 | + ID json.RawMessage `json:"id"` | ||
| 76 | + Result struct { | ||
| 77 | + SessionId sdk.SessionId `json:"sessionId"` | ||
| 78 | + } `json:"result"` | ||
| 79 | + } | ||
| 80 | + if json.Unmarshal(line, &m) != nil || len(m.ID) == 0 || m.Result.SessionId == "" { | ||
| 81 | + return "", false | ||
| 82 | + } | ||
| 83 | + return m.Result.SessionId, true | ||
| 84 | +} | ||
| 85 | + | ||
| 86 | +// --- the scope the SDK cannot carry ------------------------------------------------ | ||
| 87 | + | ||
| 88 | +// LiftElicitationScope rewrites one outgoing JSON-RPC line. ACP ties a | ||
| 89 | +// session-scoped elicitation to its session (and tool call) with `sessionId` | ||
| 90 | +// and `toolCallId` at the top level of the params, but acp-go-sdk v0.13.5's | ||
| 91 | +// UnstableCreateElicitationForm has no such fields — only `_meta`. So | ||
| 92 | +// the agents' form requests park the scope in `_meta` (elicit.ScopeMetaKey), and this | ||
| 93 | +// copies it where the specification puts it as the line is written out. The | ||
| 94 | +// `_meta` copy stays: an mm client relaying the question reads its tool call | ||
| 95 | +// there, the SDK dropping the top-level fields on its side too. Any other | ||
| 96 | +// line comes back untouched. Remove both when the SDK carries the fields. | ||
| 97 | +func LiftElicitationScope(line []byte) []byte { | ||
| 98 | + if !bytes.Contains(line, []byte(elicit.ScopeMetaKey)) { | ||
| 99 | + return line | ||
| 100 | + } | ||
| 101 | + var msg map[string]any | ||
| 102 | + if json.Unmarshal(line, &msg) != nil || msg["method"] != sdk.ClientMethodElicitationCreate { | ||
| 103 | + return line | ||
| 104 | + } | ||
| 105 | + params, _ := msg["params"].(map[string]any) | ||
| 106 | + meta, _ := params["_meta"].(map[string]any) | ||
| 107 | + scope, ok := meta[elicit.ScopeMetaKey].(map[string]any) | ||
| 108 | + if !ok { | ||
| 109 | + return line | ||
| 110 | + } | ||
| 111 | + for key, value := range scope { | ||
| 112 | + params[key] = value | ||
| 113 | + } | ||
| 114 | + out, err := json.Marshal(msg) | ||
| 115 | + if err != nil { | ||
| 116 | + return line | ||
| 117 | + } | ||
| 118 | + if bytes.HasSuffix(line, []byte("\n")) { | ||
| 119 | + out = append(out, '\n') | ||
| 120 | + } | ||
| 121 | + return out | ||
| 122 | +} | ||
modified
internal/agentinit/flow.go +19 -1 | @@ -86,6 +86,24 @@ type Flow struct { | ||
| 86 | 86 | facts Facts |
| 87 | 87 | factsFor string // the model the facts are about |
| 88 | 88 | reachable map[string]bool |
| 89 | + | |
| 90 | + // dir is where a relative file is written ("" = the process's working | |
| 91 | + // directory). mm acp-create sets it to the session's cwd: the client's | |
| 92 | + // project, not the directory the client started mm in. | |
| 93 | + dir string | |
| 94 | +} | |
| 95 | + | |
| 96 | +// SetDir makes relative files resolve from dir instead of the working | |
| 97 | +// directory (mm acp-create: the session's cwd). | |
| 98 | +func (f *Flow) SetDir(dir string) { f.dir = dir } | |
| 99 | + | |
| 100 | +// Path is where file p of the flow lives: p itself when absolute, "-" or | |
| 101 | +// when no dir was set, else p under the flow's dir. | |
| 102 | +func (f *Flow) Path(p string) string { | |
| 103 | + if f.dir == "" || p == "" || p == "-" || filepath.IsAbs(p) { | |
| 104 | + return p | |
| 105 | + } | |
| 106 | + return filepath.Join(f.dir, p) | |
| 89 | 107 | } |
| 90 | 108 | |
| 91 | 109 | // NewFlow starts a flow; what the flags gave is marked given and never asked. |
| @@ -139,7 +157,7 @@ func (f *Flow) Steps() []Step { | ||
| 139 | 157 | |
| 140 | 158 | // asksOverwrite says the chosen file exists, or that replacing it was agreed. |
| 141 | 159 | func (f *Flow) asksOverwrite() bool { |
| 142 | - return f.overwrite || (f.output != "" && f.output != "-" && exists(f.output)) | |
| 160 | + return f.overwrite || (f.output != "" && f.output != "-" && exists(f.Path(f.output))) | |
| 143 | 161 | } |
| 144 | 162 | |
| 145 | 163 | // Overwrite says the user agreed to replace the existing output file. |
| @@ -86,6 +86,24 @@ type Flow struct { | |||
| 86 | facts Facts | 86 | facts Facts |
| 87 | factsFor string // the model the facts are about | 87 | factsFor string // the model the facts are about |
| 88 | reachable map[string]bool | 88 | reachable map[string]bool |
| 89 | + | ||
| 90 | + // dir is where a relative file is written ("" = the process's working | ||
| 91 | + // directory). mm acp-create sets it to the session's cwd: the client's | ||
| 92 | + // project, not the directory the client started mm in. | ||
| 93 | + dir string | ||
| 94 | +} | ||
| 95 | + | ||
| 96 | +// SetDir makes relative files resolve from dir instead of the working | ||
| 97 | +// directory (mm acp-create: the session's cwd). | ||
| 98 | +func (f *Flow) SetDir(dir string) { f.dir = dir } | ||
| 99 | + | ||
| 100 | +// Path is where file p of the flow lives: p itself when absolute, "-" or | ||
| 101 | +// when no dir was set, else p under the flow's dir. | ||
| 102 | +func (f *Flow) Path(p string) string { | ||
| 103 | + if f.dir == "" || p == "" || p == "-" || filepath.IsAbs(p) { | ||
| 104 | + return p | ||
| 105 | + } | ||
| 106 | + return filepath.Join(f.dir, p) | ||
| 89 | } | 107 | } |
| 90 | 108 | ||
| 91 | // NewFlow starts a flow; what the flags gave is marked given and never asked. | 109 | // NewFlow starts a flow; what the flags gave is marked given and never asked. |
| @@ -139,7 +157,7 @@ func (f *Flow) Steps() []Step { | |||
| 139 | 157 | ||
| 140 | // asksOverwrite says the chosen file exists, or that replacing it was agreed. | 158 | // asksOverwrite says the chosen file exists, or that replacing it was agreed. |
| 141 | func (f *Flow) asksOverwrite() bool { | 159 | func (f *Flow) asksOverwrite() bool { |
| 142 | - return f.overwrite || (f.output != "" && f.output != "-" && exists(f.output)) | 160 | + return f.overwrite || (f.output != "" && f.output != "-" && exists(f.Path(f.output))) |
| 143 | } | 161 | } |
| 144 | 162 | ||
| 145 | // Overwrite says the user agreed to replace the existing output file. | 163 | // Overwrite says the user agreed to replace the existing output file. |
modified
internal/agentinit/wizard.go +121 -73 | @@ -52,11 +52,66 @@ func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int | ||
| 52 | 52 | if len(opts) > 0 { |
| 53 | 53 | o = opts[0] |
| 54 | 54 | } |
| 55 | - fs := flag.NewFlagSet("mm init", flag.ContinueOnError) | |
| 56 | - fs.SetOutput(stdout) | |
| 57 | - var c Choices | |
| 58 | - var yolo, output, project, sessions, skills string | |
| 59 | - var check, yes, line bool | |
| 55 | + a, code, ok := ParseArgs("mm init", args, stdout, true) | |
| 56 | + if !ok { | |
| 57 | + return code | |
| 58 | + } | |
| 59 | + if a.Yes && a.Output != "" && a.Output != "-" && exists(a.Output) { | |
| 60 | + fmt.Fprintln(stdout, "⚠️ "+alreadyExists(a.Output).Error()) | |
| 61 | + return 1 | |
| 62 | + } | |
| 63 | + ctx := context.Background() | |
| 64 | + flow, err := a.Flow(ctx) | |
| 65 | + if err != nil { | |
| 66 | + fmt.Fprintln(stdout, "⚠️ "+err.Error()) | |
| 67 | + return 1 | |
| 68 | + } | |
| 69 | + | |
| 70 | + switch { | |
| 71 | + case a.Yes: | |
| 72 | + if err := flow.takeDefaults(); err != nil { | |
| 73 | + fmt.Fprintln(stdout, "⚠️ "+err.Error()) | |
| 74 | + return 1 | |
| 75 | + } | |
| 76 | + case o.Window != nil && !a.Line && o.Terminal != nil && o.Terminal(): | |
| 77 | + done, err := o.Window(ctx, flow) | |
| 78 | + if err != nil { | |
| 79 | + fmt.Fprintln(stdout, "⚠️ "+err.Error()) | |
| 80 | + return 1 | |
| 81 | + } | |
| 82 | + if !done { | |
| 83 | + fmt.Fprintln(stdout, "nothing written") | |
| 84 | + return 1 | |
| 85 | + } | |
| 86 | + default: | |
| 87 | + w := &lineWizard{in: bufio.NewReader(stdin), out: stdout} | |
| 88 | + if err := w.run(flow); err != nil { | |
| 89 | + fmt.Fprintln(stdout, "⚠️ "+err.Error()) | |
| 90 | + return 1 | |
| 91 | + } | |
| 92 | + } | |
| 93 | + return finish(flow, stdout, a.Check) | |
| 94 | +} | |
| 95 | + | |
| 96 | +// Args are mm init's flags, read: the answers they give, the file, and the | |
| 97 | +// line mode's own switches (Check, Yes, Line — not offered by mm | |
| 98 | +// acp-create, which has no use for them). | |
| 99 | +type Args struct { | |
| 100 | + Choices Choices | |
| 101 | + Output string | |
| 102 | + Check, Yes, Line bool | |
| 103 | + presets Presets | |
| 104 | +} | |
| 105 | + | |
| 106 | +// ParseArgs reads mm init's flags (name is the command shown by -h). With | |
| 107 | +// lineSwitches false, --check, --yes and --line are not offered. ok is | |
| 108 | +// false when the command must stop: -h (code 0), a bad flag (2) or presets | |
| 109 | +// that do not load (1); the reason is already written to out. | |
| 110 | +func ParseArgs(name string, args []string, out io.Writer, lineSwitches bool) (a Args, code int, ok bool) { | |
| 111 | + fs := flag.NewFlagSet(name, flag.ContinueOnError) | |
| 112 | + fs.SetOutput(out) | |
| 113 | + c := &a.Choices | |
| 114 | + var yolo, project, sessions, skills string | |
| 60 | 115 | fs.StringVar(&c.Provider, "provider", "", "model server: "+strings.Join(providers, ", ")) |
| 61 | 116 | fs.StringVar(&c.BaseURL, "base-url", "", "the server's OpenAI-compatible address (default: the provider's); for openai, the endpoint question") |
| 62 | 117 | fs.StringVar(&c.APIKeyEnv, "api-key-env", "", "openai: the environment variable that holds the API key (default OPENAI_API_KEY)") |
| @@ -69,26 +124,25 @@ func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int | ||
| 69 | 124 | fs.StringVar(&project, "project", "", "yes: create .mm/ and write .mm/agent.yaml, paths from the project root; no: one agent.yaml (default: no)") |
| 70 | 125 | fs.StringVar(&sessions, "sessions", "", "yes: write a sessions section that saves every conversation; no: none (default: no)") |
| 71 | 126 | fs.StringVar(&skills, "skills", "", "yes: write skillsDir and create the folder (skills/, or .mm/skills/ with --project yes); no: skillsDir: no-skills (default: no)") |
| 72 | - fs.StringVar(&output, "o", "", "the file to write (default agent.yaml, .mm/agent.yaml with --project yes; - for stdout, which creates nothing); an existing file is replaced only if you agree, never with --yes") | |
| 73 | - fs.StringVar(&output, "output", "", "same as -o") | |
| 74 | - fs.BoolVar(&check, "check", false, "after writing, ask the model one question to prove it answers") | |
| 75 | - fs.BoolVar(&yes, "yes", false, "ask nothing: every unanswered question takes its default") | |
| 76 | - fs.BoolVar(&line, "line", false, "ask line by line even in a terminal (no window)") | |
| 127 | + fs.StringVar(&a.Output, "o", "", "the file to write (default agent.yaml, .mm/agent.yaml with --project yes; - for stdout, which creates nothing); an existing file is replaced only if you agree, never with --yes") | |
| 128 | + fs.StringVar(&a.Output, "output", "", "same as -o") | |
| 129 | + if lineSwitches { | |
| 130 | + fs.BoolVar(&a.Check, "check", false, "after writing, ask the model one question to prove it answers") | |
| 131 | + fs.BoolVar(&a.Yes, "yes", false, "ask nothing: every unanswered question takes its default") | |
| 132 | + fs.BoolVar(&a.Line, "line", false, "ask line by line even in a terminal (no window)") | |
| 133 | + } | |
| 77 | 134 | if err := fs.Parse(args); err != nil { |
| 78 | 135 | if errors.Is(err, flag.ErrHelp) { |
| 79 | - return 0 | |
| 136 | + return a, 0, false | |
| 80 | 137 | } |
| 81 | - return 2 | |
| 138 | + return a, 2, false | |
| 82 | 139 | } |
| 83 | 140 | presets, err := LoadPresets() |
| 84 | 141 | if err != nil { |
| 85 | - fmt.Fprintln(stdout, "⚠️ "+err.Error()) | |
| 86 | - return 1 | |
| 87 | - } | |
| 88 | - if yes && output != "" && output != "-" && exists(output) { | |
| 89 | - fmt.Fprintln(stdout, "⚠️ "+alreadyExists(output).Error()) | |
| 90 | - return 1 | |
| 142 | + fmt.Fprintln(out, "⚠️ "+err.Error()) | |
| 143 | + return a, 1, false | |
| 91 | 144 | } |
| 145 | + a.presets = presets | |
| 92 | 146 | if yolo != "" { |
| 93 | 147 | on := yolo == "yes" || yolo == "true" |
| 94 | 148 | c.Yolo = &on |
| @@ -101,85 +155,79 @@ func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int | ||
| 101 | 155 | continue |
| 102 | 156 | } |
| 103 | 157 | if err := setYesNo("--"+flagName, value.text, value.target); err != nil { |
| 104 | - fmt.Fprintln(stdout, "⚠️ "+err.Error()) | |
| 105 | - return 2 | |
| 158 | + fmt.Fprintln(out, "⚠️ "+err.Error()) | |
| 159 | + return a, 2, false | |
| 106 | 160 | } |
| 107 | 161 | } |
| 108 | - ctx := context.Background() | |
| 109 | - flow := NewFlow(ctx, presets, c, output) | |
| 110 | - if err := flow.validateGiven(); err != nil { | |
| 111 | - fmt.Fprintln(stdout, "⚠️ "+err.Error()) | |
| 112 | - return 1 | |
| 113 | - } | |
| 162 | + return a, 0, true | |
| 163 | +} | |
| 114 | 164 | |
| 115 | - switch { | |
| 116 | - case yes: | |
| 117 | - if err := flow.takeDefaults(); err != nil { | |
| 118 | - fmt.Fprintln(stdout, "⚠️ "+err.Error()) | |
| 119 | - return 1 | |
| 120 | - } | |
| 121 | - case o.Window != nil && !line && o.Terminal != nil && o.Terminal(): | |
| 122 | - done, err := o.Window(ctx, flow) | |
| 123 | - if err != nil { | |
| 124 | - fmt.Fprintln(stdout, "⚠️ "+err.Error()) | |
| 125 | - return 1 | |
| 126 | - } | |
| 127 | - if !done { | |
| 128 | - fmt.Fprintln(stdout, "nothing written") | |
| 129 | - return 1 | |
| 130 | - } | |
| 131 | - default: | |
| 132 | - w := &lineWizard{in: bufio.NewReader(stdin), out: stdout} | |
| 133 | - if err := w.run(flow); err != nil { | |
| 134 | - fmt.Fprintln(stdout, "⚠️ "+err.Error()) | |
| 135 | - return 1 | |
| 136 | - } | |
| 137 | - } | |
| 138 | - return finish(flow, stdout, check) | |
| 165 | +// Flow starts a flow from the flags: what they gave is answered, and | |
| 166 | +// checked as if typed. Each call is a new flow (mm acp-create: one per | |
| 167 | +// session). | |
| 168 | +func (a Args) Flow(ctx context.Context) (*Flow, error) { | |
| 169 | + flow := NewFlow(ctx, a.presets, a.Choices, a.Output) | |
| 170 | + return flow, flow.validateGiven() | |
| 139 | 171 | } |
| 140 | 172 | |
| 141 | 173 | // finish writes the file the flow describes, loads it, and checks it. |
| 142 | 174 | func finish(flow *Flow, stdout io.Writer, check bool) int { |
| 175 | + output, ok := WriteFile(flow, stdout) | |
| 176 | + if !ok { | |
| 177 | + return 1 | |
| 178 | + } | |
| 179 | + if output == "-" { | |
| 180 | + return 0 | |
| 181 | + } | |
| 182 | + if check { | |
| 183 | + return checkFile(stdout, output) | |
| 184 | + } | |
| 185 | + fmt.Fprintln(stdout, nextLine(output)) | |
| 186 | + return 0 | |
| 187 | +} | |
| 188 | + | |
| 189 | +// WriteFile writes the file the answered flow describes — or prints it, for | |
| 190 | +// "-" — creates the skills folder, and proves mm loads it, reporting each | |
| 191 | +// step on out. It returns the file as the flow names it (relative to the | |
| 192 | +// flow's dir, see Flow.SetDir) and whether everything went well. | |
| 193 | +func WriteFile(flow *Flow, out io.Writer) (string, bool) { | |
| 143 | 194 | choices, facts, output := flow.Result() |
| 144 | 195 | if choices.Model == "" { |
| 145 | - fmt.Fprintln(stdout, "⚠️ no model: nothing written") | |
| 146 | - return 1 | |
| 196 | + fmt.Fprintln(out, "⚠️ no model: nothing written") | |
| 197 | + return output, false | |
| 147 | 198 | } |
| 148 | 199 | text := Render(Decide(flow.presets, choices, facts)) |
| 149 | 200 | if output == "-" { |
| 150 | - fmt.Fprint(stdout, text) | |
| 151 | - return 0 | |
| 201 | + fmt.Fprint(out, text) | |
| 202 | + return output, true | |
| 152 | 203 | } |
| 204 | + path := flow.Path(output) | |
| 153 | 205 | // The Project layout's file lives in .mm/, which may not exist yet. |
| 154 | 206 | if on(choices.Project) { |
| 155 | - if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil { | |
| 156 | - fmt.Fprintln(stdout, "⚠️ "+err.Error()) | |
| 157 | - return 1 | |
| 207 | + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |
| 208 | + fmt.Fprintln(out, "⚠️ "+err.Error()) | |
| 209 | + return output, false | |
| 158 | 210 | } |
| 159 | 211 | } |
| 160 | 212 | write := writeNew |
| 161 | 213 | if flow.Overwrite() { |
| 162 | 214 | write = replaceFile |
| 163 | 215 | } |
| 164 | - if err := write(output, text); err != nil { | |
| 165 | - fmt.Fprintln(stdout, "⚠️ "+err.Error()) | |
| 166 | - return 1 | |
| 216 | + if err := write(path, text); err != nil { | |
| 217 | + fmt.Fprintln(out, "⚠️ "+err.Error()) | |
| 218 | + return output, false | |
| 167 | 219 | } |
| 168 | - fmt.Fprintf(stdout, "✅ wrote %s\n", output) | |
| 169 | - if err := createSkillsFolder(choices, output, stdout); err != nil { | |
| 170 | - fmt.Fprintln(stdout, "⚠️ "+err.Error()) | |
| 171 | - return 1 | |
| 220 | + fmt.Fprintf(out, "✅ wrote %s\n", output) | |
| 221 | + if err := createSkillsFolder(choices, path, out); err != nil { | |
| 222 | + fmt.Fprintln(out, "⚠️ "+err.Error()) | |
| 223 | + return output, false | |
| 172 | 224 | } |
| 173 | - if _, err := config.Load(output); err != nil { | |
| 174 | - fmt.Fprintf(stdout, "⚠️ mm does not load it: %v\n", err) | |
| 175 | - return 1 | |
| 225 | + if _, err := config.Load(path); err != nil { | |
| 226 | + fmt.Fprintf(out, "⚠️ mm does not load it: %v\n", err) | |
| 227 | + return output, false | |
| 176 | 228 | } |
| 177 | - fmt.Fprintln(stdout, "✅ mm loads it") | |
| 178 | - if check { | |
| 179 | - return checkFile(stdout, output) | |
| 180 | - } | |
| 181 | - fmt.Fprintln(stdout, nextLine(output)) | |
| 182 | - return 0 | |
| 229 | + fmt.Fprintln(out, "✅ mm loads it") | |
| 230 | + return output, true | |
| 183 | 231 | } |
| 184 | 232 | |
| 185 | 233 | // createSkillsFolder makes the folder skillsDir names, when skills are on |
| @@ -52,11 +52,66 @@ func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int | |||
| 52 | if len(opts) > 0 { | 52 | if len(opts) > 0 { |
| 53 | o = opts[0] | 53 | o = opts[0] |
| 54 | } | 54 | } |
| 55 | - fs := flag.NewFlagSet("mm init", flag.ContinueOnError) | 55 | + a, code, ok := ParseArgs("mm init", args, stdout, true) |
| 56 | - fs.SetOutput(stdout) | 56 | + if !ok { |
| 57 | - var c Choices | 57 | + return code |
| 58 | - var yolo, output, project, sessions, skills string | 58 | + } |
| 59 | - var check, yes, line bool | 59 | + if a.Yes && a.Output != "" && a.Output != "-" && exists(a.Output) { |
| 60 | + fmt.Fprintln(stdout, "⚠️ "+alreadyExists(a.Output).Error()) | ||
| 61 | + return 1 | ||
| 62 | + } | ||
| 63 | + ctx := context.Background() | ||
| 64 | + flow, err := a.Flow(ctx) | ||
| 65 | + if err != nil { | ||
| 66 | + fmt.Fprintln(stdout, "⚠️ "+err.Error()) | ||
| 67 | + return 1 | ||
| 68 | + } | ||
| 69 | + | ||
| 70 | + switch { | ||
| 71 | + case a.Yes: | ||
| 72 | + if err := flow.takeDefaults(); err != nil { | ||
| 73 | + fmt.Fprintln(stdout, "⚠️ "+err.Error()) | ||
| 74 | + return 1 | ||
| 75 | + } | ||
| 76 | + case o.Window != nil && !a.Line && o.Terminal != nil && o.Terminal(): | ||
| 77 | + done, err := o.Window(ctx, flow) | ||
| 78 | + if err != nil { | ||
| 79 | + fmt.Fprintln(stdout, "⚠️ "+err.Error()) | ||
| 80 | + return 1 | ||
| 81 | + } | ||
| 82 | + if !done { | ||
| 83 | + fmt.Fprintln(stdout, "nothing written") | ||
| 84 | + return 1 | ||
| 85 | + } | ||
| 86 | + default: | ||
| 87 | + w := &lineWizard{in: bufio.NewReader(stdin), out: stdout} | ||
| 88 | + if err := w.run(flow); err != nil { | ||
| 89 | + fmt.Fprintln(stdout, "⚠️ "+err.Error()) | ||
| 90 | + return 1 | ||
| 91 | + } | ||
| 92 | + } | ||
| 93 | + return finish(flow, stdout, a.Check) | ||
| 94 | +} | ||
| 95 | + | ||
| 96 | +// Args are mm init's flags, read: the answers they give, the file, and the | ||
| 97 | +// line mode's own switches (Check, Yes, Line — not offered by mm | ||
| 98 | +// acp-create, which has no use for them). | ||
| 99 | +type Args struct { | ||
| 100 | + Choices Choices | ||
| 101 | + Output string | ||
| 102 | + Check, Yes, Line bool | ||
| 103 | + presets Presets | ||
| 104 | +} | ||
| 105 | + | ||
| 106 | +// ParseArgs reads mm init's flags (name is the command shown by -h). With | ||
| 107 | +// lineSwitches false, --check, --yes and --line are not offered. ok is | ||
| 108 | +// false when the command must stop: -h (code 0), a bad flag (2) or presets | ||
| 109 | +// that do not load (1); the reason is already written to out. | ||
| 110 | +func ParseArgs(name string, args []string, out io.Writer, lineSwitches bool) (a Args, code int, ok bool) { | ||
| 111 | + fs := flag.NewFlagSet(name, flag.ContinueOnError) | ||
| 112 | + fs.SetOutput(out) | ||
| 113 | + c := &a.Choices | ||
| 114 | + var yolo, project, sessions, skills string | ||
| 60 | fs.StringVar(&c.Provider, "provider", "", "model server: "+strings.Join(providers, ", ")) | 115 | fs.StringVar(&c.Provider, "provider", "", "model server: "+strings.Join(providers, ", ")) |
| 61 | fs.StringVar(&c.BaseURL, "base-url", "", "the server's OpenAI-compatible address (default: the provider's); for openai, the endpoint question") | 116 | fs.StringVar(&c.BaseURL, "base-url", "", "the server's OpenAI-compatible address (default: the provider's); for openai, the endpoint question") |
| 62 | fs.StringVar(&c.APIKeyEnv, "api-key-env", "", "openai: the environment variable that holds the API key (default OPENAI_API_KEY)") | 117 | fs.StringVar(&c.APIKeyEnv, "api-key-env", "", "openai: the environment variable that holds the API key (default OPENAI_API_KEY)") |
| @@ -69,26 +124,25 @@ func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int | |||
| 69 | fs.StringVar(&project, "project", "", "yes: create .mm/ and write .mm/agent.yaml, paths from the project root; no: one agent.yaml (default: no)") | 124 | fs.StringVar(&project, "project", "", "yes: create .mm/ and write .mm/agent.yaml, paths from the project root; no: one agent.yaml (default: no)") |
| 70 | fs.StringVar(&sessions, "sessions", "", "yes: write a sessions section that saves every conversation; no: none (default: no)") | 125 | fs.StringVar(&sessions, "sessions", "", "yes: write a sessions section that saves every conversation; no: none (default: no)") |
| 71 | fs.StringVar(&skills, "skills", "", "yes: write skillsDir and create the folder (skills/, or .mm/skills/ with --project yes); no: skillsDir: no-skills (default: no)") | 126 | fs.StringVar(&skills, "skills", "", "yes: write skillsDir and create the folder (skills/, or .mm/skills/ with --project yes); no: skillsDir: no-skills (default: no)") |
| 72 | - fs.StringVar(&output, "o", "", "the file to write (default agent.yaml, .mm/agent.yaml with --project yes; - for stdout, which creates nothing); an existing file is replaced only if you agree, never with --yes") | 127 | + fs.StringVar(&a.Output, "o", "", "the file to write (default agent.yaml, .mm/agent.yaml with --project yes; - for stdout, which creates nothing); an existing file is replaced only if you agree, never with --yes") |
| 73 | - fs.StringVar(&output, "output", "", "same as -o") | 128 | + fs.StringVar(&a.Output, "output", "", "same as -o") |
| 74 | - fs.BoolVar(&check, "check", false, "after writing, ask the model one question to prove it answers") | 129 | + if lineSwitches { |
| 75 | - fs.BoolVar(&yes, "yes", false, "ask nothing: every unanswered question takes its default") | 130 | + fs.BoolVar(&a.Check, "check", false, "after writing, ask the model one question to prove it answers") |
| 76 | - fs.BoolVar(&line, "line", false, "ask line by line even in a terminal (no window)") | 131 | + fs.BoolVar(&a.Yes, "yes", false, "ask nothing: every unanswered question takes its default") |
| 132 | + fs.BoolVar(&a.Line, "line", false, "ask line by line even in a terminal (no window)") | ||
| 133 | + } | ||
| 77 | if err := fs.Parse(args); err != nil { | 134 | if err := fs.Parse(args); err != nil { |
| 78 | if errors.Is(err, flag.ErrHelp) { | 135 | if errors.Is(err, flag.ErrHelp) { |
| 79 | - return 0 | 136 | + return a, 0, false |
| 80 | } | 137 | } |
| 81 | - return 2 | 138 | + return a, 2, false |
| 82 | } | 139 | } |
| 83 | presets, err := LoadPresets() | 140 | presets, err := LoadPresets() |
| 84 | if err != nil { | 141 | if err != nil { |
| 85 | - fmt.Fprintln(stdout, "⚠️ "+err.Error()) | 142 | + fmt.Fprintln(out, "⚠️ "+err.Error()) |
| 86 | - return 1 | 143 | + return a, 1, false |
| 87 | - } | ||
| 88 | - if yes && output != "" && output != "-" && exists(output) { | ||
| 89 | - fmt.Fprintln(stdout, "⚠️ "+alreadyExists(output).Error()) | ||
| 90 | - return 1 | ||
| 91 | } | 144 | } |
| 145 | + a.presets = presets | ||
| 92 | if yolo != "" { | 146 | if yolo != "" { |
| 93 | on := yolo == "yes" || yolo == "true" | 147 | on := yolo == "yes" || yolo == "true" |
| 94 | c.Yolo = &on | 148 | c.Yolo = &on |
| @@ -101,85 +155,79 @@ func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int | |||
| 101 | continue | 155 | continue |
| 102 | } | 156 | } |
| 103 | if err := setYesNo("--"+flagName, value.text, value.target); err != nil { | 157 | if err := setYesNo("--"+flagName, value.text, value.target); err != nil { |
| 104 | - fmt.Fprintln(stdout, "⚠️ "+err.Error()) | 158 | + fmt.Fprintln(out, "⚠️ "+err.Error()) |
| 105 | - return 2 | 159 | + return a, 2, false |
| 106 | } | 160 | } |
| 107 | } | 161 | } |
| 108 | - ctx := context.Background() | 162 | + return a, 0, true |
| 109 | - flow := NewFlow(ctx, presets, c, output) | 163 | +} |
| 110 | - if err := flow.validateGiven(); err != nil { | ||
| 111 | - fmt.Fprintln(stdout, "⚠️ "+err.Error()) | ||
| 112 | - return 1 | ||
| 113 | - } | ||
| 114 | 164 | ||
| 115 | - switch { | 165 | +// Flow starts a flow from the flags: what they gave is answered, and |
| 116 | - case yes: | 166 | +// checked as if typed. Each call is a new flow (mm acp-create: one per |
| 117 | - if err := flow.takeDefaults(); err != nil { | 167 | +// session). |
| 118 | - fmt.Fprintln(stdout, "⚠️ "+err.Error()) | 168 | +func (a Args) Flow(ctx context.Context) (*Flow, error) { |
| 119 | - return 1 | 169 | + flow := NewFlow(ctx, a.presets, a.Choices, a.Output) |
| 120 | - } | 170 | + return flow, flow.validateGiven() |
| 121 | - case o.Window != nil && !line && o.Terminal != nil && o.Terminal(): | ||
| 122 | - done, err := o.Window(ctx, flow) | ||
| 123 | - if err != nil { | ||
| 124 | - fmt.Fprintln(stdout, "⚠️ "+err.Error()) | ||
| 125 | - return 1 | ||
| 126 | - } | ||
| 127 | - if !done { | ||
| 128 | - fmt.Fprintln(stdout, "nothing written") | ||
| 129 | - return 1 | ||
| 130 | - } | ||
| 131 | - default: | ||
| 132 | - w := &lineWizard{in: bufio.NewReader(stdin), out: stdout} | ||
| 133 | - if err := w.run(flow); err != nil { | ||
| 134 | - fmt.Fprintln(stdout, "⚠️ "+err.Error()) | ||
| 135 | - return 1 | ||
| 136 | - } | ||
| 137 | - } | ||
| 138 | - return finish(flow, stdout, check) | ||
| 139 | } | 171 | } |
| 140 | 172 | ||
| 141 | // finish writes the file the flow describes, loads it, and checks it. | 173 | // finish writes the file the flow describes, loads it, and checks it. |
| 142 | func finish(flow *Flow, stdout io.Writer, check bool) int { | 174 | func finish(flow *Flow, stdout io.Writer, check bool) int { |
| 175 | + output, ok := WriteFile(flow, stdout) | ||
| 176 | + if !ok { | ||
| 177 | + return 1 | ||
| 178 | + } | ||
| 179 | + if output == "-" { | ||
| 180 | + return 0 | ||
| 181 | + } | ||
| 182 | + if check { | ||
| 183 | + return checkFile(stdout, output) | ||
| 184 | + } | ||
| 185 | + fmt.Fprintln(stdout, nextLine(output)) | ||
| 186 | + return 0 | ||
| 187 | +} | ||
| 188 | + | ||
| 189 | +// WriteFile writes the file the answered flow describes — or prints it, for | ||
| 190 | +// "-" — creates the skills folder, and proves mm loads it, reporting each | ||
| 191 | +// step on out. It returns the file as the flow names it (relative to the | ||
| 192 | +// flow's dir, see Flow.SetDir) and whether everything went well. | ||
| 193 | +func WriteFile(flow *Flow, out io.Writer) (string, bool) { | ||
| 143 | choices, facts, output := flow.Result() | 194 | choices, facts, output := flow.Result() |
| 144 | if choices.Model == "" { | 195 | if choices.Model == "" { |
| 145 | - fmt.Fprintln(stdout, "⚠️ no model: nothing written") | 196 | + fmt.Fprintln(out, "⚠️ no model: nothing written") |
| 146 | - return 1 | 197 | + return output, false |
| 147 | } | 198 | } |
| 148 | text := Render(Decide(flow.presets, choices, facts)) | 199 | text := Render(Decide(flow.presets, choices, facts)) |
| 149 | if output == "-" { | 200 | if output == "-" { |
| 150 | - fmt.Fprint(stdout, text) | 201 | + fmt.Fprint(out, text) |
| 151 | - return 0 | 202 | + return output, true |
| 152 | } | 203 | } |
| 204 | + path := flow.Path(output) | ||
| 153 | // The Project layout's file lives in .mm/, which may not exist yet. | 205 | // The Project layout's file lives in .mm/, which may not exist yet. |
| 154 | if on(choices.Project) { | 206 | if on(choices.Project) { |
| 155 | - if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil { | 207 | + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| 156 | - fmt.Fprintln(stdout, "⚠️ "+err.Error()) | 208 | + fmt.Fprintln(out, "⚠️ "+err.Error()) |
| 157 | - return 1 | 209 | + return output, false |
| 158 | } | 210 | } |
| 159 | } | 211 | } |
| 160 | write := writeNew | 212 | write := writeNew |
| 161 | if flow.Overwrite() { | 213 | if flow.Overwrite() { |
| 162 | write = replaceFile | 214 | write = replaceFile |
| 163 | } | 215 | } |
| 164 | - if err := write(output, text); err != nil { | 216 | + if err := write(path, text); err != nil { |
| 165 | - fmt.Fprintln(stdout, "⚠️ "+err.Error()) | 217 | + fmt.Fprintln(out, "⚠️ "+err.Error()) |
| 166 | - return 1 | 218 | + return output, false |
| 167 | } | 219 | } |
| 168 | - fmt.Fprintf(stdout, "✅ wrote %s\n", output) | 220 | + fmt.Fprintf(out, "✅ wrote %s\n", output) |
| 169 | - if err := createSkillsFolder(choices, output, stdout); err != nil { | 221 | + if err := createSkillsFolder(choices, path, out); err != nil { |
| 170 | - fmt.Fprintln(stdout, "⚠️ "+err.Error()) | 222 | + fmt.Fprintln(out, "⚠️ "+err.Error()) |
| 171 | - return 1 | 223 | + return output, false |
| 172 | } | 224 | } |
| 173 | - if _, err := config.Load(output); err != nil { | 225 | + if _, err := config.Load(path); err != nil { |
| 174 | - fmt.Fprintf(stdout, "⚠️ mm does not load it: %v\n", err) | 226 | + fmt.Fprintf(out, "⚠️ mm does not load it: %v\n", err) |
| 175 | - return 1 | 227 | + return output, false |
| 176 | } | 228 | } |
| 177 | - fmt.Fprintln(stdout, "✅ mm loads it") | 229 | + fmt.Fprintln(out, "✅ mm loads it") |
| 178 | - if check { | 230 | + return output, true |
| 179 | - return checkFile(stdout, output) | ||
| 180 | - } | ||
| 181 | - fmt.Fprintln(stdout, nextLine(output)) | ||
| 182 | - return 0 | ||
| 183 | } | 231 | } |
| 184 | 232 | ||
| 185 | // createSkillsFolder makes the folder skillsDir names, when skills are on | 233 | // createSkillsFolder makes the folder skillsDir names, when skills are on |
modified
internal/dockerinit/dockerinit_test.go +119 -0 | @@ -236,3 +236,122 @@ func TestMainRefusesArguments(t *testing.T) { | ||
| 236 | 236 | t.Errorf("-h: exit %d", code) |
| 237 | 237 | } |
| 238 | 238 | } |
| 239 | + | |
| 240 | +// mmProject runs mm dockerinit in a new project whose agent is | |
| 241 | +// .mm/agent.yaml, as mm init --project yes writes it. | |
| 242 | +func mmProject(t *testing.T, agent string, args ...string) (string, int, string) { | |
| 243 | + t.Helper() | |
| 244 | + dir := t.TempDir() | |
| 245 | + t.Chdir(dir) | |
| 246 | + if err := os.MkdirAll(".mm", 0o755); err != nil { | |
| 247 | + t.Fatal(err) | |
| 248 | + } | |
| 249 | + if err := os.WriteFile(".mm/agent.yaml", []byte(agent), 0o600); err != nil { | |
| 250 | + t.Fatal(err) | |
| 251 | + } | |
| 252 | + var stdout bytes.Buffer | |
| 253 | + code := Main(args, strings.NewReader(""), &stdout) | |
| 254 | + return dir, code, stdout.String() | |
| 255 | +} | |
| 256 | + | |
| 257 | +// A .mm project (ticket 212): the agent is found with no flag and names the | |
| 258 | +// service; .mm/ is mounted read-only, the folders mm writes in writable | |
| 259 | +// again (and created, so that Docker does not create them as root); mm | |
| 260 | +// starts with no file argument. | |
| 261 | +func TestMainKnowsTheMMFolder(t *testing.T) { | |
| 262 | + agent := "acp:\n name: Bob The Bot\nsessions:\n enabled: true\n dir: .mm/sessions\ntrace:\n enabled: true\n dir: .mm/trace\n" | |
| 263 | + _, code, out := mmProject(t, agent) | |
| 264 | + if code != 0 { | |
| 265 | + t.Fatalf("exit %d:\n%s", code, out) | |
| 266 | + } | |
| 267 | + var parsed composeFile | |
| 268 | + if err := yaml.Unmarshal([]byte(read(t, "compose.yaml")), &parsed); err != nil { | |
| 269 | + t.Fatal(err) | |
| 270 | + } | |
| 271 | + service, ok := parsed.Services["bob-the-bot"] | |
| 272 | + if !ok { | |
| 273 | + t.Fatalf("the service is not named after the agent:\n%s", read(t, "compose.yaml")) | |
| 274 | + } | |
| 275 | + want := []string{ | |
| 276 | + ".:${PWD:-/workspace}", | |
| 277 | + "./.mm:${PWD:-/workspace}/.mm:ro", | |
| 278 | + "./.mm/sessions:${PWD:-/workspace}/.mm/sessions", | |
| 279 | + "./.mm/trace:${PWD:-/workspace}/.mm/trace", | |
| 280 | + } | |
| 281 | + if strings.Join(service.Volumes, " | ") != strings.Join(want, " | ") { | |
| 282 | + t.Errorf("volumes:\n got %v\nwant %v", service.Volumes, want) | |
| 283 | + } | |
| 284 | + for _, dir := range []string{".mm/sessions", ".mm/trace"} { | |
| 285 | + if info, err := os.Stat(dir); err != nil || !info.IsDir() { | |
| 286 | + t.Errorf("%s was not created", dir) | |
| 287 | + } | |
| 288 | + } | |
| 289 | + for _, s := range []string{"exec bob-the-bot mm tui\n", "run --rm bob-the-bot mm tui\n", "./.mm/sessions (sessions.dir)", "✅ created ./.mm/sessions"} { | |
| 290 | + if !strings.Contains(out, s) { | |
| 291 | + t.Errorf("output lacks %q:\n%s", s, out) | |
| 292 | + } | |
| 293 | + } | |
| 294 | + if strings.Contains(out, "No agent.yaml") { | |
| 295 | + t.Errorf("the .mm agent was not found:\n%s", out) | |
| 296 | + } | |
| 297 | + if text := read(t, "compose.yaml"); !strings.Contains(text, "exec bob-the-bot mm tui\n") || strings.Contains(text, "agent.yaml\n# docker") { | |
| 298 | + t.Errorf("the compose comments do not start mm with no file:\n%s", text) | |
| 299 | + } | |
| 300 | +} | |
| 301 | + | |
| 302 | +// Sessions off: .mm/ read-only and nothing writable again. | |
| 303 | +func TestMainMMFolderWithoutSessions(t *testing.T) { | |
| 304 | + _, code, out := mmProject(t, "acp:\n name: lucy\n") | |
| 305 | + if code != 0 || !strings.Contains(out, "writable again: nothing") { | |
| 306 | + t.Fatalf("exit %d:\n%s", code, out) | |
| 307 | + } | |
| 308 | + if text := read(t, "compose.yaml"); !strings.Contains(text, "./.mm:${PWD:-/workspace}/.mm:ro") || strings.Contains(text, ".mm/sessions") { | |
| 309 | + t.Errorf("compose.yaml:\n%s", text) | |
| 310 | + } | |
| 311 | +} | |
| 312 | + | |
| 313 | +// Another file in .mm/ is named in the commands: mm finds only agent.yaml. | |
| 314 | +func TestMainMMFolderOtherAgentFile(t *testing.T) { | |
| 315 | + _, code, out := mmProject(t, "acp:\n name: lucy\n", "--agent", ".mm/agent.yaml") | |
| 316 | + if code != 0 || !strings.Contains(out, "exec lucy mm tui\n") { | |
| 317 | + t.Fatalf("exit %d:\n%s", code, out) | |
| 318 | + } | |
| 319 | + if err := os.WriteFile(".mm/other.yaml", []byte("acp:\n name: max\n"), 0o600); err != nil { | |
| 320 | + t.Fatal(err) | |
| 321 | + } | |
| 322 | + var stdout bytes.Buffer | |
| 323 | + if code := Main([]string{"--agent", ".mm/other.yaml", "--yes"}, strings.NewReader(""), &stdout); code != 1 { | |
| 324 | + t.Fatalf("--yes over existing files: exit %d:\n%s", code, stdout.String()) | |
| 325 | + } | |
| 326 | + stdout.Reset() | |
| 327 | + if code := In(".", []string{"--agent", ".mm/other.yaml"}, &stdout, true); code != 0 || !strings.Contains(stdout.String(), "exec max mm tui ./.mm/other.yaml") { | |
| 328 | + t.Errorf("exit %d:\n%s", code, stdout.String()) | |
| 329 | + } | |
| 330 | +} | |
| 331 | + | |
| 332 | +// A plain project gets the same files as before ticket 212. | |
| 333 | +func TestMainPlainProjectUnchanged(t *testing.T) { | |
| 334 | + code, _ := runIn(t, map[string]string{"agent.yaml": "acp:\n name: bob\n"}, "") | |
| 335 | + if code != 0 { | |
| 336 | + t.Fatal(code) | |
| 337 | + } | |
| 338 | + if read(t, "compose.yaml") != Compose("bob", os.Getuid(), os.Getgid()) { | |
| 339 | + t.Error("a plain project's compose.yaml changed") | |
| 340 | + } | |
| 341 | +} | |
| 342 | + | |
| 343 | +// In writes in the given directory, not in the working directory. | |
| 344 | +func TestInWritesInTheGivenDirectory(t *testing.T) { | |
| 345 | + t.Chdir(t.TempDir()) | |
| 346 | + dir := t.TempDir() | |
| 347 | + var out bytes.Buffer | |
| 348 | + if code := In(dir, []string{"--name", "zoe"}, &out, false); code != 0 { | |
| 349 | + t.Fatalf("exit %d:\n%s", code, out.String()) | |
| 350 | + } | |
| 351 | + if _, err := os.Stat(filepath.Join(dir, "compose.yaml")); err != nil { | |
| 352 | + t.Error("compose.yaml is not in the given directory") | |
| 353 | + } | |
| 354 | + if _, err := os.Stat("compose.yaml"); err == nil { | |
| 355 | + t.Error("compose.yaml was written in the working directory") | |
| 356 | + } | |
| 357 | +} | |
| @@ -236,3 +236,122 @@ func TestMainRefusesArguments(t *testing.T) { | |||
| 236 | t.Errorf("-h: exit %d", code) | 236 | t.Errorf("-h: exit %d", code) |
| 237 | } | 237 | } |
| 238 | } | 238 | } |
| 239 | + | ||
| 240 | +// mmProject runs mm dockerinit in a new project whose agent is | ||
| 241 | +// .mm/agent.yaml, as mm init --project yes writes it. | ||
| 242 | +func mmProject(t *testing.T, agent string, args ...string) (string, int, string) { | ||
| 243 | + t.Helper() | ||
| 244 | + dir := t.TempDir() | ||
| 245 | + t.Chdir(dir) | ||
| 246 | + if err := os.MkdirAll(".mm", 0o755); err != nil { | ||
| 247 | + t.Fatal(err) | ||
| 248 | + } | ||
| 249 | + if err := os.WriteFile(".mm/agent.yaml", []byte(agent), 0o600); err != nil { | ||
| 250 | + t.Fatal(err) | ||
| 251 | + } | ||
| 252 | + var stdout bytes.Buffer | ||
| 253 | + code := Main(args, strings.NewReader(""), &stdout) | ||
| 254 | + return dir, code, stdout.String() | ||
| 255 | +} | ||
| 256 | + | ||
| 257 | +// A .mm project (ticket 212): the agent is found with no flag and names the | ||
| 258 | +// service; .mm/ is mounted read-only, the folders mm writes in writable | ||
| 259 | +// again (and created, so that Docker does not create them as root); mm | ||
| 260 | +// starts with no file argument. | ||
| 261 | +func TestMainKnowsTheMMFolder(t *testing.T) { | ||
| 262 | + agent := "acp:\n name: Bob The Bot\nsessions:\n enabled: true\n dir: .mm/sessions\ntrace:\n enabled: true\n dir: .mm/trace\n" | ||
| 263 | + _, code, out := mmProject(t, agent) | ||
| 264 | + if code != 0 { | ||
| 265 | + t.Fatalf("exit %d:\n%s", code, out) | ||
| 266 | + } | ||
| 267 | + var parsed composeFile | ||
| 268 | + if err := yaml.Unmarshal([]byte(read(t, "compose.yaml")), &parsed); err != nil { | ||
| 269 | + t.Fatal(err) | ||
| 270 | + } | ||
| 271 | + service, ok := parsed.Services["bob-the-bot"] | ||
| 272 | + if !ok { | ||
| 273 | + t.Fatalf("the service is not named after the agent:\n%s", read(t, "compose.yaml")) | ||
| 274 | + } | ||
| 275 | + want := []string{ | ||
| 276 | + ".:${PWD:-/workspace}", | ||
| 277 | + "./.mm:${PWD:-/workspace}/.mm:ro", | ||
| 278 | + "./.mm/sessions:${PWD:-/workspace}/.mm/sessions", | ||
| 279 | + "./.mm/trace:${PWD:-/workspace}/.mm/trace", | ||
| 280 | + } | ||
| 281 | + if strings.Join(service.Volumes, " | ") != strings.Join(want, " | ") { | ||
| 282 | + t.Errorf("volumes:\n got %v\nwant %v", service.Volumes, want) | ||
| 283 | + } | ||
| 284 | + for _, dir := range []string{".mm/sessions", ".mm/trace"} { | ||
| 285 | + if info, err := os.Stat(dir); err != nil || !info.IsDir() { | ||
| 286 | + t.Errorf("%s was not created", dir) | ||
| 287 | + } | ||
| 288 | + } | ||
| 289 | + for _, s := range []string{"exec bob-the-bot mm tui\n", "run --rm bob-the-bot mm tui\n", "./.mm/sessions (sessions.dir)", "✅ created ./.mm/sessions"} { | ||
| 290 | + if !strings.Contains(out, s) { | ||
| 291 | + t.Errorf("output lacks %q:\n%s", s, out) | ||
| 292 | + } | ||
| 293 | + } | ||
| 294 | + if strings.Contains(out, "No agent.yaml") { | ||
| 295 | + t.Errorf("the .mm agent was not found:\n%s", out) | ||
| 296 | + } | ||
| 297 | + if text := read(t, "compose.yaml"); !strings.Contains(text, "exec bob-the-bot mm tui\n") || strings.Contains(text, "agent.yaml\n# docker") { | ||
| 298 | + t.Errorf("the compose comments do not start mm with no file:\n%s", text) | ||
| 299 | + } | ||
| 300 | +} | ||
| 301 | + | ||
| 302 | +// Sessions off: .mm/ read-only and nothing writable again. | ||
| 303 | +func TestMainMMFolderWithoutSessions(t *testing.T) { | ||
| 304 | + _, code, out := mmProject(t, "acp:\n name: lucy\n") | ||
| 305 | + if code != 0 || !strings.Contains(out, "writable again: nothing") { | ||
| 306 | + t.Fatalf("exit %d:\n%s", code, out) | ||
| 307 | + } | ||
| 308 | + if text := read(t, "compose.yaml"); !strings.Contains(text, "./.mm:${PWD:-/workspace}/.mm:ro") || strings.Contains(text, ".mm/sessions") { | ||
| 309 | + t.Errorf("compose.yaml:\n%s", text) | ||
| 310 | + } | ||
| 311 | +} | ||
| 312 | + | ||
| 313 | +// Another file in .mm/ is named in the commands: mm finds only agent.yaml. | ||
| 314 | +func TestMainMMFolderOtherAgentFile(t *testing.T) { | ||
| 315 | + _, code, out := mmProject(t, "acp:\n name: lucy\n", "--agent", ".mm/agent.yaml") | ||
| 316 | + if code != 0 || !strings.Contains(out, "exec lucy mm tui\n") { | ||
| 317 | + t.Fatalf("exit %d:\n%s", code, out) | ||
| 318 | + } | ||
| 319 | + if err := os.WriteFile(".mm/other.yaml", []byte("acp:\n name: max\n"), 0o600); err != nil { | ||
| 320 | + t.Fatal(err) | ||
| 321 | + } | ||
| 322 | + var stdout bytes.Buffer | ||
| 323 | + if code := Main([]string{"--agent", ".mm/other.yaml", "--yes"}, strings.NewReader(""), &stdout); code != 1 { | ||
| 324 | + t.Fatalf("--yes over existing files: exit %d:\n%s", code, stdout.String()) | ||
| 325 | + } | ||
| 326 | + stdout.Reset() | ||
| 327 | + if code := In(".", []string{"--agent", ".mm/other.yaml"}, &stdout, true); code != 0 || !strings.Contains(stdout.String(), "exec max mm tui ./.mm/other.yaml") { | ||
| 328 | + t.Errorf("exit %d:\n%s", code, stdout.String()) | ||
| 329 | + } | ||
| 330 | +} | ||
| 331 | + | ||
| 332 | +// A plain project gets the same files as before ticket 212. | ||
| 333 | +func TestMainPlainProjectUnchanged(t *testing.T) { | ||
| 334 | + code, _ := runIn(t, map[string]string{"agent.yaml": "acp:\n name: bob\n"}, "") | ||
| 335 | + if code != 0 { | ||
| 336 | + t.Fatal(code) | ||
| 337 | + } | ||
| 338 | + if read(t, "compose.yaml") != Compose("bob", os.Getuid(), os.Getgid()) { | ||
| 339 | + t.Error("a plain project's compose.yaml changed") | ||
| 340 | + } | ||
| 341 | +} | ||
| 342 | + | ||
| 343 | +// In writes in the given directory, not in the working directory. | ||
| 344 | +func TestInWritesInTheGivenDirectory(t *testing.T) { | ||
| 345 | + t.Chdir(t.TempDir()) | ||
| 346 | + dir := t.TempDir() | ||
| 347 | + var out bytes.Buffer | ||
| 348 | + if code := In(dir, []string{"--name", "zoe"}, &out, false); code != 0 { | ||
| 349 | + t.Fatalf("exit %d:\n%s", code, out.String()) | ||
| 350 | + } | ||
| 351 | + if _, err := os.Stat(filepath.Join(dir, "compose.yaml")); err != nil { | ||
| 352 | + t.Error("compose.yaml is not in the given directory") | ||
| 353 | + } | ||
| 354 | + if _, err := os.Stat("compose.yaml"); err == nil { | ||
| 355 | + t.Error("compose.yaml was written in the working directory") | ||
| 356 | + } | ||
| 357 | +} | ||
modified
internal/dockerinit/files.go +44 -5 | @@ -3,7 +3,13 @@ | ||
| 3 | 3 | // file. |
| 4 | 4 | package dockerinit |
| 5 | 5 | |
| 6 | -import "fmt" | |
| 6 | +import ( | |
| 7 | + "fmt" | |
| 8 | + "path/filepath" | |
| 9 | + "strings" | |
| 10 | + | |
| 11 | + "mm/internal/scaffold" | |
| 12 | +) | |
| 7 | 13 | |
| 8 | 14 | // fallbackID is the uid and gid in the compose file when the machine has no |
| 9 | 15 | // usable one: Windows has none, and root must never be the default. |
| @@ -70,15 +76,26 @@ func Dockerfile() string { return dockerfile } | ||
| 70 | 76 | // |
| 71 | 77 | // dockerinit.Compose(scaffold.Name("Bob"), os.Getuid(), os.Getgid()) |
| 72 | 78 | func Compose(name string, uid, gid int) string { |
| 79 | + return compose(name, uid, gid, nil) | |
| 80 | +} | |
| 81 | + | |
| 82 | +// compose is Compose for a project, with the mounts of a .mm project | |
| 83 | +// (ticket 212) when mm is set: .mm/ read-only over the project, the folders | |
| 84 | +// mm writes in writable again — the same rules as mm sbxinit. | |
| 85 | +func compose(name string, uid, gid int, mm *scaffold.MMFolder) string { | |
| 86 | + start := "mm tui agent.yaml" | |
| 87 | + if mm != nil { | |
| 88 | + start = tuiCommand(startArg(mm)) | |
| 89 | + } | |
| 73 | 90 | return fmt.Sprintf(`# %[1]s: mm in a container. Written by `+"`mm dockerinit`"+`. |
| 74 | 91 | # %[2]s |
| 75 | 92 | # |
| 76 | 93 | # Keep a container running, for the terminal and for an editor (ACP): |
| 77 | 94 | # docker compose up -d --wait |
| 78 | -# docker compose exec %[1]s mm tui agent.yaml | |
| 95 | +# docker compose exec %[1]s %[5]s | |
| 79 | 96 | # |
| 80 | 97 | # Or one session, in a container that goes away with it: |
| 81 | -# docker compose run --rm %[1]s mm tui agent.yaml | |
| 98 | +# docker compose run --rm %[1]s %[5]s | |
| 82 | 99 | services: |
| 83 | 100 | %[1]s: |
| 84 | 101 | build: . |
| @@ -92,13 +109,35 @@ services: | ||
| 92 | 109 | working_dir: ${PWD:-/workspace} |
| 93 | 110 | volumes: |
| 94 | 111 | - .:${PWD:-/workspace} |
| 95 | - stdin_open: true | |
| 112 | +%[6]s stdin_open: true | |
| 96 | 113 | tty: true |
| 97 | 114 | # A model served on this machine. Docker Desktop knows this name; this line |
| 98 | 115 | # makes Linux know it too. |
| 99 | 116 | extra_hosts: |
| 100 | 117 | - host.docker.internal:host-gateway |
| 101 | -`, name, howToURL, usableID(uid), usableID(gid)) | |
| 118 | +`, name, howToURL, usableID(uid), usableID(gid), start, mmVolumes(mm)) | |
| 119 | +} | |
| 120 | + | |
| 121 | +// mmVolumes is the volume lines a .mm project adds after the project's. | |
| 122 | +func mmVolumes(mm *scaffold.MMFolder) string { | |
| 123 | + if mm == nil { | |
| 124 | + return "" | |
| 125 | + } | |
| 126 | + inside := func(path string) string { | |
| 127 | + return "${PWD:-/workspace}/" + strings.TrimPrefix(mm.Rel(path), "./") | |
| 128 | + } | |
| 129 | + var b strings.Builder | |
| 130 | + b.WriteString(` # The agent is .mm/agent.yaml (mm init --project yes). .mm/ is read-only: | |
| 131 | + # the agent cannot change its own settings, skills or hooks. The folders | |
| 132 | + # mm writes in are writable again, with the paths of the agent file; | |
| 133 | + # anything else written under .mm/ (a hook's log) needs a line of its own. | |
| 134 | +`) | |
| 135 | + agentDir := filepath.Dir(mm.Agent) | |
| 136 | + fmt.Fprintf(&b, " - %s:%s:ro\n", mm.Rel(agentDir), inside(agentDir)) | |
| 137 | + for _, w := range mm.Writable { | |
| 138 | + fmt.Fprintf(&b, " - %s:%s # %s\n", mm.Rel(w.Path), inside(w.Path), w.Key) | |
| 139 | + } | |
| 140 | + return b.String() | |
| 102 | 141 | } |
| 103 | 142 | |
| 104 | 143 | // usableID keeps the compose file's default from ever being root. |
| @@ -3,7 +3,13 @@ | |||
| 3 | // file. | 3 | // file. |
| 4 | package dockerinit | 4 | package dockerinit |
| 5 | 5 | ||
| 6 | -import "fmt" | 6 | +import ( |
| 7 | + "fmt" | ||
| 8 | + "path/filepath" | ||
| 9 | + "strings" | ||
| 10 | + | ||
| 11 | + "mm/internal/scaffold" | ||
| 12 | +) | ||
| 7 | 13 | ||
| 8 | // fallbackID is the uid and gid in the compose file when the machine has no | 14 | // fallbackID is the uid and gid in the compose file when the machine has no |
| 9 | // usable one: Windows has none, and root must never be the default. | 15 | // usable one: Windows has none, and root must never be the default. |
| @@ -70,15 +76,26 @@ func Dockerfile() string { return dockerfile } | |||
| 70 | // | 76 | // |
| 71 | // dockerinit.Compose(scaffold.Name("Bob"), os.Getuid(), os.Getgid()) | 77 | // dockerinit.Compose(scaffold.Name("Bob"), os.Getuid(), os.Getgid()) |
| 72 | func Compose(name string, uid, gid int) string { | 78 | func Compose(name string, uid, gid int) string { |
| 79 | + return compose(name, uid, gid, nil) | ||
| 80 | +} | ||
| 81 | + | ||
| 82 | +// compose is Compose for a project, with the mounts of a .mm project | ||
| 83 | +// (ticket 212) when mm is set: .mm/ read-only over the project, the folders | ||
| 84 | +// mm writes in writable again — the same rules as mm sbxinit. | ||
| 85 | +func compose(name string, uid, gid int, mm *scaffold.MMFolder) string { | ||
| 86 | + start := "mm tui agent.yaml" | ||
| 87 | + if mm != nil { | ||
| 88 | + start = tuiCommand(startArg(mm)) | ||
| 89 | + } | ||
| 73 | return fmt.Sprintf(`# %[1]s: mm in a container. Written by `+"`mm dockerinit`"+`. | 90 | return fmt.Sprintf(`# %[1]s: mm in a container. Written by `+"`mm dockerinit`"+`. |
| 74 | # %[2]s | 91 | # %[2]s |
| 75 | # | 92 | # |
| 76 | # Keep a container running, for the terminal and for an editor (ACP): | 93 | # Keep a container running, for the terminal and for an editor (ACP): |
| 77 | # docker compose up -d --wait | 94 | # docker compose up -d --wait |
| 78 | -# docker compose exec %[1]s mm tui agent.yaml | 95 | +# docker compose exec %[1]s %[5]s |
| 79 | # | 96 | # |
| 80 | # Or one session, in a container that goes away with it: | 97 | # Or one session, in a container that goes away with it: |
| 81 | -# docker compose run --rm %[1]s mm tui agent.yaml | 98 | +# docker compose run --rm %[1]s %[5]s |
| 82 | services: | 99 | services: |
| 83 | %[1]s: | 100 | %[1]s: |
| 84 | build: . | 101 | build: . |
| @@ -92,13 +109,35 @@ services: | |||
| 92 | working_dir: ${PWD:-/workspace} | 109 | working_dir: ${PWD:-/workspace} |
| 93 | volumes: | 110 | volumes: |
| 94 | - .:${PWD:-/workspace} | 111 | - .:${PWD:-/workspace} |
| 95 | - stdin_open: true | 112 | +%[6]s stdin_open: true |
| 96 | tty: true | 113 | tty: true |
| 97 | # A model served on this machine. Docker Desktop knows this name; this line | 114 | # A model served on this machine. Docker Desktop knows this name; this line |
| 98 | # makes Linux know it too. | 115 | # makes Linux know it too. |
| 99 | extra_hosts: | 116 | extra_hosts: |
| 100 | - host.docker.internal:host-gateway | 117 | - host.docker.internal:host-gateway |
| 101 | -`, name, howToURL, usableID(uid), usableID(gid)) | 118 | +`, name, howToURL, usableID(uid), usableID(gid), start, mmVolumes(mm)) |
| 119 | +} | ||
| 120 | + | ||
| 121 | +// mmVolumes is the volume lines a .mm project adds after the project's. | ||
| 122 | +func mmVolumes(mm *scaffold.MMFolder) string { | ||
| 123 | + if mm == nil { | ||
| 124 | + return "" | ||
| 125 | + } | ||
| 126 | + inside := func(path string) string { | ||
| 127 | + return "${PWD:-/workspace}/" + strings.TrimPrefix(mm.Rel(path), "./") | ||
| 128 | + } | ||
| 129 | + var b strings.Builder | ||
| 130 | + b.WriteString(` # The agent is .mm/agent.yaml (mm init --project yes). .mm/ is read-only: | ||
| 131 | + # the agent cannot change its own settings, skills or hooks. The folders | ||
| 132 | + # mm writes in are writable again, with the paths of the agent file; | ||
| 133 | + # anything else written under .mm/ (a hook's log) needs a line of its own. | ||
| 134 | +`) | ||
| 135 | + agentDir := filepath.Dir(mm.Agent) | ||
| 136 | + fmt.Fprintf(&b, " - %s:%s:ro\n", mm.Rel(agentDir), inside(agentDir)) | ||
| 137 | + for _, w := range mm.Writable { | ||
| 138 | + fmt.Fprintf(&b, " - %s:%s # %s\n", mm.Rel(w.Path), inside(w.Path), w.Key) | ||
| 139 | + } | ||
| 140 | + return b.String() | ||
| 102 | } | 141 | } |
| 103 | 142 | ||
| 104 | // usableID keeps the compose file's default from ever being root. | 143 | // usableID keeps the compose file's default from ever being root. |
modified
internal/dockerinit/main.go +113 -10 | @@ -6,7 +6,10 @@ import ( | ||
| 6 | 6 | "fmt" |
| 7 | 7 | "io" |
| 8 | 8 | "os" |
| 9 | + "path/filepath" | |
| 10 | + "strings" | |
| 9 | 11 | |
| 12 | + "mm/internal/config" | |
| 10 | 13 | "mm/internal/scaffold" |
| 11 | 14 | ) |
| 12 | 15 | |
| @@ -18,10 +21,39 @@ import ( | ||
| 18 | 21 | // mm dockerinit |
| 19 | 22 | // mm dockerinit --name bob --yes |
| 20 | 23 | func Main(args []string, stdin io.Reader, stdout io.Writer) int { |
| 24 | + return run(args, stdin, stdout, place{}) | |
| 25 | +} | |
| 26 | + | |
| 27 | +// In is `mm dockerinit` run for another directory than the process's: dir | |
| 28 | +// stands for the current directory — where the files are written and the | |
| 29 | +// agent file is looked for. Nothing is asked: existing files are replaced | |
| 30 | +// when overwrite is set, kept otherwise (exit 1). mm acp-create's | |
| 31 | +// /dockerinit uses it with the session's cwd. | |
| 32 | +// | |
| 33 | +// code := dockerinit.In("/home/me/project", []string{"--name", "bob"}, &out, false) | |
| 34 | +func In(dir string, args []string, out io.Writer, overwrite bool) int { | |
| 35 | + return run(args, strings.NewReader(""), out, place{dir: dir, overwrite: overwrite}) | |
| 36 | +} | |
| 37 | + | |
| 38 | +// place is where run works: dir instead of the working directory when set, | |
| 39 | +// and whether existing files may be replaced without asking. | |
| 40 | +type place struct { | |
| 41 | + dir string | |
| 42 | + overwrite bool | |
| 43 | +} | |
| 44 | + | |
| 45 | +func (p place) resolve(path string) string { | |
| 46 | + if p.dir == "" || filepath.IsAbs(path) { | |
| 47 | + return path | |
| 48 | + } | |
| 49 | + return filepath.Join(p.dir, path) | |
| 50 | +} | |
| 51 | + | |
| 52 | +func run(args []string, stdin io.Reader, stdout io.Writer, at place) int { | |
| 21 | 53 | fs := flag.NewFlagSet("mm dockerinit", flag.ContinueOnError) |
| 22 | 54 | fs.SetOutput(stdout) |
| 23 | 55 | name := fs.String("name", "", "the service's name (default: acp.name in the agent file, else mm)") |
| 24 | - agentFile := fs.String("agent", "agent.yaml", "the agent file to name the service after and to start") | |
| 56 | + agentFile := fs.String("agent", "agent.yaml", "the agent file to name the service after and to start (default: agent.yaml, then .mm/agent.yaml)") | |
| 25 | 57 | yes := fs.Bool("yes", false, "ask nothing: an existing file stops everything, nothing is overwritten") |
| 26 | 58 | if err := fs.Parse(args); err != nil { |
| 27 | 59 | if errors.Is(err, flag.ErrHelp) { |
| @@ -34,14 +66,32 @@ func Main(args []string, stdin io.Reader, stdout io.Writer) int { | ||
| 34 | 66 | return 2 |
| 35 | 67 | } |
| 36 | 68 | |
| 69 | + // Absolute: the .mm folder is recognised by comparing absolute paths. | |
| 70 | + project, err := filepath.Abs(at.resolve(".")) | |
| 71 | + if err != nil { | |
| 72 | + fmt.Fprintln(stdout, "⚠️ "+err.Error()) | |
| 73 | + return 1 | |
| 74 | + } | |
| 75 | + explicit := false | |
| 76 | + fs.Visit(func(f *flag.Flag) { explicit = explicit || f.Name == "agent" }) | |
| 77 | + agentPath := scaffold.FindAgentFile(*agentFile, project, explicit, at.resolve) | |
| 78 | + // A .mm project (ticket 212): its paths are written relative to the | |
| 79 | + // folder of compose.yaml, the project, as compose reads them. | |
| 80 | + mm := scaffold.MMFolderOf(agentPath, project) | |
| 81 | + start := *agentFile | |
| 82 | + if mm != nil { | |
| 83 | + mm.Base = project | |
| 84 | + start = startArg(mm) | |
| 85 | + } | |
| 86 | + | |
| 37 | 87 | chosen := *name |
| 38 | 88 | if chosen == "" { |
| 39 | - chosen = scaffold.AgentName(*agentFile, stdout) | |
| 89 | + chosen = scaffold.AgentName(agentPath, stdout) | |
| 40 | 90 | } |
| 41 | 91 | service := scaffold.Name(chosen) |
| 42 | 92 | files := []scaffold.File{ |
| 43 | - {Path: "Dockerfile", Text: Dockerfile()}, | |
| 44 | - {Path: "compose.yaml", Text: Compose(service, os.Getuid(), os.Getgid())}, | |
| 93 | + {Path: at.resolve("Dockerfile"), Text: Dockerfile(), Replace: at.overwrite}, | |
| 94 | + {Path: at.resolve("compose.yaml"), Text: compose(service, os.Getuid(), os.Getgid(), mm), Replace: at.overwrite}, | |
| 45 | 95 | } |
| 46 | 96 | wrote, err := scaffold.Write(files, stdin, stdout, *yes) |
| 47 | 97 | if err != nil { |
| @@ -49,17 +99,70 @@ func Main(args []string, stdin io.Reader, stdout io.Writer) int { | ||
| 49 | 99 | return 1 |
| 50 | 100 | } |
| 51 | 101 | if wrote { |
| 52 | - printNext(stdout, service, *agentFile) | |
| 102 | + if mm != nil { | |
| 103 | + reportMMFolder(stdout, mm) | |
| 104 | + } | |
| 105 | + printNext(stdout, service, start, missing(agentPath, *agentFile)) | |
| 53 | 106 | } |
| 54 | 107 | return 0 |
| 55 | 108 | } |
| 56 | 109 | |
| 110 | +// startArg is what follows `mm tui` for a .mm project: nothing for | |
| 111 | +// .mm/agent.yaml, which mm finds on its own from the project (mm 1.0.7 and | |
| 112 | +// later; the image holds the latest release), the file otherwise. | |
| 113 | +func startArg(mm *scaffold.MMFolder) string { | |
| 114 | + if filepath.Base(mm.Agent) == config.DefaultPath { | |
| 115 | + return "" | |
| 116 | + } | |
| 117 | + return mm.Rel(mm.Agent) | |
| 118 | +} | |
| 119 | + | |
| 120 | +// missing is the agent file's name when it does not exist, "" otherwise. | |
| 121 | +func missing(agentPath, asGiven string) string { | |
| 122 | + if _, err := os.Stat(agentPath); err != nil { | |
| 123 | + return asGiven | |
| 124 | + } | |
| 125 | + return "" | |
| 126 | +} | |
| 127 | + | |
| 128 | +// reportMMFolder says what the .mm project's mounts are, and creates the | |
| 129 | +// writable folders that do not exist yet: Docker would create them as root, | |
| 130 | +// and the agent, which never runs as root, could not write in them. | |
| 131 | +func reportMMFolder(out io.Writer, mm *scaffold.MMFolder) { | |
| 132 | + fmt.Fprintf(out, "\nThe agent is %s: .mm/ is mounted read-only, writable again:", mm.Rel(mm.Agent)) | |
| 133 | + if len(mm.Writable) == 0 { | |
| 134 | + fmt.Fprint(out, " nothing (no sessions, no trace)") | |
| 135 | + } | |
| 136 | + for _, w := range mm.Writable { | |
| 137 | + fmt.Fprintf(out, " %s (%s)", mm.Rel(w.Path), w.Key) | |
| 138 | + } | |
| 139 | + fmt.Fprintln(out) | |
| 140 | + for _, w := range mm.Writable { | |
| 141 | + if _, err := os.Stat(w.Path); err == nil { | |
| 142 | + continue | |
| 143 | + } | |
| 144 | + if err := os.MkdirAll(w.Path, 0o755); err != nil { | |
| 145 | + fmt.Fprintf(out, "⚠️ cannot create %s: %v — create it before starting the container\n", mm.Rel(w.Path), err) | |
| 146 | + continue | |
| 147 | + } | |
| 148 | + fmt.Fprintf(out, "✅ created %s\n", mm.Rel(w.Path)) | |
| 149 | + } | |
| 150 | +} | |
| 151 | + | |
| 57 | 152 | // printNext says what to run, with the agent file only if there is one. |
| 58 | -func printNext(out io.Writer, service, agentFile string) { | |
| 59 | - if _, err := os.Stat(agentFile); err != nil { | |
| 60 | - fmt.Fprintf(out, "\nNo %s here yet: write one with mm init.\n", agentFile) | |
| 153 | +func printNext(out io.Writer, service, start, missing string) { | |
| 154 | + if missing != "" { | |
| 155 | + fmt.Fprintf(out, "\nNo %s here yet: write one with mm init.\n", missing) | |
| 61 | 156 | } |
| 62 | - fmt.Fprintf(out, "\nKeep a container running:\n docker compose up -d --wait\n docker compose exec %[1]s mm tui %[2]s\n", service, agentFile) | |
| 63 | - fmt.Fprintf(out, "\nOr one session:\n docker compose run --rm %[1]s mm tui %[2]s\n", service, agentFile) | |
| 157 | + fmt.Fprintf(out, "\nKeep a container running:\n docker compose up -d --wait\n docker compose exec %[1]s %[2]s\n", service, tuiCommand(start)) | |
| 158 | + fmt.Fprintf(out, "\nOr one session:\n docker compose run --rm %[1]s %[2]s\n", service, tuiCommand(start)) | |
| 64 | 159 | fmt.Fprintf(out, "\nFor both architectures:\n docker buildx build --platform linux/amd64,linux/arm64 -t <registry>/mm-%s:latest --push .\n", service) |
| 65 | 160 | } |
| 161 | + | |
| 162 | +// tuiCommand is `mm tui`, with the agent file when one is needed. | |
| 163 | +func tuiCommand(agent string) string { | |
| 164 | + if agent == "" { | |
| 165 | + return "mm tui" | |
| 166 | + } | |
| 167 | + return "mm tui " + agent | |
| 168 | +} | |
| @@ -6,7 +6,10 @@ import ( | |||
| 6 | "fmt" | 6 | "fmt" |
| 7 | "io" | 7 | "io" |
| 8 | "os" | 8 | "os" |
| 9 | + "path/filepath" | ||
| 10 | + "strings" | ||
| 9 | 11 | ||
| 12 | + "mm/internal/config" | ||
| 10 | "mm/internal/scaffold" | 13 | "mm/internal/scaffold" |
| 11 | ) | 14 | ) |
| 12 | 15 | ||
| @@ -18,10 +21,39 @@ import ( | |||
| 18 | // mm dockerinit | 21 | // mm dockerinit |
| 19 | // mm dockerinit --name bob --yes | 22 | // mm dockerinit --name bob --yes |
| 20 | func Main(args []string, stdin io.Reader, stdout io.Writer) int { | 23 | func Main(args []string, stdin io.Reader, stdout io.Writer) int { |
| 24 | + return run(args, stdin, stdout, place{}) | ||
| 25 | +} | ||
| 26 | + | ||
| 27 | +// In is `mm dockerinit` run for another directory than the process's: dir | ||
| 28 | +// stands for the current directory — where the files are written and the | ||
| 29 | +// agent file is looked for. Nothing is asked: existing files are replaced | ||
| 30 | +// when overwrite is set, kept otherwise (exit 1). mm acp-create's | ||
| 31 | +// /dockerinit uses it with the session's cwd. | ||
| 32 | +// | ||
| 33 | +// code := dockerinit.In("/home/me/project", []string{"--name", "bob"}, &out, false) | ||
| 34 | +func In(dir string, args []string, out io.Writer, overwrite bool) int { | ||
| 35 | + return run(args, strings.NewReader(""), out, place{dir: dir, overwrite: overwrite}) | ||
| 36 | +} | ||
| 37 | + | ||
| 38 | +// place is where run works: dir instead of the working directory when set, | ||
| 39 | +// and whether existing files may be replaced without asking. | ||
| 40 | +type place struct { | ||
| 41 | + dir string | ||
| 42 | + overwrite bool | ||
| 43 | +} | ||
| 44 | + | ||
| 45 | +func (p place) resolve(path string) string { | ||
| 46 | + if p.dir == "" || filepath.IsAbs(path) { | ||
| 47 | + return path | ||
| 48 | + } | ||
| 49 | + return filepath.Join(p.dir, path) | ||
| 50 | +} | ||
| 51 | + | ||
| 52 | +func run(args []string, stdin io.Reader, stdout io.Writer, at place) int { | ||
| 21 | fs := flag.NewFlagSet("mm dockerinit", flag.ContinueOnError) | 53 | fs := flag.NewFlagSet("mm dockerinit", flag.ContinueOnError) |
| 22 | fs.SetOutput(stdout) | 54 | fs.SetOutput(stdout) |
| 23 | name := fs.String("name", "", "the service's name (default: acp.name in the agent file, else mm)") | 55 | name := fs.String("name", "", "the service's name (default: acp.name in the agent file, else mm)") |
| 24 | - agentFile := fs.String("agent", "agent.yaml", "the agent file to name the service after and to start") | 56 | + agentFile := fs.String("agent", "agent.yaml", "the agent file to name the service after and to start (default: agent.yaml, then .mm/agent.yaml)") |
| 25 | yes := fs.Bool("yes", false, "ask nothing: an existing file stops everything, nothing is overwritten") | 57 | yes := fs.Bool("yes", false, "ask nothing: an existing file stops everything, nothing is overwritten") |
| 26 | if err := fs.Parse(args); err != nil { | 58 | if err := fs.Parse(args); err != nil { |
| 27 | if errors.Is(err, flag.ErrHelp) { | 59 | if errors.Is(err, flag.ErrHelp) { |
| @@ -34,14 +66,32 @@ func Main(args []string, stdin io.Reader, stdout io.Writer) int { | |||
| 34 | return 2 | 66 | return 2 |
| 35 | } | 67 | } |
| 36 | 68 | ||
| 69 | + // Absolute: the .mm folder is recognised by comparing absolute paths. | ||
| 70 | + project, err := filepath.Abs(at.resolve(".")) | ||
| 71 | + if err != nil { | ||
| 72 | + fmt.Fprintln(stdout, "⚠️ "+err.Error()) | ||
| 73 | + return 1 | ||
| 74 | + } | ||
| 75 | + explicit := false | ||
| 76 | + fs.Visit(func(f *flag.Flag) { explicit = explicit || f.Name == "agent" }) | ||
| 77 | + agentPath := scaffold.FindAgentFile(*agentFile, project, explicit, at.resolve) | ||
| 78 | + // A .mm project (ticket 212): its paths are written relative to the | ||
| 79 | + // folder of compose.yaml, the project, as compose reads them. | ||
| 80 | + mm := scaffold.MMFolderOf(agentPath, project) | ||
| 81 | + start := *agentFile | ||
| 82 | + if mm != nil { | ||
| 83 | + mm.Base = project | ||
| 84 | + start = startArg(mm) | ||
| 85 | + } | ||
| 86 | + | ||
| 37 | chosen := *name | 87 | chosen := *name |
| 38 | if chosen == "" { | 88 | if chosen == "" { |
| 39 | - chosen = scaffold.AgentName(*agentFile, stdout) | 89 | + chosen = scaffold.AgentName(agentPath, stdout) |
| 40 | } | 90 | } |
| 41 | service := scaffold.Name(chosen) | 91 | service := scaffold.Name(chosen) |
| 42 | files := []scaffold.File{ | 92 | files := []scaffold.File{ |
| 43 | - {Path: "Dockerfile", Text: Dockerfile()}, | 93 | + {Path: at.resolve("Dockerfile"), Text: Dockerfile(), Replace: at.overwrite}, |
| 44 | - {Path: "compose.yaml", Text: Compose(service, os.Getuid(), os.Getgid())}, | 94 | + {Path: at.resolve("compose.yaml"), Text: compose(service, os.Getuid(), os.Getgid(), mm), Replace: at.overwrite}, |
| 45 | } | 95 | } |
| 46 | wrote, err := scaffold.Write(files, stdin, stdout, *yes) | 96 | wrote, err := scaffold.Write(files, stdin, stdout, *yes) |
| 47 | if err != nil { | 97 | if err != nil { |
| @@ -49,17 +99,70 @@ func Main(args []string, stdin io.Reader, stdout io.Writer) int { | |||
| 49 | return 1 | 99 | return 1 |
| 50 | } | 100 | } |
| 51 | if wrote { | 101 | if wrote { |
| 52 | - printNext(stdout, service, *agentFile) | 102 | + if mm != nil { |
| 103 | + reportMMFolder(stdout, mm) | ||
| 104 | + } | ||
| 105 | + printNext(stdout, service, start, missing(agentPath, *agentFile)) | ||
| 53 | } | 106 | } |
| 54 | return 0 | 107 | return 0 |
| 55 | } | 108 | } |
| 56 | 109 | ||
| 110 | +// startArg is what follows `mm tui` for a .mm project: nothing for | ||
| 111 | +// .mm/agent.yaml, which mm finds on its own from the project (mm 1.0.7 and | ||
| 112 | +// later; the image holds the latest release), the file otherwise. | ||
| 113 | +func startArg(mm *scaffold.MMFolder) string { | ||
| 114 | + if filepath.Base(mm.Agent) == config.DefaultPath { | ||
| 115 | + return "" | ||
| 116 | + } | ||
| 117 | + return mm.Rel(mm.Agent) | ||
| 118 | +} | ||
| 119 | + | ||
| 120 | +// missing is the agent file's name when it does not exist, "" otherwise. | ||
| 121 | +func missing(agentPath, asGiven string) string { | ||
| 122 | + if _, err := os.Stat(agentPath); err != nil { | ||
| 123 | + return asGiven | ||
| 124 | + } | ||
| 125 | + return "" | ||
| 126 | +} | ||
| 127 | + | ||
| 128 | +// reportMMFolder says what the .mm project's mounts are, and creates the | ||
| 129 | +// writable folders that do not exist yet: Docker would create them as root, | ||
| 130 | +// and the agent, which never runs as root, could not write in them. | ||
| 131 | +func reportMMFolder(out io.Writer, mm *scaffold.MMFolder) { | ||
| 132 | + fmt.Fprintf(out, "\nThe agent is %s: .mm/ is mounted read-only, writable again:", mm.Rel(mm.Agent)) | ||
| 133 | + if len(mm.Writable) == 0 { | ||
| 134 | + fmt.Fprint(out, " nothing (no sessions, no trace)") | ||
| 135 | + } | ||
| 136 | + for _, w := range mm.Writable { | ||
| 137 | + fmt.Fprintf(out, " %s (%s)", mm.Rel(w.Path), w.Key) | ||
| 138 | + } | ||
| 139 | + fmt.Fprintln(out) | ||
| 140 | + for _, w := range mm.Writable { | ||
| 141 | + if _, err := os.Stat(w.Path); err == nil { | ||
| 142 | + continue | ||
| 143 | + } | ||
| 144 | + if err := os.MkdirAll(w.Path, 0o755); err != nil { | ||
| 145 | + fmt.Fprintf(out, "⚠️ cannot create %s: %v — create it before starting the container\n", mm.Rel(w.Path), err) | ||
| 146 | + continue | ||
| 147 | + } | ||
| 148 | + fmt.Fprintf(out, "✅ created %s\n", mm.Rel(w.Path)) | ||
| 149 | + } | ||
| 150 | +} | ||
| 151 | + | ||
| 57 | // printNext says what to run, with the agent file only if there is one. | 152 | // printNext says what to run, with the agent file only if there is one. |
| 58 | -func printNext(out io.Writer, service, agentFile string) { | 153 | +func printNext(out io.Writer, service, start, missing string) { |
| 59 | - if _, err := os.Stat(agentFile); err != nil { | 154 | + if missing != "" { |
| 60 | - fmt.Fprintf(out, "\nNo %s here yet: write one with mm init.\n", agentFile) | 155 | + fmt.Fprintf(out, "\nNo %s here yet: write one with mm init.\n", missing) |
| 61 | } | 156 | } |
| 62 | - fmt.Fprintf(out, "\nKeep a container running:\n docker compose up -d --wait\n docker compose exec %[1]s mm tui %[2]s\n", service, agentFile) | 157 | + fmt.Fprintf(out, "\nKeep a container running:\n docker compose up -d --wait\n docker compose exec %[1]s %[2]s\n", service, tuiCommand(start)) |
| 63 | - fmt.Fprintf(out, "\nOr one session:\n docker compose run --rm %[1]s mm tui %[2]s\n", service, agentFile) | 158 | + fmt.Fprintf(out, "\nOr one session:\n docker compose run --rm %[1]s %[2]s\n", service, tuiCommand(start)) |
| 64 | fmt.Fprintf(out, "\nFor both architectures:\n docker buildx build --platform linux/amd64,linux/arm64 -t <registry>/mm-%s:latest --push .\n", service) | 159 | fmt.Fprintf(out, "\nFor both architectures:\n docker buildx build --platform linux/amd64,linux/arm64 -t <registry>/mm-%s:latest --push .\n", service) |
| 65 | } | 160 | } |
| 161 | + | ||
| 162 | +// tuiCommand is `mm tui`, with the agent file when one is needed. | ||
| 163 | +func tuiCommand(agent string) string { | ||
| 164 | + if agent == "" { | ||
| 165 | + return "mm tui" | ||
| 166 | + } | ||
| 167 | + return "mm tui " + agent | ||
| 168 | +} | ||
modified
internal/sbxinit/mmfolder.go +12 -105 | @@ -1,122 +1,29 @@ | ||
| 1 | 1 | package sbxinit |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | - "os" | |
| 5 | 4 | "path/filepath" |
| 6 | 5 | "strings" |
| 7 | 6 | |
| 8 | - "mm/internal/config" | |
| 9 | - | |
| 10 | - "gopkg.in/yaml.v3" | |
| 7 | + "mm/internal/scaffold" | |
| 11 | 8 | ) |
| 12 | 9 | |
| 13 | -// MMFolder is a project whose agent is <workspace>/.mm/<file> (ticket 208): | |
| 14 | -// the sandbox mounts .mm/ read-only over the workspace and gives write access | |
| 15 | -// back to the folders mm writes in. The agent file is not named: the kit | |
| 16 | -// (always the latest, mini-me-kit 0.1.5 or later) ships mm 1.0.7+, which | |
| 17 | -// finds .mm/agent.yaml on its own from the workspace (ticket 209). Its paths | |
| 18 | -// are written relative to Base, the folder of sbxenv.yaml, which is how sbx | |
| 19 | -// reads them: the file does not depend on where the project is. | |
| 20 | -type MMFolder struct { | |
| 21 | - Agent string // the agent file, absolute | |
| 22 | - Writable []Writable // folders under .mm/ that mm writes in | |
| 23 | - Base string // the folder of sbxenv.yaml, absolute; "" = write absolute paths | |
| 24 | -} | |
| 10 | +// MMFolder is scaffold.MMFolder: a .mm project (ticket 208), shared with mm | |
| 11 | +// dockerinit since ticket 212. | |
| 12 | +type MMFolder = scaffold.MMFolder | |
| 25 | 13 | |
| 26 | -// Writable is a folder mounted writable again over the read-only .mm/, and | |
| 27 | -// the agent-file key it comes from. | |
| 28 | -type Writable struct { | |
| 29 | - Path string // absolute | |
| 30 | - Key string // "sessions.dir", "trace.dir" | |
| 31 | -} | |
| 32 | - | |
| 33 | -// mmFolderOf describes the project when agentPath is <workspace>/.mm/<file>, | |
| 34 | -// nil otherwise. The writable folders are the agent file's sessions.dir and | |
| 35 | -// trace.dir when they are on and fall under .mm/ — relative paths read from | |
| 36 | -// the workspace, where mm runs in the sandbox. | |
| 37 | -// | |
| 38 | -// f := mmFolderOf("/p/.mm/agent.yaml", "/p") // Writable: [/p/.mm/sessions] when sessions are on | |
| 39 | -func mmFolderOf(agentPath, workspace string) *MMFolder { | |
| 40 | - agent, err := filepath.Abs(agentPath) | |
| 41 | - if err != nil || filepath.Dir(agent) != filepath.Join(workspace, config.ProjectDir) { | |
| 42 | - return nil | |
| 43 | - } | |
| 44 | - folder := &MMFolder{Agent: agent} | |
| 45 | - data, err := os.ReadFile(agent) | |
| 46 | - if err != nil { | |
| 47 | - return folder | |
| 48 | - } | |
| 49 | - var settings struct { | |
| 50 | - Sessions struct { | |
| 51 | - Enabled bool `yaml:"enabled"` | |
| 52 | - Dir string `yaml:"dir"` | |
| 53 | - } `yaml:"sessions"` | |
| 54 | - Trace struct { | |
| 55 | - Enabled bool `yaml:"enabled"` | |
| 56 | - Dir string `yaml:"dir"` | |
| 57 | - } `yaml:"trace"` | |
| 58 | - } | |
| 59 | - if yaml.Unmarshal(data, &settings) != nil { | |
| 60 | - return folder | |
| 61 | - } | |
| 62 | - // mm's own defaults (config.Cfg) when the file names no dir. | |
| 63 | - for _, w := range []struct { | |
| 64 | - on bool | |
| 65 | - dir, def string | |
| 66 | - key string | |
| 67 | - }{ | |
| 68 | - {settings.Sessions.Enabled, settings.Sessions.Dir, config.Cfg.Sessions.Dir, "sessions.dir"}, | |
| 69 | - {settings.Trace.Enabled, settings.Trace.Dir, config.Cfg.Trace.Dir, "trace.dir"}, | |
| 70 | - } { | |
| 71 | - if !w.on { | |
| 72 | - continue | |
| 73 | - } | |
| 74 | - dir := w.dir | |
| 75 | - if dir == "" { | |
| 76 | - dir = w.def | |
| 77 | - } | |
| 78 | - if !filepath.IsAbs(dir) { | |
| 79 | - dir = filepath.Join(workspace, dir) | |
| 80 | - } | |
| 81 | - if under(dir, filepath.Join(workspace, config.ProjectDir)) { | |
| 82 | - folder.Writable = append(folder.Writable, Writable{Path: filepath.Clean(dir), Key: w.key}) | |
| 83 | - } | |
| 84 | - } | |
| 85 | - return folder | |
| 86 | -} | |
| 87 | - | |
| 88 | -// under says path is dir or inside it. | |
| 89 | -func under(path, dir string) bool { | |
| 90 | - rel, err := filepath.Rel(dir, path) | |
| 91 | - return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) | |
| 92 | -} | |
| 93 | - | |
| 94 | -// rel writes path relative to Base, as sbx reads it: "./.mm", "." — | |
| 95 | -// absolute when there is no Base or the path is not below it. | |
| 96 | -func (m *MMFolder) rel(path string) string { | |
| 97 | - if m.Base == "" { | |
| 98 | - return path | |
| 99 | - } | |
| 100 | - r, err := filepath.Rel(m.Base, path) | |
| 101 | - if err != nil || r == ".." || strings.HasPrefix(r, ".."+string(filepath.Separator)) { | |
| 102 | - return path | |
| 103 | - } | |
| 104 | - if r == "." { | |
| 105 | - return "." | |
| 106 | - } | |
| 107 | - return "./" + filepath.ToSlash(r) | |
| 108 | -} | |
| 14 | +// mmFolderOf is scaffold.MMFolderOf. | |
| 15 | +var mmFolderOf = scaffold.MMFolderOf | |
| 109 | 16 | |
| 110 | -// workspace is the workspace line's value: relative for a .mm project. | |
| 111 | -func (m *MMFolder) workspace(absolute string) string { | |
| 17 | +// workspaceOf is the workspace line's value: relative for a .mm project. | |
| 18 | +func workspaceOf(m *MMFolder, absolute string) string { | |
| 112 | 19 | if m == nil { |
| 113 | 20 | return absolute |
| 114 | 21 | } |
| 115 | - return m.rel(absolute) | |
| 22 | + return m.Rel(absolute) | |
| 116 | 23 | } |
| 117 | 24 | |
| 118 | 25 | // block is the part of sbxenv.yaml a .mm project adds after the workspace. |
| 119 | -func (m *MMFolder) block() string { | |
| 26 | +func block(m *MMFolder) string { | |
| 120 | 27 | if m == nil { |
| 121 | 28 | return "" |
| 122 | 29 | } |
| @@ -129,11 +36,11 @@ func (m *MMFolder) block() string { | ||
| 129 | 36 | # needs a line of its own. After a change to these mounts, re-create the |
| 130 | 37 | # sandbox: sbx env rm, then sbx env create. |
| 131 | 38 | additionalWorkspaces: |
| 132 | - - path: ` + yamlScalar(m.rel(filepath.Dir(m.Agent))) + ` | |
| 39 | + - path: ` + yamlScalar(m.Rel(filepath.Dir(m.Agent))) + ` | |
| 133 | 40 | readOnly: true |
| 134 | 41 | `) |
| 135 | 42 | for _, w := range m.Writable { |
| 136 | - b.WriteString(" - path: " + yamlScalar(m.rel(w.Path)) + " # " + w.Key + "\n") | |
| 43 | + b.WriteString(" - path: " + yamlScalar(m.Rel(w.Path)) + " # " + w.Key + "\n") | |
| 137 | 44 | } |
| 138 | 45 | b.WriteString(` |
| 139 | 46 | # No AGENT_CONFIG: the kit's mm (1.0.7 or later) starts in the workspace and |
| @@ -1,122 +1,29 @@ | |||
| 1 | package sbxinit | 1 | package sbxinit |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | - "os" | ||
| 5 | "path/filepath" | 4 | "path/filepath" |
| 6 | "strings" | 5 | "strings" |
| 7 | 6 | ||
| 8 | - "mm/internal/config" | 7 | + "mm/internal/scaffold" |
| 9 | - | ||
| 10 | - "gopkg.in/yaml.v3" | ||
| 11 | ) | 8 | ) |
| 12 | 9 | ||
| 13 | -// MMFolder is a project whose agent is <workspace>/.mm/<file> (ticket 208): | 10 | +// MMFolder is scaffold.MMFolder: a .mm project (ticket 208), shared with mm |
| 14 | -// the sandbox mounts .mm/ read-only over the workspace and gives write access | 11 | +// dockerinit since ticket 212. |
| 15 | -// back to the folders mm writes in. The agent file is not named: the kit | 12 | +type MMFolder = scaffold.MMFolder |
| 16 | -// (always the latest, mini-me-kit 0.1.5 or later) ships mm 1.0.7+, which | ||
| 17 | -// finds .mm/agent.yaml on its own from the workspace (ticket 209). Its paths | ||
| 18 | -// are written relative to Base, the folder of sbxenv.yaml, which is how sbx | ||
| 19 | -// reads them: the file does not depend on where the project is. | ||
| 20 | -type MMFolder struct { | ||
| 21 | - Agent string // the agent file, absolute | ||
| 22 | - Writable []Writable // folders under .mm/ that mm writes in | ||
| 23 | - Base string // the folder of sbxenv.yaml, absolute; "" = write absolute paths | ||
| 24 | -} | ||
| 25 | 13 | ||
| 26 | -// Writable is a folder mounted writable again over the read-only .mm/, and | 14 | +// mmFolderOf is scaffold.MMFolderOf. |
| 27 | -// the agent-file key it comes from. | 15 | +var mmFolderOf = scaffold.MMFolderOf |
| 28 | -type Writable struct { | ||
| 29 | - Path string // absolute | ||
| 30 | - Key string // "sessions.dir", "trace.dir" | ||
| 31 | -} | ||
| 32 | - | ||
| 33 | -// mmFolderOf describes the project when agentPath is <workspace>/.mm/<file>, | ||
| 34 | -// nil otherwise. The writable folders are the agent file's sessions.dir and | ||
| 35 | -// trace.dir when they are on and fall under .mm/ — relative paths read from | ||
| 36 | -// the workspace, where mm runs in the sandbox. | ||
| 37 | -// | ||
| 38 | -// f := mmFolderOf("/p/.mm/agent.yaml", "/p") // Writable: [/p/.mm/sessions] when sessions are on | ||
| 39 | -func mmFolderOf(agentPath, workspace string) *MMFolder { | ||
| 40 | - agent, err := filepath.Abs(agentPath) | ||
| 41 | - if err != nil || filepath.Dir(agent) != filepath.Join(workspace, config.ProjectDir) { | ||
| 42 | - return nil | ||
| 43 | - } | ||
| 44 | - folder := &MMFolder{Agent: agent} | ||
| 45 | - data, err := os.ReadFile(agent) | ||
| 46 | - if err != nil { | ||
| 47 | - return folder | ||
| 48 | - } | ||
| 49 | - var settings struct { | ||
| 50 | - Sessions struct { | ||
| 51 | - Enabled bool `yaml:"enabled"` | ||
| 52 | - Dir string `yaml:"dir"` | ||
| 53 | - } `yaml:"sessions"` | ||
| 54 | - Trace struct { | ||
| 55 | - Enabled bool `yaml:"enabled"` | ||
| 56 | - Dir string `yaml:"dir"` | ||
| 57 | - } `yaml:"trace"` | ||
| 58 | - } | ||
| 59 | - if yaml.Unmarshal(data, &settings) != nil { | ||
| 60 | - return folder | ||
| 61 | - } | ||
| 62 | - // mm's own defaults (config.Cfg) when the file names no dir. | ||
| 63 | - for _, w := range []struct { | ||
| 64 | - on bool | ||
| 65 | - dir, def string | ||
| 66 | - key string | ||
| 67 | - }{ | ||
| 68 | - {settings.Sessions.Enabled, settings.Sessions.Dir, config.Cfg.Sessions.Dir, "sessions.dir"}, | ||
| 69 | - {settings.Trace.Enabled, settings.Trace.Dir, config.Cfg.Trace.Dir, "trace.dir"}, | ||
| 70 | - } { | ||
| 71 | - if !w.on { | ||
| 72 | - continue | ||
| 73 | - } | ||
| 74 | - dir := w.dir | ||
| 75 | - if dir == "" { | ||
| 76 | - dir = w.def | ||
| 77 | - } | ||
| 78 | - if !filepath.IsAbs(dir) { | ||
| 79 | - dir = filepath.Join(workspace, dir) | ||
| 80 | - } | ||
| 81 | - if under(dir, filepath.Join(workspace, config.ProjectDir)) { | ||
| 82 | - folder.Writable = append(folder.Writable, Writable{Path: filepath.Clean(dir), Key: w.key}) | ||
| 83 | - } | ||
| 84 | - } | ||
| 85 | - return folder | ||
| 86 | -} | ||
| 87 | - | ||
| 88 | -// under says path is dir or inside it. | ||
| 89 | -func under(path, dir string) bool { | ||
| 90 | - rel, err := filepath.Rel(dir, path) | ||
| 91 | - return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) | ||
| 92 | -} | ||
| 93 | - | ||
| 94 | -// rel writes path relative to Base, as sbx reads it: "./.mm", "." — | ||
| 95 | -// absolute when there is no Base or the path is not below it. | ||
| 96 | -func (m *MMFolder) rel(path string) string { | ||
| 97 | - if m.Base == "" { | ||
| 98 | - return path | ||
| 99 | - } | ||
| 100 | - r, err := filepath.Rel(m.Base, path) | ||
| 101 | - if err != nil || r == ".." || strings.HasPrefix(r, ".."+string(filepath.Separator)) { | ||
| 102 | - return path | ||
| 103 | - } | ||
| 104 | - if r == "." { | ||
| 105 | - return "." | ||
| 106 | - } | ||
| 107 | - return "./" + filepath.ToSlash(r) | ||
| 108 | -} | ||
| 109 | 16 | ||
| 110 | -// workspace is the workspace line's value: relative for a .mm project. | 17 | +// workspaceOf is the workspace line's value: relative for a .mm project. |
| 111 | -func (m *MMFolder) workspace(absolute string) string { | 18 | +func workspaceOf(m *MMFolder, absolute string) string { |
| 112 | if m == nil { | 19 | if m == nil { |
| 113 | return absolute | 20 | return absolute |
| 114 | } | 21 | } |
| 115 | - return m.rel(absolute) | 22 | + return m.Rel(absolute) |
| 116 | } | 23 | } |
| 117 | 24 | ||
| 118 | // block is the part of sbxenv.yaml a .mm project adds after the workspace. | 25 | // block is the part of sbxenv.yaml a .mm project adds after the workspace. |
| 119 | -func (m *MMFolder) block() string { | 26 | +func block(m *MMFolder) string { |
| 120 | if m == nil { | 27 | if m == nil { |
| 121 | return "" | 28 | return "" |
| 122 | } | 29 | } |
| @@ -129,11 +36,11 @@ func (m *MMFolder) block() string { | |||
| 129 | # needs a line of its own. After a change to these mounts, re-create the | 36 | # needs a line of its own. After a change to these mounts, re-create the |
| 130 | # sandbox: sbx env rm, then sbx env create. | 37 | # sandbox: sbx env rm, then sbx env create. |
| 131 | additionalWorkspaces: | 38 | additionalWorkspaces: |
| 132 | - - path: ` + yamlScalar(m.rel(filepath.Dir(m.Agent))) + ` | 39 | + - path: ` + yamlScalar(m.Rel(filepath.Dir(m.Agent))) + ` |
| 133 | readOnly: true | 40 | readOnly: true |
| 134 | `) | 41 | `) |
| 135 | for _, w := range m.Writable { | 42 | for _, w := range m.Writable { |
| 136 | - b.WriteString(" - path: " + yamlScalar(m.rel(w.Path)) + " # " + w.Key + "\n") | 43 | + b.WriteString(" - path: " + yamlScalar(m.Rel(w.Path)) + " # " + w.Key + "\n") |
| 137 | } | 44 | } |
| 138 | b.WriteString(` | 45 | b.WriteString(` |
| 139 | # No AGENT_CONFIG: the kit's mm (1.0.7 or later) starts in the workspace and | 46 | # No AGENT_CONFIG: the kit's mm (1.0.7 or later) starts in the workspace and |
modified
internal/sbxinit/sbxinit.go +50 -34 | @@ -14,7 +14,6 @@ import ( | ||
| 14 | 14 | "strings" |
| 15 | 15 | "time" |
| 16 | 16 | |
| 17 | - "mm/internal/config" | |
| 18 | 17 | "mm/internal/scaffold" |
| 19 | 18 | |
| 20 | 19 | "gopkg.in/yaml.v3" |
| @@ -84,8 +83,9 @@ sandboxOptions: | ||
| 84 | 83 | pullPolicy: always |
| 85 | 84 | |
| 86 | 85 | # sbx env create --force |
| 86 | +# sbx env run --force | |
| 87 | 87 | # sbx env rm --force |
| 88 | -`, name, howToURL, kitTag, yamlScalar(mm.workspace(workspace)), mm.block()) | |
| 88 | +`, name, howToURL, kitTag, yamlScalar(workspaceOf(mm, workspace)), block(mm)) | |
| 89 | 89 | return text |
| 90 | 90 | } |
| 91 | 91 | |
| @@ -106,12 +106,47 @@ func yamlScalar(value string) string { | ||
| 106 | 106 | // mm sbxinit |
| 107 | 107 | // mm sbxinit --workspace ./workspace --name bob --yes |
| 108 | 108 | func Main(args []string, stdin io.Reader, stdout io.Writer) int { |
| 109 | - return run(args, stdin, stdout, func() (string, error) { | |
| 110 | - return LatestKitVersion(&http.Client{Timeout: lookupTimeout}, kitReleasesLatest) | |
| 111 | - }) | |
| 109 | + return run(args, stdin, stdout, latestRelease, place{}) | |
| 112 | 110 | } |
| 113 | 111 | |
| 114 | -func run(args []string, stdin io.Reader, stdout io.Writer, latest func() (string, error)) int { | |
| 112 | +// In is `mm sbxinit` run for another directory than the process's: dir | |
| 113 | +// stands for the current directory — where sbxenv.yaml is written, where | |
| 114 | +// relative flags start, where the agent file is looked for. Nothing is | |
| 115 | +// asked: an existing sbxenv.yaml is replaced when overwrite is set, kept | |
| 116 | +// otherwise (exit 1). mm acp-create's /sbxinit uses it with the session's | |
| 117 | +// cwd. | |
| 118 | +// | |
| 119 | +// code := sbxinit.In("/home/me/project", []string{"--name", "bob"}, &out, false) | |
| 120 | +func In(dir string, args []string, out io.Writer, overwrite bool) int { | |
| 121 | + return InWith(dir, args, out, overwrite, latestRelease) | |
| 122 | +} | |
| 123 | + | |
| 124 | +// InWith is In with the kit version read by latest instead of from the | |
| 125 | +// latest release (tests). | |
| 126 | +func InWith(dir string, args []string, out io.Writer, overwrite bool, latest func() (string, error)) int { | |
| 127 | + return run(args, strings.NewReader(""), out, latest, place{dir: dir, overwrite: overwrite}) | |
| 128 | +} | |
| 129 | + | |
| 130 | +func latestRelease() (string, error) { | |
| 131 | + return LatestKitVersion(&http.Client{Timeout: lookupTimeout}, kitReleasesLatest) | |
| 132 | +} | |
| 133 | + | |
| 134 | +// place is where run works: dir instead of the working directory when set, | |
| 135 | +// and whether an existing file may be replaced without asking. | |
| 136 | +type place struct { | |
| 137 | + dir string | |
| 138 | + overwrite bool | |
| 139 | +} | |
| 140 | + | |
| 141 | +// resolve is path p seen from the place's directory. | |
| 142 | +func (p place) resolve(path string) string { | |
| 143 | + if p.dir == "" || filepath.IsAbs(path) { | |
| 144 | + return path | |
| 145 | + } | |
| 146 | + return filepath.Join(p.dir, path) | |
| 147 | +} | |
| 148 | + | |
| 149 | +func run(args []string, stdin io.Reader, stdout io.Writer, latest func() (string, error), at place) int { | |
| 115 | 150 | fs := flag.NewFlagSet("mm sbxinit", flag.ContinueOnError) |
| 116 | 151 | fs.SetOutput(stdout) |
| 117 | 152 | name := fs.String("name", "", "the agent's name; the sandbox is <name>-sbx (default: acp.name in the agent file, else mm)") |
| @@ -128,7 +163,7 @@ func run(args []string, stdin io.Reader, stdout io.Writer, latest func() (string | ||
| 128 | 163 | fmt.Fprintf(stdout, "⚠️ mm sbxinit takes no argument, got %q\n", fs.Arg(0)) |
| 129 | 164 | return 2 |
| 130 | 165 | } |
| 131 | - absolute, err := filepath.Abs(*workspace) | |
| 166 | + absolute, err := filepath.Abs(at.resolve(*workspace)) | |
| 132 | 167 | if err != nil { |
| 133 | 168 | fmt.Fprintln(stdout, "⚠️ "+err.Error()) |
| 134 | 169 | return 1 |
| @@ -136,19 +171,22 @@ func run(args []string, stdin io.Reader, stdout io.Writer, latest func() (string | ||
| 136 | 171 | |
| 137 | 172 | explicit := false |
| 138 | 173 | fs.Visit(func(f *flag.Flag) { explicit = explicit || f.Name == "agent" }) |
| 139 | - agentPath := findAgentFile(*agentFile, absolute, explicit) | |
| 174 | + agentPath := scaffold.FindAgentFile(*agentFile, absolute, explicit, at.resolve) | |
| 140 | 175 | mmFolder := mmFolderOf(agentPath, absolute) |
| 141 | 176 | if mmFolder != nil { |
| 142 | 177 | // The paths of a .mm project's file are relative to the folder of |
| 143 | 178 | // sbxenv.yaml, as sbx reads them: the file can be committed. |
| 144 | - mmFolder.Base, _ = os.Getwd() | |
| 179 | + mmFolder.Base = at.dir | |
| 180 | + if mmFolder.Base == "" { | |
| 181 | + mmFolder.Base, _ = os.Getwd() | |
| 182 | + } | |
| 145 | 183 | } |
| 146 | 184 | chosen := *name |
| 147 | 185 | if chosen == "" { |
| 148 | 186 | chosen = scaffold.AgentName(agentPath, stdout) |
| 149 | 187 | } |
| 150 | 188 | sandbox := scaffold.Name(chosen) + "-sbx" |
| 151 | - files := []scaffold.File{{Path: "sbxenv.yaml", Text: Render(sandbox, kitTag(latest, stdout), absolute, mmFolder)}} | |
| 189 | + files := []scaffold.File{{Path: at.resolve("sbxenv.yaml"), Replace: at.overwrite, Text: Render(sandbox, kitTag(latest, stdout), absolute, mmFolder)}} | |
| 152 | 190 | wrote, err := scaffold.Write(files, stdin, stdout, *yes) |
| 153 | 191 | if err != nil { |
| 154 | 192 | fmt.Fprintln(stdout, "⚠️ "+err.Error()) |
| @@ -162,11 +200,11 @@ func run(args []string, stdin io.Reader, stdout io.Writer, latest func() (string | ||
| 162 | 200 | fmt.Fprint(stdout, " nothing (no sessions, no trace)") |
| 163 | 201 | } |
| 164 | 202 | for _, w := range mmFolder.Writable { |
| 165 | - fmt.Fprintf(stdout, " %s (%s)", mmFolder.rel(w.Path), w.Key) | |
| 203 | + fmt.Fprintf(stdout, " %s (%s)", mmFolder.Rel(w.Path), w.Key) | |
| 166 | 204 | } |
| 167 | 205 | fmt.Fprintln(stdout) |
| 168 | 206 | } |
| 169 | - fmt.Fprintf(stdout, "\nCreate the sandbox %s:\n sbx env create --force\n\nRemove it:\n sbx env rm --force\n", sandbox) | |
| 207 | + fmt.Fprintf(stdout, "\nCreate the sandbox %[1]s:\n sbx env create --force\n\nRun the sandbox %[1]s:\n sbx env run --force\n\nRemove it:\n sbx env rm --force\n", sandbox) | |
| 170 | 208 | } |
| 171 | 209 | return 0 |
| 172 | 210 | } |
| @@ -181,28 +219,6 @@ func kitTag(latest func() (string, error), stdout io.Writer) string { | ||
| 181 | 219 | return version |
| 182 | 220 | } |
| 183 | 221 | |
| 184 | -// findAgentFile looks in the current directory, then in the workspace, where | |
| 185 | -// the sandbox will read it. Without -agent it looks for agent.yaml, then for | |
| 186 | -// .mm/agent.yaml — the order mm itself follows (config.DefaultPath, then the | |
| 187 | -// .mm folder). It returns the first path, even missing, otherwise. | |
| 188 | -func findAgentFile(agentFile, workspace string, explicit bool) string { | |
| 189 | - if filepath.IsAbs(agentFile) { | |
| 190 | - return agentFile | |
| 191 | - } | |
| 192 | - names := []string{agentFile} | |
| 193 | - if !explicit { | |
| 194 | - names = append(names, filepath.Join(config.ProjectDir, config.DefaultPath)) | |
| 195 | - } | |
| 196 | - for _, name := range names { | |
| 197 | - for _, path := range []string{name, filepath.Join(workspace, name)} { | |
| 198 | - if _, err := os.Stat(path); err == nil { | |
| 199 | - return path | |
| 200 | - } | |
| 201 | - } | |
| 202 | - } | |
| 203 | - return agentFile | |
| 204 | -} | |
| 205 | - | |
| 206 | 222 | // warnAboutWorkspace reports what would only show inside the sandbox: a |
| 207 | 223 | // workspace that does not exist, an agent file the sandbox cannot see. |
| 208 | 224 | func warnAboutWorkspace(out io.Writer, workspace, agentPath string) { |
| @@ -14,7 +14,6 @@ import ( | |||
| 14 | "strings" | 14 | "strings" |
| 15 | "time" | 15 | "time" |
| 16 | 16 | ||
| 17 | - "mm/internal/config" | ||
| 18 | "mm/internal/scaffold" | 17 | "mm/internal/scaffold" |
| 19 | 18 | ||
| 20 | "gopkg.in/yaml.v3" | 19 | "gopkg.in/yaml.v3" |
| @@ -84,8 +83,9 @@ sandboxOptions: | |||
| 84 | pullPolicy: always | 83 | pullPolicy: always |
| 85 | 84 | ||
| 86 | # sbx env create --force | 85 | # sbx env create --force |
| 86 | +# sbx env run --force | ||
| 87 | # sbx env rm --force | 87 | # sbx env rm --force |
| 88 | -`, name, howToURL, kitTag, yamlScalar(mm.workspace(workspace)), mm.block()) | 88 | +`, name, howToURL, kitTag, yamlScalar(workspaceOf(mm, workspace)), block(mm)) |
| 89 | return text | 89 | return text |
| 90 | } | 90 | } |
| 91 | 91 | ||
| @@ -106,12 +106,47 @@ func yamlScalar(value string) string { | |||
| 106 | // mm sbxinit | 106 | // mm sbxinit |
| 107 | // mm sbxinit --workspace ./workspace --name bob --yes | 107 | // mm sbxinit --workspace ./workspace --name bob --yes |
| 108 | func Main(args []string, stdin io.Reader, stdout io.Writer) int { | 108 | func Main(args []string, stdin io.Reader, stdout io.Writer) int { |
| 109 | - return run(args, stdin, stdout, func() (string, error) { | 109 | + return run(args, stdin, stdout, latestRelease, place{}) |
| 110 | - return LatestKitVersion(&http.Client{Timeout: lookupTimeout}, kitReleasesLatest) | ||
| 111 | - }) | ||
| 112 | } | 110 | } |
| 113 | 111 | ||
| 114 | -func run(args []string, stdin io.Reader, stdout io.Writer, latest func() (string, error)) int { | 112 | +// In is `mm sbxinit` run for another directory than the process's: dir |
| 113 | +// stands for the current directory — where sbxenv.yaml is written, where | ||
| 114 | +// relative flags start, where the agent file is looked for. Nothing is | ||
| 115 | +// asked: an existing sbxenv.yaml is replaced when overwrite is set, kept | ||
| 116 | +// otherwise (exit 1). mm acp-create's /sbxinit uses it with the session's | ||
| 117 | +// cwd. | ||
| 118 | +// | ||
| 119 | +// code := sbxinit.In("/home/me/project", []string{"--name", "bob"}, &out, false) | ||
| 120 | +func In(dir string, args []string, out io.Writer, overwrite bool) int { | ||
| 121 | + return InWith(dir, args, out, overwrite, latestRelease) | ||
| 122 | +} | ||
| 123 | + | ||
| 124 | +// InWith is In with the kit version read by latest instead of from the | ||
| 125 | +// latest release (tests). | ||
| 126 | +func InWith(dir string, args []string, out io.Writer, overwrite bool, latest func() (string, error)) int { | ||
| 127 | + return run(args, strings.NewReader(""), out, latest, place{dir: dir, overwrite: overwrite}) | ||
| 128 | +} | ||
| 129 | + | ||
| 130 | +func latestRelease() (string, error) { | ||
| 131 | + return LatestKitVersion(&http.Client{Timeout: lookupTimeout}, kitReleasesLatest) | ||
| 132 | +} | ||
| 133 | + | ||
| 134 | +// place is where run works: dir instead of the working directory when set, | ||
| 135 | +// and whether an existing file may be replaced without asking. | ||
| 136 | +type place struct { | ||
| 137 | + dir string | ||
| 138 | + overwrite bool | ||
| 139 | +} | ||
| 140 | + | ||
| 141 | +// resolve is path p seen from the place's directory. | ||
| 142 | +func (p place) resolve(path string) string { | ||
| 143 | + if p.dir == "" || filepath.IsAbs(path) { | ||
| 144 | + return path | ||
| 145 | + } | ||
| 146 | + return filepath.Join(p.dir, path) | ||
| 147 | +} | ||
| 148 | + | ||
| 149 | +func run(args []string, stdin io.Reader, stdout io.Writer, latest func() (string, error), at place) int { | ||
| 115 | fs := flag.NewFlagSet("mm sbxinit", flag.ContinueOnError) | 150 | fs := flag.NewFlagSet("mm sbxinit", flag.ContinueOnError) |
| 116 | fs.SetOutput(stdout) | 151 | fs.SetOutput(stdout) |
| 117 | name := fs.String("name", "", "the agent's name; the sandbox is <name>-sbx (default: acp.name in the agent file, else mm)") | 152 | name := fs.String("name", "", "the agent's name; the sandbox is <name>-sbx (default: acp.name in the agent file, else mm)") |
| @@ -128,7 +163,7 @@ func run(args []string, stdin io.Reader, stdout io.Writer, latest func() (string | |||
| 128 | fmt.Fprintf(stdout, "⚠️ mm sbxinit takes no argument, got %q\n", fs.Arg(0)) | 163 | fmt.Fprintf(stdout, "⚠️ mm sbxinit takes no argument, got %q\n", fs.Arg(0)) |
| 129 | return 2 | 164 | return 2 |
| 130 | } | 165 | } |
| 131 | - absolute, err := filepath.Abs(*workspace) | 166 | + absolute, err := filepath.Abs(at.resolve(*workspace)) |
| 132 | if err != nil { | 167 | if err != nil { |
| 133 | fmt.Fprintln(stdout, "⚠️ "+err.Error()) | 168 | fmt.Fprintln(stdout, "⚠️ "+err.Error()) |
| 134 | return 1 | 169 | return 1 |
| @@ -136,19 +171,22 @@ func run(args []string, stdin io.Reader, stdout io.Writer, latest func() (string | |||
| 136 | 171 | ||
| 137 | explicit := false | 172 | explicit := false |
| 138 | fs.Visit(func(f *flag.Flag) { explicit = explicit || f.Name == "agent" }) | 173 | fs.Visit(func(f *flag.Flag) { explicit = explicit || f.Name == "agent" }) |
| 139 | - agentPath := findAgentFile(*agentFile, absolute, explicit) | 174 | + agentPath := scaffold.FindAgentFile(*agentFile, absolute, explicit, at.resolve) |
| 140 | mmFolder := mmFolderOf(agentPath, absolute) | 175 | mmFolder := mmFolderOf(agentPath, absolute) |
| 141 | if mmFolder != nil { | 176 | if mmFolder != nil { |
| 142 | // The paths of a .mm project's file are relative to the folder of | 177 | // The paths of a .mm project's file are relative to the folder of |
| 143 | // sbxenv.yaml, as sbx reads them: the file can be committed. | 178 | // sbxenv.yaml, as sbx reads them: the file can be committed. |
| 144 | - mmFolder.Base, _ = os.Getwd() | 179 | + mmFolder.Base = at.dir |
| 180 | + if mmFolder.Base == "" { | ||
| 181 | + mmFolder.Base, _ = os.Getwd() | ||
| 182 | + } | ||
| 145 | } | 183 | } |
| 146 | chosen := *name | 184 | chosen := *name |
| 147 | if chosen == "" { | 185 | if chosen == "" { |
| 148 | chosen = scaffold.AgentName(agentPath, stdout) | 186 | chosen = scaffold.AgentName(agentPath, stdout) |
| 149 | } | 187 | } |
| 150 | sandbox := scaffold.Name(chosen) + "-sbx" | 188 | sandbox := scaffold.Name(chosen) + "-sbx" |
| 151 | - files := []scaffold.File{{Path: "sbxenv.yaml", Text: Render(sandbox, kitTag(latest, stdout), absolute, mmFolder)}} | 189 | + files := []scaffold.File{{Path: at.resolve("sbxenv.yaml"), Replace: at.overwrite, Text: Render(sandbox, kitTag(latest, stdout), absolute, mmFolder)}} |
| 152 | wrote, err := scaffold.Write(files, stdin, stdout, *yes) | 190 | wrote, err := scaffold.Write(files, stdin, stdout, *yes) |
| 153 | if err != nil { | 191 | if err != nil { |
| 154 | fmt.Fprintln(stdout, "⚠️ "+err.Error()) | 192 | fmt.Fprintln(stdout, "⚠️ "+err.Error()) |
| @@ -162,11 +200,11 @@ func run(args []string, stdin io.Reader, stdout io.Writer, latest func() (string | |||
| 162 | fmt.Fprint(stdout, " nothing (no sessions, no trace)") | 200 | fmt.Fprint(stdout, " nothing (no sessions, no trace)") |
| 163 | } | 201 | } |
| 164 | for _, w := range mmFolder.Writable { | 202 | for _, w := range mmFolder.Writable { |
| 165 | - fmt.Fprintf(stdout, " %s (%s)", mmFolder.rel(w.Path), w.Key) | 203 | + fmt.Fprintf(stdout, " %s (%s)", mmFolder.Rel(w.Path), w.Key) |
| 166 | } | 204 | } |
| 167 | fmt.Fprintln(stdout) | 205 | fmt.Fprintln(stdout) |
| 168 | } | 206 | } |
| 169 | - fmt.Fprintf(stdout, "\nCreate the sandbox %s:\n sbx env create --force\n\nRemove it:\n sbx env rm --force\n", sandbox) | 207 | + fmt.Fprintf(stdout, "\nCreate the sandbox %[1]s:\n sbx env create --force\n\nRun the sandbox %[1]s:\n sbx env run --force\n\nRemove it:\n sbx env rm --force\n", sandbox) |
| 170 | } | 208 | } |
| 171 | return 0 | 209 | return 0 |
| 172 | } | 210 | } |
| @@ -181,28 +219,6 @@ func kitTag(latest func() (string, error), stdout io.Writer) string { | |||
| 181 | return version | 219 | return version |
| 182 | } | 220 | } |
| 183 | 221 | ||
| 184 | -// findAgentFile looks in the current directory, then in the workspace, where | ||
| 185 | -// the sandbox will read it. Without -agent it looks for agent.yaml, then for | ||
| 186 | -// .mm/agent.yaml — the order mm itself follows (config.DefaultPath, then the | ||
| 187 | -// .mm folder). It returns the first path, even missing, otherwise. | ||
| 188 | -func findAgentFile(agentFile, workspace string, explicit bool) string { | ||
| 189 | - if filepath.IsAbs(agentFile) { | ||
| 190 | - return agentFile | ||
| 191 | - } | ||
| 192 | - names := []string{agentFile} | ||
| 193 | - if !explicit { | ||
| 194 | - names = append(names, filepath.Join(config.ProjectDir, config.DefaultPath)) | ||
| 195 | - } | ||
| 196 | - for _, name := range names { | ||
| 197 | - for _, path := range []string{name, filepath.Join(workspace, name)} { | ||
| 198 | - if _, err := os.Stat(path); err == nil { | ||
| 199 | - return path | ||
| 200 | - } | ||
| 201 | - } | ||
| 202 | - } | ||
| 203 | - return agentFile | ||
| 204 | -} | ||
| 205 | - | ||
| 206 | // warnAboutWorkspace reports what would only show inside the sandbox: a | 222 | // warnAboutWorkspace reports what would only show inside the sandbox: a |
| 207 | // workspace that does not exist, an agent file the sandbox cannot see. | 223 | // workspace that does not exist, an agent file the sandbox cannot see. |
| 208 | func warnAboutWorkspace(out io.Writer, workspace, agentPath string) { | 224 | func warnAboutWorkspace(out io.Writer, workspace, agentPath string) { |
modified
internal/sbxinit/sbxinit_test.go +2 -2 | @@ -117,7 +117,7 @@ func runIn(t *testing.T, files map[string]string, stdin string, latest func() (s | ||
| 117 | 117 | } |
| 118 | 118 | } |
| 119 | 119 | var out bytes.Buffer |
| 120 | - code := run(args, strings.NewReader(stdin), &out, latest) | |
| 120 | + code := run(args, strings.NewReader(stdin), &out, latest, place{}) | |
| 121 | 121 | return dir, code, out.String() |
| 122 | 122 | } |
| 123 | 123 | |
| @@ -141,7 +141,7 @@ func TestMainDefaultsToTheCurrentDirectoryAndTheAgentName(t *testing.T) { | ||
| 141 | 141 | if got.Name != "charles-sbx" || got.Workspace != dir || got.Kits[0] != "docker.io/k33g/mini-me-kit:0.1.2" { |
| 142 | 142 | t.Errorf("%+v", got) |
| 143 | 143 | } |
| 144 | - if !strings.Contains(out, "sbx env create --force") || strings.Contains(out, "⚠️") { | |
| 144 | + if !strings.Contains(out, "sbx env create --force") || !strings.Contains(out, "Run the sandbox charles-sbx:\n sbx env run --force") || strings.Contains(out, "⚠️") { | |
| 145 | 145 | t.Errorf("output:\n%s", out) |
| 146 | 146 | } |
| 147 | 147 | } |
| @@ -117,7 +117,7 @@ func runIn(t *testing.T, files map[string]string, stdin string, latest func() (s | |||
| 117 | } | 117 | } |
| 118 | } | 118 | } |
| 119 | var out bytes.Buffer | 119 | var out bytes.Buffer |
| 120 | - code := run(args, strings.NewReader(stdin), &out, latest) | 120 | + code := run(args, strings.NewReader(stdin), &out, latest, place{}) |
| 121 | return dir, code, out.String() | 121 | return dir, code, out.String() |
| 122 | } | 122 | } |
| 123 | 123 | ||
| @@ -141,7 +141,7 @@ func TestMainDefaultsToTheCurrentDirectoryAndTheAgentName(t *testing.T) { | |||
| 141 | if got.Name != "charles-sbx" || got.Workspace != dir || got.Kits[0] != "docker.io/k33g/mini-me-kit:0.1.2" { | 141 | if got.Name != "charles-sbx" || got.Workspace != dir || got.Kits[0] != "docker.io/k33g/mini-me-kit:0.1.2" { |
| 142 | t.Errorf("%+v", got) | 142 | t.Errorf("%+v", got) |
| 143 | } | 143 | } |
| 144 | - if !strings.Contains(out, "sbx env create --force") || strings.Contains(out, "⚠️") { | 144 | + if !strings.Contains(out, "sbx env create --force") || !strings.Contains(out, "Run the sandbox charles-sbx:\n sbx env run --force") || strings.Contains(out, "⚠️") { |
| 145 | t.Errorf("output:\n%s", out) | 145 | t.Errorf("output:\n%s", out) |
| 146 | } | 146 | } |
| 147 | } | 147 | } |
added
internal/scaffold/mmfolder.go +134 -0 | new file mode 100644 | ||
| @@ -0,0 +1,134 @@ | ||
| 1 | +package scaffold | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "os" | |
| 5 | + "path/filepath" | |
| 6 | + "strings" | |
| 7 | + | |
| 8 | + "mm/internal/config" | |
| 9 | + | |
| 10 | + "gopkg.in/yaml.v3" | |
| 11 | +) | |
| 12 | + | |
| 13 | +// MMFolder is a project whose agent is <workspace>/.mm/<file> (tickets 208, | |
| 14 | +// 212): mm sbxinit and mm dockerinit mount .mm/ read-only over the workspace | |
| 15 | +// and give write access back to the folders mm writes in. The agent file is not named: the kit | |
| 16 | +// (always the latest, mini-me-kit 0.1.5 or later) ships mm 1.0.7+, which | |
| 17 | +// finds .mm/agent.yaml on its own from the workspace (ticket 209). Its paths | |
| 18 | +// are written relative to Base, the folder of sbxenv.yaml or compose.yaml, | |
| 19 | +// which is how sbx and compose read them: the file does not depend on where the project is. | |
| 20 | +type MMFolder struct { | |
| 21 | + Agent string // the agent file, absolute | |
| 22 | + Writable []Writable // folders under .mm/ that mm writes in | |
| 23 | + Base string // the folder of the written file, absolute; "" = write absolute paths | |
| 24 | +} | |
| 25 | + | |
| 26 | +// Writable is a folder mounted writable again over the read-only .mm/, and | |
| 27 | +// the agent-file key it comes from. | |
| 28 | +type Writable struct { | |
| 29 | + Path string // absolute | |
| 30 | + Key string // "sessions.dir", "trace.dir" | |
| 31 | +} | |
| 32 | + | |
| 33 | +// MMFolderOf describes the project when agentPath is <workspace>/.mm/<file>, | |
| 34 | +// nil otherwise. The writable folders are the agent file's sessions.dir and | |
| 35 | +// trace.dir when they are on and fall under .mm/ — relative paths read from | |
| 36 | +// the workspace, where mm runs in the sandbox. | |
| 37 | +// | |
| 38 | +// f := scaffold.MMFolderOf("/p/.mm/agent.yaml", "/p") // Writable: [/p/.mm/sessions] when sessions are on | |
| 39 | +func MMFolderOf(agentPath, workspace string) *MMFolder { | |
| 40 | + agent, err := filepath.Abs(agentPath) | |
| 41 | + if err != nil || filepath.Dir(agent) != filepath.Join(workspace, config.ProjectDir) { | |
| 42 | + return nil | |
| 43 | + } | |
| 44 | + folder := &MMFolder{Agent: agent} | |
| 45 | + data, err := os.ReadFile(agent) | |
| 46 | + if err != nil { | |
| 47 | + return folder | |
| 48 | + } | |
| 49 | + var settings struct { | |
| 50 | + Sessions struct { | |
| 51 | + Enabled bool `yaml:"enabled"` | |
| 52 | + Dir string `yaml:"dir"` | |
| 53 | + } `yaml:"sessions"` | |
| 54 | + Trace struct { | |
| 55 | + Enabled bool `yaml:"enabled"` | |
| 56 | + Dir string `yaml:"dir"` | |
| 57 | + } `yaml:"trace"` | |
| 58 | + } | |
| 59 | + if yaml.Unmarshal(data, &settings) != nil { | |
| 60 | + return folder | |
| 61 | + } | |
| 62 | + // mm's own defaults (config.Cfg) when the file names no dir. | |
| 63 | + for _, w := range []struct { | |
| 64 | + on bool | |
| 65 | + dir, def string | |
| 66 | + key string | |
| 67 | + }{ | |
| 68 | + {settings.Sessions.Enabled, settings.Sessions.Dir, config.Cfg.Sessions.Dir, "sessions.dir"}, | |
| 69 | + {settings.Trace.Enabled, settings.Trace.Dir, config.Cfg.Trace.Dir, "trace.dir"}, | |
| 70 | + } { | |
| 71 | + if !w.on { | |
| 72 | + continue | |
| 73 | + } | |
| 74 | + dir := w.dir | |
| 75 | + if dir == "" { | |
| 76 | + dir = w.def | |
| 77 | + } | |
| 78 | + if !filepath.IsAbs(dir) { | |
| 79 | + dir = filepath.Join(workspace, dir) | |
| 80 | + } | |
| 81 | + if under(dir, filepath.Join(workspace, config.ProjectDir)) { | |
| 82 | + folder.Writable = append(folder.Writable, Writable{Path: filepath.Clean(dir), Key: w.key}) | |
| 83 | + } | |
| 84 | + } | |
| 85 | + return folder | |
| 86 | +} | |
| 87 | + | |
| 88 | +// under says path is dir or inside it. | |
| 89 | +func under(path, dir string) bool { | |
| 90 | + rel, err := filepath.Rel(dir, path) | |
| 91 | + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) | |
| 92 | +} | |
| 93 | + | |
| 94 | +// Rel writes path relative to Base, as sbx and compose read it: "./.mm", "." — | |
| 95 | +// absolute when there is no Base or the path is not below it. | |
| 96 | +func (m *MMFolder) Rel(path string) string { | |
| 97 | + if m.Base == "" { | |
| 98 | + return path | |
| 99 | + } | |
| 100 | + r, err := filepath.Rel(m.Base, path) | |
| 101 | + if err != nil || r == ".." || strings.HasPrefix(r, ".."+string(filepath.Separator)) { | |
| 102 | + return path | |
| 103 | + } | |
| 104 | + if r == "." { | |
| 105 | + return "." | |
| 106 | + } | |
| 107 | + return "./" + filepath.ToSlash(r) | |
| 108 | +} | |
| 109 | + | |
| 110 | +// FindAgentFile is the agent file a scaffolding command describes: an | |
| 111 | +// absolute agentFile as is; otherwise it is looked for in the current | |
| 112 | +// directory — resolve turns a relative path into the caller's — then in the | |
| 113 | +// workspace. Without an explicit --agent, .mm/agent.yaml is looked for too, | |
| 114 | +// after agentFile: the order mm itself follows (config.DefaultPath, then the | |
| 115 | +// .mm folder). It returns the first path found, or agentFile resolved. | |
| 116 | +// | |
| 117 | +// path := scaffold.FindAgentFile("agent.yaml", "/p", false, func(p string) string { return p }) | |
| 118 | +func FindAgentFile(agentFile, workspace string, explicit bool, resolve func(string) string) string { | |
| 119 | + if filepath.IsAbs(agentFile) { | |
| 120 | + return agentFile | |
| 121 | + } | |
| 122 | + names := []string{agentFile} | |
| 123 | + if !explicit { | |
| 124 | + names = append(names, filepath.Join(config.ProjectDir, config.DefaultPath)) | |
| 125 | + } | |
| 126 | + for _, name := range names { | |
| 127 | + for _, path := range []string{resolve(name), filepath.Join(workspace, name)} { | |
| 128 | + if _, err := os.Stat(path); err == nil { | |
| 129 | + return path | |
| 130 | + } | |
| 131 | + } | |
| 132 | + } | |
| 133 | + return resolve(agentFile) | |
| 134 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,134 @@ | |||
| 1 | +package scaffold | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "os" | ||
| 5 | + "path/filepath" | ||
| 6 | + "strings" | ||
| 7 | + | ||
| 8 | + "mm/internal/config" | ||
| 9 | + | ||
| 10 | + "gopkg.in/yaml.v3" | ||
| 11 | +) | ||
| 12 | + | ||
| 13 | +// MMFolder is a project whose agent is <workspace>/.mm/<file> (tickets 208, | ||
| 14 | +// 212): mm sbxinit and mm dockerinit mount .mm/ read-only over the workspace | ||
| 15 | +// and give write access back to the folders mm writes in. The agent file is not named: the kit | ||
| 16 | +// (always the latest, mini-me-kit 0.1.5 or later) ships mm 1.0.7+, which | ||
| 17 | +// finds .mm/agent.yaml on its own from the workspace (ticket 209). Its paths | ||
| 18 | +// are written relative to Base, the folder of sbxenv.yaml or compose.yaml, | ||
| 19 | +// which is how sbx and compose read them: the file does not depend on where the project is. | ||
| 20 | +type MMFolder struct { | ||
| 21 | + Agent string // the agent file, absolute | ||
| 22 | + Writable []Writable // folders under .mm/ that mm writes in | ||
| 23 | + Base string // the folder of the written file, absolute; "" = write absolute paths | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +// Writable is a folder mounted writable again over the read-only .mm/, and | ||
| 27 | +// the agent-file key it comes from. | ||
| 28 | +type Writable struct { | ||
| 29 | + Path string // absolute | ||
| 30 | + Key string // "sessions.dir", "trace.dir" | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +// MMFolderOf describes the project when agentPath is <workspace>/.mm/<file>, | ||
| 34 | +// nil otherwise. The writable folders are the agent file's sessions.dir and | ||
| 35 | +// trace.dir when they are on and fall under .mm/ — relative paths read from | ||
| 36 | +// the workspace, where mm runs in the sandbox. | ||
| 37 | +// | ||
| 38 | +// f := scaffold.MMFolderOf("/p/.mm/agent.yaml", "/p") // Writable: [/p/.mm/sessions] when sessions are on | ||
| 39 | +func MMFolderOf(agentPath, workspace string) *MMFolder { | ||
| 40 | + agent, err := filepath.Abs(agentPath) | ||
| 41 | + if err != nil || filepath.Dir(agent) != filepath.Join(workspace, config.ProjectDir) { | ||
| 42 | + return nil | ||
| 43 | + } | ||
| 44 | + folder := &MMFolder{Agent: agent} | ||
| 45 | + data, err := os.ReadFile(agent) | ||
| 46 | + if err != nil { | ||
| 47 | + return folder | ||
| 48 | + } | ||
| 49 | + var settings struct { | ||
| 50 | + Sessions struct { | ||
| 51 | + Enabled bool `yaml:"enabled"` | ||
| 52 | + Dir string `yaml:"dir"` | ||
| 53 | + } `yaml:"sessions"` | ||
| 54 | + Trace struct { | ||
| 55 | + Enabled bool `yaml:"enabled"` | ||
| 56 | + Dir string `yaml:"dir"` | ||
| 57 | + } `yaml:"trace"` | ||
| 58 | + } | ||
| 59 | + if yaml.Unmarshal(data, &settings) != nil { | ||
| 60 | + return folder | ||
| 61 | + } | ||
| 62 | + // mm's own defaults (config.Cfg) when the file names no dir. | ||
| 63 | + for _, w := range []struct { | ||
| 64 | + on bool | ||
| 65 | + dir, def string | ||
| 66 | + key string | ||
| 67 | + }{ | ||
| 68 | + {settings.Sessions.Enabled, settings.Sessions.Dir, config.Cfg.Sessions.Dir, "sessions.dir"}, | ||
| 69 | + {settings.Trace.Enabled, settings.Trace.Dir, config.Cfg.Trace.Dir, "trace.dir"}, | ||
| 70 | + } { | ||
| 71 | + if !w.on { | ||
| 72 | + continue | ||
| 73 | + } | ||
| 74 | + dir := w.dir | ||
| 75 | + if dir == "" { | ||
| 76 | + dir = w.def | ||
| 77 | + } | ||
| 78 | + if !filepath.IsAbs(dir) { | ||
| 79 | + dir = filepath.Join(workspace, dir) | ||
| 80 | + } | ||
| 81 | + if under(dir, filepath.Join(workspace, config.ProjectDir)) { | ||
| 82 | + folder.Writable = append(folder.Writable, Writable{Path: filepath.Clean(dir), Key: w.key}) | ||
| 83 | + } | ||
| 84 | + } | ||
| 85 | + return folder | ||
| 86 | +} | ||
| 87 | + | ||
| 88 | +// under says path is dir or inside it. | ||
| 89 | +func under(path, dir string) bool { | ||
| 90 | + rel, err := filepath.Rel(dir, path) | ||
| 91 | + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) | ||
| 92 | +} | ||
| 93 | + | ||
| 94 | +// Rel writes path relative to Base, as sbx and compose read it: "./.mm", "." — | ||
| 95 | +// absolute when there is no Base or the path is not below it. | ||
| 96 | +func (m *MMFolder) Rel(path string) string { | ||
| 97 | + if m.Base == "" { | ||
| 98 | + return path | ||
| 99 | + } | ||
| 100 | + r, err := filepath.Rel(m.Base, path) | ||
| 101 | + if err != nil || r == ".." || strings.HasPrefix(r, ".."+string(filepath.Separator)) { | ||
| 102 | + return path | ||
| 103 | + } | ||
| 104 | + if r == "." { | ||
| 105 | + return "." | ||
| 106 | + } | ||
| 107 | + return "./" + filepath.ToSlash(r) | ||
| 108 | +} | ||
| 109 | + | ||
| 110 | +// FindAgentFile is the agent file a scaffolding command describes: an | ||
| 111 | +// absolute agentFile as is; otherwise it is looked for in the current | ||
| 112 | +// directory — resolve turns a relative path into the caller's — then in the | ||
| 113 | +// workspace. Without an explicit --agent, .mm/agent.yaml is looked for too, | ||
| 114 | +// after agentFile: the order mm itself follows (config.DefaultPath, then the | ||
| 115 | +// .mm folder). It returns the first path found, or agentFile resolved. | ||
| 116 | +// | ||
| 117 | +// path := scaffold.FindAgentFile("agent.yaml", "/p", false, func(p string) string { return p }) | ||
| 118 | +func FindAgentFile(agentFile, workspace string, explicit bool, resolve func(string) string) string { | ||
| 119 | + if filepath.IsAbs(agentFile) { | ||
| 120 | + return agentFile | ||
| 121 | + } | ||
| 122 | + names := []string{agentFile} | ||
| 123 | + if !explicit { | ||
| 124 | + names = append(names, filepath.Join(config.ProjectDir, config.DefaultPath)) | ||
| 125 | + } | ||
| 126 | + for _, name := range names { | ||
| 127 | + for _, path := range []string{resolve(name), filepath.Join(workspace, name)} { | ||
| 128 | + if _, err := os.Stat(path); err == nil { | ||
| 129 | + return path | ||
| 130 | + } | ||
| 131 | + } | ||
| 132 | + } | ||
| 133 | + return resolve(agentFile) | ||
| 134 | +} | ||
modified
internal/scaffold/scaffold.go +8 -0 | @@ -58,6 +58,10 @@ func AgentName(agentFile string, out io.Writer) string { | ||
| 58 | 58 | type File struct { |
| 59 | 59 | Path string |
| 60 | 60 | Text string |
| 61 | + // Replace: an existing file is replaced without asking — the caller | |
| 62 | + // already has the user's agreement (mm acp-create's /sbxinit --overwrite, | |
| 63 | + // where nobody can answer on stdin). | |
| 64 | + Replace bool | |
| 61 | 65 | } |
| 62 | 66 | |
| 63 | 67 | // Write writes the files, saying so on out. Each existing file is first asked |
| @@ -92,6 +96,10 @@ func decide(files []File, in *bufio.Reader, out io.Writer, refuse bool) (replace | ||
| 92 | 96 | if _, statErr := os.Stat(file.Path); statErr != nil { |
| 93 | 97 | continue |
| 94 | 98 | } |
| 99 | + if file.Replace { | |
| 100 | + replace[file.Path] = true | |
| 101 | + continue | |
| 102 | + } | |
| 95 | 103 | if refuse { |
| 96 | 104 | return nil, nil, alreadyExists(file.Path) |
| 97 | 105 | } |
| @@ -58,6 +58,10 @@ func AgentName(agentFile string, out io.Writer) string { | |||
| 58 | type File struct { | 58 | type File struct { |
| 59 | Path string | 59 | Path string |
| 60 | Text string | 60 | Text string |
| 61 | + // Replace: an existing file is replaced without asking — the caller | ||
| 62 | + // already has the user's agreement (mm acp-create's /sbxinit --overwrite, | ||
| 63 | + // where nobody can answer on stdin). | ||
| 64 | + Replace bool | ||
| 61 | } | 65 | } |
| 62 | 66 | ||
| 63 | // Write writes the files, saying so on out. Each existing file is first asked | 67 | // Write writes the files, saying so on out. Each existing file is first asked |
| @@ -92,6 +96,10 @@ func decide(files []File, in *bufio.Reader, out io.Writer, refuse bool) (replace | |||
| 92 | if _, statErr := os.Stat(file.Path); statErr != nil { | 96 | if _, statErr := os.Stat(file.Path); statErr != nil { |
| 93 | continue | 97 | continue |
| 94 | } | 98 | } |
| 99 | + if file.Replace { | ||
| 100 | + replace[file.Path] = true | ||
| 101 | + continue | ||
| 102 | + } | ||
| 95 | if refuse { | 103 | if refuse { |
| 96 | return nil, nil, alreadyExists(file.Path) | 104 | return nil, nil, alreadyExists(file.Path) |
| 97 | } | 105 | } |
modified
main.go +8 -0 | @@ -41,6 +41,7 @@ import ( | ||
| 41 | 41 | "time" |
| 42 | 42 | |
| 43 | 43 | "mm/internal/acp" |
| 44 | + "mm/internal/acpcreate" | |
| 44 | 45 | "mm/internal/agent" |
| 45 | 46 | "mm/internal/agentinit" |
| 46 | 47 | "mm/internal/ask" |
| @@ -83,6 +84,12 @@ func main() { | ||
| 83 | 84 | if len(os.Args) > 1 && isInit(os.Args[1]) { |
| 84 | 85 | os.Exit(agentinit.Main(os.Args[2:], os.Stdin, os.Stdout, agentinit.Options{Window: tui.RunInit, Terminal: tui.Terminal})) |
| 85 | 86 | } |
| 87 | + // `mm acp-create` and `mm --acp-create` (ticket 210): the same wizard | |
| 88 | + // served over ACP, for an editor; no configuration either. stdout is | |
| 89 | + // the protocol's, so its help and errors go to stderr. | |
| 90 | + if len(os.Args) > 1 && acpcreate.Is(os.Args[1]) { | |
| 91 | + os.Exit(acpcreate.Main(os.Args[2:], os.Stdin, os.Stdout, os.Stderr)) | |
| 92 | + } | |
| 86 | 93 | // `mm dockerinit` (ticket 189): writes files, before any configuration too. |
| 87 | 94 | if len(os.Args) > 1 && isDockerInit(os.Args[1]) { |
| 88 | 95 | os.Exit(dockerinit.Main(os.Args[2:], os.Stdin, os.Stdout)) |
| @@ -139,6 +146,7 @@ func main() { | ||
| 139 | 146 | showAbout := flag.Bool("about", false, "print what mm is, and exit") |
| 140 | 147 | // Listed for -h only: `mm --init` is answered before flag.Parse. |
| 141 | 148 | flag.Bool("init", false, "write a new agent.yaml with a wizard (same as `mm init`; `mm init -h` for its flags)") |
| 149 | + flag.Bool("acp-create", false, "serve mm init's wizard over the Agent Client Protocol, to create an agent.yaml from an editor (same as mm acp-create; mm acp-create -h for its flags)") | |
| 142 | 150 | flag.Bool("dockerinit", false, "write a Dockerfile and a compose.yaml to run mm in a container (same as mm dockerinit; mm dockerinit -h for its flags)") |
| 143 | 151 | flag.Bool("skillcreate", false, "write a new skill with a wizard, where the agent reads its skills (same as mm skillcreate; mm skillcreate -h for its flags)") |
| 144 | 152 | flag.Bool("sbxinit", false, "write a sbxenv.yaml to run mm in a Docker Sandbox (same as mm sbxinit; mm sbxinit -h for its flags)") |
| @@ -41,6 +41,7 @@ import ( | |||
| 41 | "time" | 41 | "time" |
| 42 | 42 | ||
| 43 | "mm/internal/acp" | 43 | "mm/internal/acp" |
| 44 | + "mm/internal/acpcreate" | ||
| 44 | "mm/internal/agent" | 45 | "mm/internal/agent" |
| 45 | "mm/internal/agentinit" | 46 | "mm/internal/agentinit" |
| 46 | "mm/internal/ask" | 47 | "mm/internal/ask" |
| @@ -83,6 +84,12 @@ func main() { | |||
| 83 | if len(os.Args) > 1 && isInit(os.Args[1]) { | 84 | if len(os.Args) > 1 && isInit(os.Args[1]) { |
| 84 | os.Exit(agentinit.Main(os.Args[2:], os.Stdin, os.Stdout, agentinit.Options{Window: tui.RunInit, Terminal: tui.Terminal})) | 85 | os.Exit(agentinit.Main(os.Args[2:], os.Stdin, os.Stdout, agentinit.Options{Window: tui.RunInit, Terminal: tui.Terminal})) |
| 85 | } | 86 | } |
| 87 | + // `mm acp-create` and `mm --acp-create` (ticket 210): the same wizard | ||
| 88 | + // served over ACP, for an editor; no configuration either. stdout is | ||
| 89 | + // the protocol's, so its help and errors go to stderr. | ||
| 90 | + if len(os.Args) > 1 && acpcreate.Is(os.Args[1]) { | ||
| 91 | + os.Exit(acpcreate.Main(os.Args[2:], os.Stdin, os.Stdout, os.Stderr)) | ||
| 92 | + } | ||
| 86 | // `mm dockerinit` (ticket 189): writes files, before any configuration too. | 93 | // `mm dockerinit` (ticket 189): writes files, before any configuration too. |
| 87 | if len(os.Args) > 1 && isDockerInit(os.Args[1]) { | 94 | if len(os.Args) > 1 && isDockerInit(os.Args[1]) { |
| 88 | os.Exit(dockerinit.Main(os.Args[2:], os.Stdin, os.Stdout)) | 95 | os.Exit(dockerinit.Main(os.Args[2:], os.Stdin, os.Stdout)) |
| @@ -139,6 +146,7 @@ func main() { | |||
| 139 | showAbout := flag.Bool("about", false, "print what mm is, and exit") | 146 | showAbout := flag.Bool("about", false, "print what mm is, and exit") |
| 140 | // Listed for -h only: `mm --init` is answered before flag.Parse. | 147 | // Listed for -h only: `mm --init` is answered before flag.Parse. |
| 141 | flag.Bool("init", false, "write a new agent.yaml with a wizard (same as `mm init`; `mm init -h` for its flags)") | 148 | flag.Bool("init", false, "write a new agent.yaml with a wizard (same as `mm init`; `mm init -h` for its flags)") |
| 149 | + flag.Bool("acp-create", false, "serve mm init's wizard over the Agent Client Protocol, to create an agent.yaml from an editor (same as mm acp-create; mm acp-create -h for its flags)") | ||
| 142 | flag.Bool("dockerinit", false, "write a Dockerfile and a compose.yaml to run mm in a container (same as mm dockerinit; mm dockerinit -h for its flags)") | 150 | flag.Bool("dockerinit", false, "write a Dockerfile and a compose.yaml to run mm in a container (same as mm dockerinit; mm dockerinit -h for its flags)") |
| 143 | flag.Bool("skillcreate", false, "write a new skill with a wizard, where the agent reads its skills (same as mm skillcreate; mm skillcreate -h for its flags)") | 151 | flag.Bool("skillcreate", false, "write a new skill with a wizard, where the agent reads its skills (same as mm skillcreate; mm skillcreate -h for its flags)") |
| 144 | flag.Bool("sbxinit", false, "write a sbxenv.yaml to run mm in a Docker Sandbox (same as mm sbxinit; mm sbxinit -h for its flags)") | 152 | flag.Bool("sbxinit", false, "write a sbxenv.yaml to run mm in a Docker Sandbox (same as mm sbxinit; mm sbxinit -h for its flags)") |