@merchantduo/code 0.2.0-beta.2 → 0.2.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +26 -119
  2. package/dist/app/runtime.d.ts +8 -0
  3. package/dist/app/runtime.js +45 -1
  4. package/dist/app/status.js +12 -4
  5. package/dist/app/system-prompt.js +5 -1
  6. package/dist/environments/adapter.d.ts +1 -0
  7. package/dist/environments/adapters/warden.d.ts +5 -0
  8. package/dist/environments/adapters/warden.js +7 -0
  9. package/dist/environments/backend.d.ts +7 -0
  10. package/dist/environments/backend.js +31 -0
  11. package/dist/environments/model.d.ts +2 -0
  12. package/dist/features/magerun2/index.d.ts +2 -0
  13. package/dist/features/magerun2/index.js +2 -0
  14. package/dist/features/magerun2/model.d.ts +10 -0
  15. package/dist/features/magerun2/model.js +1 -0
  16. package/dist/features/magerun2/service.d.ts +12 -0
  17. package/dist/{testing/magerun.js → features/magerun2/service.js} +34 -13
  18. package/dist/integrations/pi/context.js +13 -7
  19. package/dist/integrations/pi/environment.d.ts +3 -0
  20. package/dist/integrations/pi/environment.js +64 -0
  21. package/dist/integrations/pi/magerun2.d.ts +4 -0
  22. package/dist/integrations/pi/magerun2.js +37 -0
  23. package/dist/integrations/pi/optional-services.d.ts +9 -0
  24. package/dist/integrations/pi/optional-services.js +79 -0
  25. package/dist/integrations/pi/permissions.js +3 -1
  26. package/dist/integrations/pi/testing.js +8 -8
  27. package/dist/integrations/pi/theme.js +1 -1
  28. package/dist/integrations/pi/workflows.js +1 -1
  29. package/dist/testing/discovery.d.ts +0 -1
  30. package/dist/testing/discovery.js +1 -4
  31. package/dist/testing/host.d.ts +1 -0
  32. package/dist/testing/host.js +14 -3
  33. package/dist/testing/index.d.ts +0 -1
  34. package/dist/testing/index.js +0 -1
  35. package/dist/testing/model.d.ts +0 -1
  36. package/package.json +5 -1
  37. package/skills/magerun2/SKILL.md +22 -0
  38. package/dist/testing/magerun.d.ts +0 -8
package/README.md CHANGED
@@ -1,66 +1,27 @@
1
1
  # MerchantDuo
2
2
 
3
- MerchantDuo is a Pi coding agent for Magento teams that works from the store's real environment, Magento version, deployment mode, and theme context.
3
+ MerchantDuo is a Pi coding agent for Magento 2.4 teams. It starts from the store's selected environment, Magento version, deployment mode, and theme context instead of treating a Magento project as a generic repository.
4
4
 
5
- ## Main features
5
+ ## Overview
6
6
 
7
- - Magento-aware coding sessions
8
- - Local, Warden, and SSH environments
9
- - Developer-store provisioning
10
- - Magento 2.4, Luma, Hyva, Mage2Gen, and testing skills
11
- - Magento workflows and focused test tools
12
- - Version, edition, deployment-mode, and theme detection
13
- - Remote-write and operational safety controls
7
+ - Works with local checkouts, Warden, and SSH environments.
8
+ - Detects Magento edition, version, PHP version, deployment mode, cache types, and registered theme inheritance.
9
+ - Ships Magento 2.4, Luma, Hyva, Mage2Gen, and testing skills.
10
+ - Provides guided local/Warden store provisioning.
11
+ - Keeps remote writes and Magento operations behind explicit execution boundaries.
14
12
 
15
- ## Details
13
+ At session start, MerchantDuo produces a Magento snapshot from the selected environment. Frontend and admin URLs are discovered but remain unverified until an explicit test. The compact status line shows MerchantDuo and Magento versions, environment lifecycle, write access, an explicit theme scope when one is selected, and the frontend URL when available.
16
14
 
17
- MerchantDuo gives Magento developers a coding-agent workspace that starts with the project context they need. It runs on Pi, inspects the selected Magento environment, keeps theme scope visible, and exposes Magento-specific tools for the work that usually slows an agency team down.
15
+ ## Commands and tools
18
16
 
19
- It is built for Magento 2.4 development, not as a generic chat wrapper with a few shell commands attached. A session knows whether it is working locally, in Warden, or through SSH. It can identify the Magento edition, version, PHP version, deployment mode, enabled module count, cache types, and real registered themes before it suggests framework-specific work.
17
+ Extension slash commands are namespaced with `duo-` to avoid collisions with other Pi extensions.
20
18
 
21
- MerchantDuo is currently beta software for development teams.
19
+ - [Command reference](docs/commands.md) covers `/duo-switch-theme`, lifecycle, and navigation commands.
20
+ - [Tool reference](docs/tools.md) covers workspace, environment, Magento workflow, magerun2, testing, PHP-console, and optional knowledge tools, including parameters and confirmation boundaries.
22
21
 
23
- ## What agencies get
22
+ ## Environments
24
23
 
25
- - A Pi coding-agent package with Magento context from the start of each session.
26
- - Local, Warden, and SSH environments selected per project or command.
27
- - A guided `provision` command for preparing a local or Warden developer store.
28
- - Native Magento 2.4, Luma, Hyva, Mage2Gen, and testing skills.
29
- - Magento-aware file, search, edit, module-generation, workflow, HTTP, browser, and PHP-console tools.
30
- - Theme discovery based on registered themes in `vendor` and `app/design`, including parent chains and Luma or Hyva classification.
31
- - Deployment-mode guidance that changes for developer, default, and production installations.
32
- - Guardrails around remote writes, Magento operations, credentials, and browser access.
33
-
34
- The result is less time re-explaining a store to an agent and fewer generic recommendations that do not fit Magento.
35
-
36
- ## Magento context before the first change
37
-
38
- At session startup, MerchantDuo creates a Magento snapshot for the selected environment. It reads `composer.lock` and `bin/magento --version` when available, then reports disagreements instead of silently choosing one. The snapshot includes:
39
-
40
- - Magento Open Source or Adobe Commerce edition and version
41
- - PHP version and deployment mode
42
- - Enabled module count and cache types
43
- - Registered themes, inheritance chains, and Luma or Hyva classification
44
- - Available frontend and admin URLs, marked unverified until explicitly tested
45
- - Whether the selected environment is writable
46
-
47
- Theme discovery only scans real Magento theme roots: package roots under `vendor` and themes under `app/design`. It does not search arbitrary vendor fixtures. Use `/theme` to select `all`, `any`, or a detected theme code. `all` keeps every detected theme in scope, while `any` tells the agent to identify the relevant theme before it edits theme-specific code.
48
-
49
- ## Curated Magento knowledge
50
-
51
- MerchantDuo ships focused Pi skills instead of relying on broad, generic instructions:
52
-
53
- - `magento-24x` guides Magento 2.4 work toward service contracts, dependency injection, declarative schema and data patches, layout XML, and the right deployment-mode workflow.
54
- - `luma` covers Magento fallback, layout XML, RequireJS, UI components, Knockout, LESS, and static assets.
55
- - `hyva` directs work toward Hyva templates, Alpine, Tailwind, and Hyva UI components. The package also vendors the relevant Hyva skills.
56
- - `mage2gen` makes vendored Mage2Gen the standard starting point for new modules.
57
- - `magento-testing` keeps testing explicit, targeted, and unauthenticated.
58
-
59
- The current package provides this curated knowledge locally through Pi skills. `merchantduo doctor` reports knowledge-service status and currently shows that no external knowledge connection is configured. That prevents the agent from presenting an unavailable MCP source as project knowledge.
60
-
61
- ## Environments that match agency work
62
-
63
- Define the environments your team actually uses in `.merchantduo.yaml`, then select one at launch with `--env`.
24
+ Define the environments your team uses in `.merchantduo.yaml`, then select one at launch with `--env`.
64
25
 
65
26
  ```yaml
66
27
  defaultEnvironment: local
@@ -88,9 +49,7 @@ environments:
88
49
  writable: false
89
50
  ```
90
51
 
91
- Local work uses the Magento checkout directly. Warden work routes file and Magento commands through the selected service. SSH starts read-only unless you set `writable: true`, and writable remote file or shell work requires confirmation.
92
-
93
- For SSH user names, ports, and identities, use an OpenSSH alias rather than extra YAML keys:
52
+ Local uses the checkout directly. Warden routes project and Magento execution through its configured service. SSH is read-only by default; configure its user, port, and key using an OpenSSH host alias:
94
53
 
95
54
  ```sshconfig
96
55
  Host shop-stage
@@ -100,89 +59,37 @@ Host shop-stage
100
59
  IdentityFile ~/.ssh/id_ed25519
101
60
  ```
102
61
 
103
- Use the alias as `host: shop-stage` in `.merchantduo.yaml`.
62
+ Use that alias as `host: shop-stage`. `merchantduo init` offers local first and Warden when its project `.env` is detected; it prints an SSH example instead of writing an unused remote environment.
104
63
 
105
- `merchantduo init` offers the local checkout first. When the project `.env` contains Warden routing markers, it also offers a Warden environment. It writes only the environments you accept and then prints the remote SSH example instead of adding a fake remote entry. Local development certificates are accepted by default; set `testing.allowInsecureTls: false` when the selected URLs have publicly trusted certificates.
64
+ ## Provisioning
106
65
 
107
- ## Provision a developer store
108
-
109
- Run provisioning from an empty directory or an existing Magento project:
66
+ Provision a local or Warden developer store from an empty directory or an existing Magento checkout:
110
67
 
111
68
  ```sh
112
69
  merchantduo provision
113
70
  merchantduo provision --env warden --source https://git.example.test/store.git --database ./backup.sql.gz --yes
114
71
  ```
115
72
 
116
- Provisioning supports local and Warden environments. It inspects available services, Magento source, database dumps, and existing web-server routing. The interactive flow asks only for choices it cannot resolve, prints an ordered plan, and asks once before it changes anything.
117
-
118
- With `--yes`, every decision must already be explicit or unambiguous. The command fails rather than guessing a source, database, web server, or environment.
119
-
120
- Sources can be an existing checkout, Git URL, local archive, or HTTPS archive. Databases can be the current database, a local SQL or compressed SQL dump, or an HTTPS URL. Downloaded archives are staged below `MERCHANTDUO_HOME/provisions`, checked for path traversal, and removed after extraction.
121
-
122
- Input precedence is command flags, then a discovered Magento checkout or the only SQL dump in the project root, then the interactive interview.
123
-
124
- For Warden, MerchantDuo runs Warden lifecycle and database commands on the host, then runs Composer and Magento commands in the configured PHP service. Local provisioning requires PHP, Composer, the MySQL client, and an active nginx or Apache service. If no matching virtual host exists, the approved plan can add a minimal `pub` vhost and an `/etc/hosts` entry for the selected local domain. It does not replace an existing matching vhost.
125
-
126
- If `MERCHANTDUO_LLM_TOKEN` is set, provisioning can ask a narrow OpenAI-compatible advisor to choose from its already generated action plan. Its built-in endpoint and model are mock wiring, not a hosted AI service. Invalid advisor output falls back to the deterministic plan. MerchantDuo never sends the token, database credentials, or `app/etc/env.php` contents to the advisor.
127
-
128
- ## Tools that respect Magento boundaries
129
-
130
- MerchantDuo uses direct Pi extensions for context, permissions, workspace work, themes, Mage2Gen, workflows, and testing. The package manifest is the source of truth for both extensions and skills.
131
-
132
- | Capability | What it does |
133
- | --- | --- |
134
- | Workspace tools | Read, search, write, and edit inside the selected environment. |
135
- | `mage2gen_generate_module` | Previews or generates a module under the selected local or Warden project's `app/code`. It refuses existing destinations and does not run over SSH. |
136
- | `magento_workflow` | Previews Magento operations such as cache cleaning, setup upgrade, DI compilation, static-content deployment, reindexing, and tests. Use `execute: true` to request the single in-tool confirmation. |
137
- | `magento_http_test` | Runs an explicit unauthenticated GET or HEAD request against a discovered Magento URL. |
138
- | `magento_browser_test` | Captures a screenshot or DOM dump with an isolated Chromium profile. Artifacts are stored under `MERCHANTDUO_HOME/artifacts/<project-id>/`. |
139
- | `magento_php_repl` | Runs one PHP snippet through a one-shot `n98-magerun2` console. |
140
- | `/navigate-frontend` and `/navigate-adminhtml` | Open a discovered same-origin frontend or admin URL without handling login credentials. |
141
-
142
- No lifecycle hook runs tests, cache actions, deployments, or browser commands because a session started or a file changed. Those operations remain explicit. Testing and navigation are direct user-request boundaries, so they do not ask for the MerchantDuo confirmation.
143
-
144
- ## Magento workflows without blind deployment commands
145
-
146
- MerchantDuo tracks changed files and recommends the smallest relevant operation. It does not assume every edit needs a full Magento deployment.
147
-
148
- | Detected mode | Typical module-change sequence |
149
- | --- | --- |
150
- | Developer | `setup:upgrade`, then clean relevant cache types. |
151
- | Default | `setup:upgrade`, then clean relevant cache types. Compile DI or deploy static content only when the change requires it. |
152
- | Production | Enable maintenance mode, run `setup:upgrade`, compile DI, deploy static content, clean relevant cache types, then disable maintenance mode. |
153
-
154
- If the deployment mode cannot be determined, MerchantDuo reports that fact instead of inventing a sequence.
73
+ The interactive flow discovers available source, database, and service evidence, shows an ordered plan, and asks once before mutations. `--yes` requires all decisions to be explicit or unambiguous.
155
74
 
156
75
  ## Install and start
157
76
 
158
- MerchantDuo requires Node.js 22.19 or later, Bash, Python 3, curl, and Chromium or Chrome. Warden environments require Warden. SSH environments require OpenSSH. A remote PHP console needs `n98-magerun2` on that environment.
159
-
160
- Linux navigation also needs `xdg-open`. Browser discovery accepts `chromium-browser`, `chromium`, `google-chrome`, and `google-chrome-stable`.
161
-
162
- Install the package through your team's npm registry, then create the project configuration:
77
+ MerchantDuo requires Node.js 22.19 or later, Bash, and Python 3. HTTP/browser testing additionally needs curl and Chrome or Chromium; Warden and SSH environments need Warden and OpenSSH respectively.
163
78
 
164
79
  ```sh
80
+ pnpm install --frozen-lockfile
165
81
  merchantduo init
166
82
  merchantduo doctor --env local
167
83
  merchantduo --env local
168
84
  ```
169
85
 
170
- `merchantduo init` creates `.merchantduo.yaml`. Review it before the first agent session. MerchantDuo requires an explicit one-time trust decision for a project configuration. Set `MERCHANTDUO_TRUST_CONFIG=1` only after reviewing that file.
171
-
172
- Testing URLs can be configured at the top level or per environment. Configured URLs take precedence over discovery. Local discovery reads Magento base URLs and the admin URI. Warden reads `TRAEFIK_DOMAIN` and `TRAEFIK_SUBDOMAIN` from the project `.env`. MerchantDuo does not contact discovered URLs until an explicit testing request.
173
-
174
- For SSH, MerchantDuo uses configured URLs or reads Magento remotely when URLs are absent. HTTP tests, browser tests, navigation, temporary browser profiles, and test artifacts always run on and belong to the host, including for Warden. MerchantDuo finds and invokes the host `curl` and Chrome/Chromium, never their container copies. Local TLS exceptions apply consistently to both curl and the browser. The PHP REPL remains Magento-side because it executes application code and therefore needs Warden's PHP service, project files, configuration, and database. When local `n98-magerun2` is unavailable, MerchantDuo looks in `vendor/bin`, `bin`, and `PATH`, then can use a verified official PHAR cache under `MERCHANTDUO_HOME/tools/n98-magerun2` without modifying the Magento project. Failure to obtain that cache does not stop the session.
86
+ Review the generated `.merchantduo.yaml` before the first session. MerchantDuo requires a one-time configuration trust decision; set `MERCHANTDUO_TRUST_CONFIG=1` only after review.
175
87
 
176
- ## Safety model
88
+ ## Safety and optional services
177
89
 
178
- Magento projects carry more operational risk than ordinary application repositories. MerchantDuo keeps the boundaries simple:
90
+ MerchantDuo does not load `app/etc/env.php`, credentials, or tokens into agent context. It does not run background tests, cache commands, deployment actions, browser operations, or PHP code. Explicit operations use the confirmation rules described in the [tool reference](docs/tools.md).
179
91
 
180
- - It does not read `app/etc/env.php`, credentials, or tokens for agent context.
181
- - It treats discovered URLs as unverified until you run an explicit HTTP or browser test.
182
- - It performs no background cache, deployment, browser, HTTP, PHP-console, or workflow action.
183
- - Module generation and Magento workflows preview first. Execution requires `execute: true` and one harness confirmation.
184
- - Read-only SSH blocks write, edit, and shell operations. Writable SSH asks for confirmation before those operations.
185
- - SSH testing uses the publicly reachable URL and never creates a tunnel.
92
+ Set `MERCHANTDUO_MCP_TOKEN` to enable the built-in read-only knowledge tool. Set `MERCHANTDUO_LLM_TOKEN` to enable the built-in MerchantDuo provider. Neither endpoint is project configuration.
186
93
 
187
94
  ## Development
188
95
 
@@ -190,7 +97,7 @@ Magento projects carry more operational risk than ordinary application repositor
190
97
  pnpm install --frozen-lockfile
191
98
  pnpm check
192
99
  pnpm test
193
- pnpm pack --dry-run
100
+ npm pack --dry-run
194
101
  ```
195
102
 
196
103
  For the extension layout, provisioning boundary, and session lifecycle, see [the architecture notes](docs/architecture.md).
@@ -1,9 +1,11 @@
1
1
  import { type MerchantConfig } from "#config/index";
2
2
  import { EnvironmentBackend } from "#environments/backend";
3
3
  import type { Environment } from "#environments/model";
4
+ import type { EnvironmentStatus } from "#environments/model";
4
5
  import { type MagentoInspector, type MagentoSnapshot } from "#magento/index";
5
6
  import { ChangeTracker } from "#workflows/change-tracker";
6
7
  import type { TestingSnapshot } from "#testing/model";
8
+ import type { Magerun2Capability } from "#features/magerun2/index";
7
9
  export type SessionState = {
8
10
  config: MerchantConfig;
9
11
  configPath?: string;
@@ -12,8 +14,10 @@ export type SessionState = {
12
14
  environment: Environment;
13
15
  };
14
16
  backend: EnvironmentBackend;
17
+ environmentStatus: EnvironmentStatus;
15
18
  magento: MagentoSnapshot;
16
19
  testing?: TestingSnapshot;
20
+ magerun2?: Magerun2Capability;
17
21
  };
18
22
  export declare class MerchantDuoRuntime {
19
23
  #private;
@@ -27,4 +31,8 @@ export declare class MerchantDuoRuntime {
27
31
  changeTheme(cwd: string, activeTheme: string): Promise<SessionState>;
28
32
  writable(state: SessionState): boolean;
29
33
  setTesting(snapshot: TestingSnapshot): void;
34
+ setMagerun2(capability: Magerun2Capability): void;
35
+ setEnvironmentStatus(status: EnvironmentStatus): SessionState;
36
+ startEnvironment(cwd: string): Promise<SessionState>;
37
+ stopEnvironment(cwd: string): Promise<SessionState>;
30
38
  }
@@ -20,11 +20,13 @@ export class MerchantDuoRuntime {
20
20
  });
21
21
  const selected = selectEnvironment(loaded.config, cwd, process.env.MERCHANTDUO_SELECTED_ENV);
22
22
  const backend = new EnvironmentBackend(selected.environment, cwd);
23
+ const environmentStatus = await backend.status();
23
24
  this.#state = {
24
25
  ...loaded,
25
26
  selected,
26
27
  backend,
27
- magento: await this.inspector.inspect(backend),
28
+ environmentStatus,
29
+ magento: environmentStatus === "running" ? await this.inspector.inspect(backend) : stoppedMagentoSnapshot(),
28
30
  };
29
31
  return this.#state;
30
32
  }
@@ -53,4 +55,46 @@ export class MerchantDuoRuntime {
53
55
  throw new Error("Session has not started");
54
56
  this.#state.testing = snapshot;
55
57
  }
58
+ setMagerun2(capability) {
59
+ if (!this.#state)
60
+ throw new Error("Session has not started");
61
+ this.#state.magerun2 = capability;
62
+ }
63
+ setEnvironmentStatus(status) {
64
+ if (!this.#state)
65
+ throw new Error("Session has not started");
66
+ this.#state.environmentStatus = status;
67
+ if (status === "stopped") {
68
+ this.#state.magento = stoppedMagentoSnapshot();
69
+ this.#state.testing = undefined;
70
+ this.#state.magerun2 = undefined;
71
+ }
72
+ return this.#state;
73
+ }
74
+ async startEnvironment(cwd) {
75
+ const state = await this.boot(cwd);
76
+ const result = await state.backend.start();
77
+ if (result && result.exitCode !== 0)
78
+ throw new Error(result.stderr || result.stdout || "Could not start environment");
79
+ state.environmentStatus = await state.backend.status();
80
+ if (state.environmentStatus === "running")
81
+ state.magento = await this.inspector.inspect(state.backend);
82
+ return state;
83
+ }
84
+ async stopEnvironment(cwd) {
85
+ const state = await this.boot(cwd);
86
+ const result = await state.backend.stop();
87
+ if (result && result.exitCode !== 0)
88
+ throw new Error(result.stderr || result.stdout || "Could not stop environment");
89
+ state.environmentStatus = await state.backend.status();
90
+ if (state.environmentStatus === "stopped") {
91
+ state.magento = stoppedMagentoSnapshot();
92
+ state.testing = undefined;
93
+ state.magerun2 = undefined;
94
+ }
95
+ return state;
96
+ }
97
+ }
98
+ function stoppedMagentoSnapshot() {
99
+ return { cacheTypes: [], warnings: ["Environment is stopped"], evidence: [], themes: [] };
56
100
  }
@@ -2,8 +2,16 @@ export function refreshStatus(ctx, state) {
2
2
  const environment = state.selected.environment;
3
3
  const writePolicy = environment.type === "ssh"
4
4
  ? environment.writable
5
- ? "write + approve"
6
- : "read-only + approve"
7
- : "write + approve";
8
- ctx.ui.setStatus("merchantduo", `MerchantDuo ${process.env.MERCHANTDUO_PACKAGE_VERSION ?? "?"} | Magento ${state.magento.version ?? "?"} | ${state.selected.name}/${environment.type} | ${writePolicy} | Theme: ${state.config.activeTheme} | URLs: ${state.testing?.frontendUrl ?? "unavailable"} (unverified)`);
5
+ ? "✎+?"
6
+ : "RO"
7
+ : "";
8
+ const parts = [
9
+ `MerchantDuo ${process.env.MERCHANTDUO_PACKAGE_VERSION ?? "?"}`,
10
+ `M${state.magento.version ?? "?"}`,
11
+ `${state.selected.name} (${state.environmentStatus})`,
12
+ writePolicy,
13
+ ...(state.config.activeTheme === "all" ? [] : [state.config.activeTheme]),
14
+ ...(state.testing?.frontendUrl ? [state.testing.frontendUrl] : []),
15
+ ];
16
+ ctx.ui.setStatus("merchantduo", parts.join(" · "));
9
17
  }
@@ -11,5 +11,9 @@ For every request to create a new Magento module, call the \`mage2gen_generate_m
11
11
 
12
12
  After every change, inspect the changed module and files and choose the smallest applicable post-change workflow from Magento conventions; never apply a full sequence blindly. Ordinary PHP or template edits normally need only the relevant targeted cache clean, if any. Module registration, schema, declarative configuration, or dependency changes trigger mode-aware guidance from the detected snapshot mode: developer recommends \`setup:upgrade\` then targeted cache clean and does not normally compile DI or deploy static content; default recommends \`setup:upgrade\` then targeted cache clean, with compile or static deploy only when the task specifically requires them; production recommends maintenance enable, \`setup:upgrade\`, DI compile, static-content deploy, targeted cache clean, then maintenance disable. If the mode is unknown, report that and do not infer a deployment sequence.
13
13
 
14
- For cache clean/flush, maintenance enable/disable, static content deployment, compilation, setup upgrade, indexing, tests, or deployment: when the user directly requests execution, invoke \`magento_workflow\` with \`execute: true\` immediately. Use only this workflow tool for operational Magento commands. Do not ask the user for a separate conversational confirmation: the harness dialog is the sole authoritative confirmation. When execution was not requested, explain the operation and offer a preview. Never inspect app/etc/env.php or credentials.`;
14
+ For cache clean/flush, maintenance enable/disable, static content deployment, compilation, setup upgrade, indexing, tests, or deployment: when the user directly requests execution, invoke \`magento_workflow\` with \`execute: true\` immediately. Use only this workflow tool for operational Magento commands. Do not ask the user for a separate conversational confirmation: the harness dialog is the sole authoritative confirmation. When execution was not requested, explain the operation and offer a preview.
15
+
16
+ For a supported cache, deploy, indexing, or test operation, prefer \`magento_workflow\`. For a different n98-magerun2 subcommand, use \`magerun2\` with an exact \`args: string[]\` vector, never a shell command or interpolation. When the user did not directly request execution, return its preview without \`execute: true\`; an executed generic magerun2 call requires \`execute: true\` and uses the same sole harness confirmation. Never send \`dev:console\` through \`magerun2\`: use the typed \`magento_php_repl\` tool instead.
17
+
18
+ Environment lifecycle is explicit. Warden has known direct controls. SSH is always reported running and must never be started, stopped, or restarted. A local environment is agent-directed: when the user requests a start or stop, inspect the actual project lifecycle first, then call \`environment_start\` or \`environment_stop\` with the exact smallest command. Never guess a generic Docker, Compose, npm, or service command, and never affect unrelated host services. Use \`environment_set_status\` after a status inspection that does not itself start or stop the stack. Never inspect app/etc/env.php or credentials.`;
15
19
  }
@@ -2,4 +2,5 @@ import type { CommandSpec } from "#environments/executor";
2
2
  export interface EnvironmentAdapter {
3
3
  path(path: string): string;
4
4
  command(argv: string[], target?: string): CommandSpec;
5
+ lifecycleCommand?(action: "status" | "start" | "stop"): CommandSpec;
5
6
  }
@@ -10,4 +10,9 @@ export declare class WardenAdapter implements EnvironmentAdapter {
10
10
  args: string[];
11
11
  cwd: string;
12
12
  };
13
+ lifecycleCommand(action: "status" | "start" | "stop"): {
14
+ file: string;
15
+ args: string[];
16
+ cwd: string;
17
+ };
13
18
  }
@@ -21,4 +21,11 @@ export class WardenAdapter {
21
21
  cwd: resolve(this.cwd, this.environment.projectRoot),
22
22
  };
23
23
  }
24
+ lifecycleCommand(action) {
25
+ // Lifecycle commands run on the host in the Warden project, never in php-fpm.
26
+ const actionArgs = action === "status"
27
+ ? ["env", "ps", "--status", "running", "--services"]
28
+ : ["env", action];
29
+ return { file: "warden", args: actionArgs, cwd: resolve(this.cwd, this.environment.projectRoot) };
30
+ }
24
31
  }
@@ -1,4 +1,5 @@
1
1
  import type { Environment } from "#environments/model";
2
+ import type { EnvironmentStatus } from "#environments/model";
2
3
  import type { CommandExecutor, CommandResult, CommandSpec, RunOptions } from "#environments/executor";
3
4
  export { contain } from "#environments/path-policy";
4
5
  /** Concrete local/Warden/SSH command adapter selected once for a session. */
@@ -10,4 +11,10 @@ export declare class EnvironmentBackend implements CommandExecutor {
10
11
  path(path: string): string;
11
12
  command(argv: string[], target?: string): CommandSpec;
12
13
  run(argv: string[], options?: RunOptions): Promise<CommandResult>;
14
+ lifecycleCommand(action: "status" | "start" | "stop"): CommandSpec | undefined;
15
+ status(): Promise<EnvironmentStatus>;
16
+ start(): Promise<CommandResult | undefined>;
17
+ stop(): Promise<CommandResult | undefined>;
13
18
  }
19
+ /** `warden env ps --status running --services` prints one service per running container. */
20
+ export declare function wardenRunning(stdout: string): boolean;
@@ -22,6 +22,33 @@ export class EnvironmentBackend {
22
22
  }
23
23
  async run(argv, options = {}) {
24
24
  const spec = this.command(argv, options.target);
25
+ return this.#runSpec(spec, options);
26
+ }
27
+ lifecycleCommand(action) {
28
+ return this.#adapter.lifecycleCommand?.(action);
29
+ }
30
+ async status() {
31
+ if (this.environment.type === "ssh")
32
+ return "running";
33
+ if (this.environment.type === "local")
34
+ return "unknown";
35
+ const spec = this.lifecycleCommand("status");
36
+ if (!spec)
37
+ return "stopped";
38
+ const output = await this.#runSpec(spec);
39
+ return output.exitCode === 0 && wardenRunning(output.stdout) ? "running" : "stopped";
40
+ }
41
+ async start() {
42
+ return this.#lifecycle("start");
43
+ }
44
+ async stop() {
45
+ return this.#lifecycle("stop");
46
+ }
47
+ async #lifecycle(action) {
48
+ const spec = this.lifecycleCommand(action);
49
+ return spec ? this.#runSpec(spec) : undefined;
50
+ }
51
+ #runSpec(spec, options = {}) {
25
52
  return new Promise((resolveResult) => {
26
53
  const child = spawn(spec.file, spec.args, {
27
54
  cwd: spec.cwd,
@@ -71,3 +98,7 @@ export class EnvironmentBackend {
71
98
  });
72
99
  }
73
100
  }
101
+ /** `warden env ps --status running --services` prints one service per running container. */
102
+ export function wardenRunning(stdout) {
103
+ return stdout.split("\n").some((line) => line.trim().length > 0);
104
+ }
@@ -3,6 +3,8 @@ type EnvironmentTesting = {
3
3
  adminUrl?: string;
4
4
  allowInsecureTls?: boolean;
5
5
  };
6
+ /** Unknown is valid for a local stack until the agent has identified its actual lifecycle. */
7
+ export type EnvironmentStatus = "running" | "stopped" | "unknown";
6
8
  export type LocalEnvironment = {
7
9
  type: "local";
8
10
  root: string;
@@ -0,0 +1,2 @@
1
+ export * from "#features/magerun2/model";
2
+ export * from "#features/magerun2/service";
@@ -0,0 +1,2 @@
1
+ export * from "#features/magerun2/model";
2
+ export * from "#features/magerun2/service";
@@ -0,0 +1,10 @@
1
+ export type Magerun2Capability = {
2
+ available: boolean;
3
+ executable?: string;
4
+ diagnostic?: string;
5
+ };
6
+ export type Magerun2Result = {
7
+ output: string;
8
+ exitCode: number;
9
+ truncated: boolean;
10
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,12 @@
1
+ import type { EnvironmentBackend } from "#environments/backend";
2
+ import type { Environment } from "#environments/model";
3
+ import { type HostProcessRunner } from "#testing/host";
4
+ import type { Magerun2Capability, Magerun2Result } from "#features/magerun2/model";
5
+ /** Resolve magerun once for the selected Magento environment without altering the project. */
6
+ export declare function discoverMagerun2(environment: Environment, backend: EnvironmentBackend, cwd: string): Promise<Magerun2Capability>;
7
+ /** Run a structured argument vector. Local commands are host processes; Warden and SSH use their selected backend. */
8
+ export declare function runMagerun2(capability: Magerun2Capability, environment: Environment, backend: EnvironmentBackend, cwd: string, args: string[], stdin?: string, signal?: AbortSignal, host?: HostProcessRunner): Promise<Magerun2Result>;
9
+ /** Stable display only: this is never passed to a shell. */
10
+ export declare function quotedMagerunPreview(executable: string, args: string[]): string;
11
+ /** Install the official PHAR outside the Magento project; verify its release SHA-256 before publishing it. */
12
+ export declare function installMagerun2(home: string, _cwd: string, request?: typeof fetch): Promise<string>;
@@ -2,30 +2,53 @@ import { chmod, mkdir, readFile, rename, stat, writeFile } from "node:fs/promise
2
2
  import { join } from "node:path";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  import { merchantHome } from "#config/paths";
5
+ import { NodeHostProcessRunner } from "#testing/host";
5
6
  const RELEASE_URL = "https://api.github.com/repos/netz98/n98-magerun2/releases/latest";
6
- export async function discoverMagerun(environment, backend, cwd) {
7
+ const MAX_OUTPUT = 64 * 1024;
8
+ /** Resolve magerun once for the selected Magento environment without altering the project. */
9
+ export async function discoverMagerun2(environment, backend, cwd) {
7
10
  if (environment.type === "warden")
8
- return { executable: "/usr/local/bin/mr" };
11
+ return { available: true, executable: "/usr/local/bin/mr" };
9
12
  const candidates = environment.type === "local"
10
13
  ? ["vendor/bin/n98-magerun2", "bin/n98-magerun2", "n98-magerun2", "magerun2"]
11
14
  : ["n98-magerun2", "magerun2"];
12
15
  for (const candidate of candidates) {
13
- const result = await backend.run(["sh", "-lc", `command -v ${candidate} 2>/dev/null || test -x ${candidate} && printf '%s' ${candidate}`]);
16
+ const result = await backend.run(["sh", "-lc", `command -v -- ${candidate} 2>/dev/null || { test -x ${candidate} && printf '%s' ${candidate}; }`]);
14
17
  const executable = result.stdout.trim();
15
18
  if (executable)
16
- return { executable };
19
+ return { available: true, executable };
17
20
  }
18
21
  if (environment.type === "ssh")
19
- return { diagnostic: environment.writable ? "Install n98-magerun2 on the remote host to enable PHP REPL." : "PHP REPL is disabled for read-only SSH." };
22
+ return { available: false, diagnostic: environment.writable ? "Install n98-magerun2 on the remote host." : "Magerun2 is disabled for read-only SSH." };
20
23
  try {
21
- return { executable: await installMagerun(merchantHome(), cwd) };
24
+ return { available: true, executable: await installMagerun2(merchantHome(), cwd) };
22
25
  }
23
26
  catch (error) {
24
- return { diagnostic: `Could not install n98-magerun2: ${error.message}` };
27
+ return { available: false, diagnostic: `Could not install n98-magerun2: ${error.message}` };
25
28
  }
26
29
  }
27
- /** Install the official PHAR outside the Magento project; verify the release API SHA-256 digest before publishing it. */
28
- export async function installMagerun(home, _cwd, request = fetch) {
30
+ /** Run a structured argument vector. Local commands are host processes; Warden and SSH use their selected backend. */
31
+ export async function runMagerun2(capability, environment, backend, cwd, args, stdin, signal, host = new NodeHostProcessRunner()) {
32
+ if (!capability.executable)
33
+ throw new Error(capability.diagnostic ?? "n98-magerun2 is unavailable.");
34
+ if (environment.type === "local") {
35
+ const command = backend.command([capability.executable, ...args]);
36
+ const result = await host.run(command.file, command.args, { cwd: command.cwd ?? cwd, signal, input: stdin });
37
+ return boundedResult(result.output, result.code, result.truncated);
38
+ }
39
+ const result = await backend.run([capability.executable, ...args], { input: stdin, signal });
40
+ return boundedResult(`${result.stdout}${result.stderr}`, result.exitCode, result.truncated);
41
+ }
42
+ /** Stable display only: this is never passed to a shell. */
43
+ export function quotedMagerunPreview(executable, args) {
44
+ return [executable, ...args].map((value) => `'${value.replace(/'/g, "'\\''")}'`).join(" ");
45
+ }
46
+ function boundedResult(output, exitCode, alreadyTruncated = false) {
47
+ const truncated = alreadyTruncated || output.length > MAX_OUTPUT;
48
+ return { output: truncated ? `${output.slice(0, MAX_OUTPUT)}\n[output truncated]` : output, exitCode, truncated };
49
+ }
50
+ /** Install the official PHAR outside the Magento project; verify its release SHA-256 before publishing it. */
51
+ export async function installMagerun2(home, _cwd, request = fetch) {
29
52
  const directory = join(home, "tools", "n98-magerun2");
30
53
  const destination = join(directory, "n98-magerun2.phar");
31
54
  const digestPath = `${destination}.sha256`;
@@ -49,11 +72,10 @@ export async function installMagerun(home, _cwd, request = fetch) {
49
72
  if (!phar.ok)
50
73
  throw new Error("official PHAR download failed");
51
74
  const bytes = new Uint8Array(await phar.arrayBuffer());
52
- const actual = sha256(bytes);
53
- if (!expected || actual !== expected)
75
+ if (sha256(bytes) !== expected)
54
76
  throw new Error("PHAR SHA-256 verification failed");
55
77
  const temporary = `${destination}.${process.pid}.${randomUUID()}.tmp`;
56
- await BunOrNode.write(temporary, bytes);
78
+ await writeFile(temporary, bytes, { mode: 0o700, flag: "wx" });
57
79
  await chmod(temporary, 0o700);
58
80
  try {
59
81
  await rename(temporary, destination);
@@ -70,4 +92,3 @@ export async function installMagerun(home, _cwd, request = fetch) {
70
92
  return destination;
71
93
  }
72
94
  function sha256(bytes) { return createHash("sha256").update(bytes).digest("hex"); }
73
- const BunOrNode = { async write(path, bytes) { const { writeFile } = await import("node:fs/promises"); await writeFile(path, bytes, { mode: 0o700, flag: "wx" }); } };
@@ -1,13 +1,20 @@
1
1
  import { refreshStatus } from "#app/status";
2
2
  import { merchantDuoSystemPrompt } from "#app/system-prompt";
3
- import { discoverTesting, discoverMagerun } from "#testing/index";
3
+ import { discoverTesting } from "#testing/index";
4
+ import { discoverMagerun2 } from "#features/magerun2/index";
4
5
  import { discoverHostExecutable } from "#testing/host";
5
6
  import { runtime, sessionEvents } from "#integrations/pi/session";
6
7
  export default function context(pi) {
7
8
  pi.on("session_start", async (_event, ctx) => {
8
9
  runtime.resetInjection();
9
10
  const state = await runtime.boot(ctx.cwd);
10
- const magerun = await discoverMagerun(state.selected.environment, state.backend, ctx.cwd);
11
+ if (state.environmentStatus === "stopped") {
12
+ refreshStatus(ctx, state);
13
+ sessionEvents.emit("merchantduo.context", state);
14
+ pi.appendEntry("merchantduo.context", { environment: state.selected.name, environmentStatus: state.environmentStatus });
15
+ return;
16
+ }
17
+ const magerun2 = await discoverMagerun2(state.selected.environment, state.backend, ctx.cwd);
11
18
  // HTTP and Chromium deliberately run on the host against Warden's routed URL.
12
19
  // Do not use EnvironmentBackend here: its Warden adapter would probe php-fpm.
13
20
  // Prefer non-Snap Chrome: Snap Chromium cannot write MerchantDuo artifacts
@@ -16,12 +23,11 @@ export default function context(pi) {
16
23
  discoverHostExecutable(["curl"]),
17
24
  discoverHostExecutable(["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"]),
18
25
  ]);
19
- runtime.setTesting(await discoverTesting(state.selected.environment, state.backend, ctx.cwd, { ...state.config.testing, ...state.selected.environment.testing, allowInsecureTls: state.selected.environment.testing?.allowInsecureTls ?? state.config.testing.allowInsecureTls }, { http, browser, magerun: magerun.executable }));
20
- if (magerun.diagnostic)
21
- state.testing?.diagnostics.push(magerun.diagnostic);
26
+ runtime.setMagerun2(magerun2);
27
+ runtime.setTesting(await discoverTesting(state.selected.environment, state.backend, ctx.cwd, { ...state.config.testing, ...state.selected.environment.testing, allowInsecureTls: state.selected.environment.testing?.allowInsecureTls ?? state.config.testing.allowInsecureTls }, { http, browser }));
22
28
  refreshStatus(ctx, state);
23
29
  sessionEvents.emit("merchantduo.context", state);
24
- pi.appendEntry("merchantduo.context", { environment: state.selected.name, testing: state.testing });
30
+ pi.appendEntry("merchantduo.context", { environment: state.selected.name, environmentStatus: state.environmentStatus, testing: state.testing, magerun2: state.magerun2 });
25
31
  });
26
32
  pi.on("session_compact", () => runtime.resetInjection());
27
33
  pi.on("session_shutdown", () => sessionEvents.clear());
@@ -41,6 +47,6 @@ export default function context(pi) {
41
47
  pi.on("before_agent_start", async (event, ctx) => {
42
48
  const state = await runtime.boot(ctx.cwd);
43
49
  const testing = state.testing;
44
- return { systemPrompt: `${event.systemPrompt}\n${merchantDuoSystemPrompt()}\nTesting URLs are discovered but unverified. Frontend: ${testing?.frontendUrl ?? "unavailable"}; Admin: ${testing?.adminUrl ?? "unavailable"}. HTTP, browser, navigation, and test artifacts are host-only; never use environment bash to locate or repair their host paths. Use explicit test tools only when relevant.` };
50
+ return { systemPrompt: `${event.systemPrompt}\n${merchantDuoSystemPrompt()}\nEnvironment: ${state.selected.name} is ${state.environmentStatus}. ${state.environmentStatus === "stopped" ? "Use environment_start only after the user directly asks to start it; do not use project tools until it is running." : "Testing URLs are discovered but unverified. Frontend: " + (testing?.frontendUrl ?? "unavailable") + "; Admin: " + (testing?.adminUrl ?? "unavailable") + ". HTTP, browser, navigation, and test artifacts are host-only; never use environment bash to locate or repair their host paths. Use explicit test tools only when relevant."}` };
45
51
  });
46
52
  }
@@ -0,0 +1,3 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ /** Warden is managed directly; a local project's lifecycle is selected by the agent from live project evidence. */
3
+ export default function environment(pi: ExtensionAPI): void;
@@ -0,0 +1,64 @@
1
+ import { Type } from "typebox";
2
+ import { refreshStatus } from "#app/status";
3
+ import { runtime, text } from "#integrations/pi/session";
4
+ /** Warden is managed directly; a local project's lifecycle is selected by the agent from live project evidence. */
5
+ export default function environment(pi) {
6
+ pi.registerTool({ name: "environment_status", label: "Environment status", description: "Report whether the selected MerchantDuo environment is running or stopped.", parameters: Type.Object({}), async execute(_id, _params, _signal, _update, ctx) {
7
+ const state = await runtime.boot(ctx.cwd);
8
+ return text(environmentMessage(state));
9
+ } });
10
+ pi.registerTool({ name: "environment_start", label: "Start environment", description: "Start Warden directly, or run the agent-selected local lifecycle command after inspection.", parameters: Type.Object({ command: Type.Optional(Type.String({ minLength: 1 })) }), async execute(_id, params, signal, _update, ctx) {
11
+ const state = await change("start", ctx.cwd, params.command, signal);
12
+ refreshStatus(ctx, state);
13
+ return text(environmentMessage(state));
14
+ } });
15
+ pi.registerTool({ name: "environment_stop", label: "Stop environment", description: "Stop Warden directly, or run the agent-selected local lifecycle command after inspection.", parameters: Type.Object({ command: Type.Optional(Type.String({ minLength: 1 })) }), async execute(_id, params, signal, _update, ctx) {
16
+ const state = await change("stop", ctx.cwd, params.command, signal);
17
+ refreshStatus(ctx, state);
18
+ return text(environmentMessage(state));
19
+ } });
20
+ pi.registerTool({ name: "environment_set_status", label: "Set environment status", description: "Record the local environment lifecycle status after inspecting or running its project-specific commands. Include concise command/output evidence.", parameters: Type.Object({ status: Type.Union([Type.Literal("running"), Type.Literal("stopped")]), evidence: Type.String({ minLength: 1 }) }), async execute(_id, params, _signal, _update, ctx) {
21
+ const state = await runtime.boot(ctx.cwd);
22
+ if (state.selected.environment.type !== "local")
23
+ throw new Error("Only local environment status is agent-reported.");
24
+ refreshStatus(ctx, runtime.setEnvironmentStatus(params.status));
25
+ return text(`Local environment marked ${params.status}: ${params.evidence}`);
26
+ } });
27
+ registerCommand(pi, "duo-env-start", "start");
28
+ registerCommand(pi, "duo-env-stop", "stop");
29
+ }
30
+ function registerCommand(pi, name, action) {
31
+ pi.registerCommand(name, { description: `${action === "start" ? "Start" : "Stop"} the selected environment. Warden is direct; local is agent-directed.`, handler: async (_args, ctx) => {
32
+ const state = await runtime.boot(ctx.cwd);
33
+ if (state.selected.environment.type === "local") {
34
+ pi.sendUserMessage(localLifecyclePrompt(action));
35
+ ctx.ui.notify(`Asked the agent to ${action} the local environment from project evidence.`, "info");
36
+ return;
37
+ }
38
+ const changed = await change(action, ctx.cwd);
39
+ refreshStatus(ctx, changed);
40
+ ctx.ui.notify(environmentMessage(changed), "info");
41
+ } });
42
+ }
43
+ async function change(action, cwd, command, signal) {
44
+ const state = await runtime.boot(cwd);
45
+ if (state.selected.environment.type === "ssh")
46
+ throw new Error("SSH is always reported running; MerchantDuo never controls remote services.");
47
+ if (state.selected.environment.type === "local") {
48
+ if (!command)
49
+ throw new Error(`Inspect the local project first, then call environment_${action} with its exact lifecycle command.`);
50
+ const result = await state.backend.run(["sh", "-lc", command], { signal });
51
+ if (result.exitCode !== 0)
52
+ throw new Error(result.stderr || result.stdout || `Could not ${action} local environment`);
53
+ return runtime.setEnvironmentStatus(action === "start" ? "running" : "stopped");
54
+ }
55
+ return action === "start" ? runtime.startEnvironment(cwd) : runtime.stopEnvironment(cwd);
56
+ }
57
+ function localLifecyclePrompt(action) {
58
+ return `The user directly requested that you ${action} the selected local environment. This local stack is not assumed to use any one runtime. Inspect the project documentation, package scripts, Compose files, service configuration, and existing process state first. Determine the smallest correct ${action} command, explain the evidence and intended effect, then execute it through environment_${action} with the exact command. Do not use a generic command, do not stop unrelated host services, and if the project lifecycle cannot be determined safely, ask the user. After a successful command, the tool records ${action === "start" ? "running" : "stopped"}.`;
59
+ }
60
+ function environmentMessage(state) {
61
+ const environment = state.selected.environment;
62
+ const managed = environment.type === "warden" ? "Warden-managed" : environment.type === "local" ? "agent-directed" : "remote unmanaged";
63
+ return `${state.selected.name} (${environment.type}): ${state.environmentStatus} [${managed}]`;
64
+ }
@@ -0,0 +1,4 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ /** Generic magerun2 is intentionally separate from the typed PHP console. */
3
+ export default function magerun2(pi: ExtensionAPI): void;
4
+ export declare function magerun2Refusal(args: string[], readOnlySsh: boolean): string | undefined;
@@ -0,0 +1,37 @@
1
+ import { Type } from "typebox";
2
+ import { quotedMagerunPreview, runMagerun2 } from "#features/magerun2/index";
3
+ import { runtime, text } from "#integrations/pi/session";
4
+ /** Generic magerun2 is intentionally separate from the typed PHP console. */
5
+ export default function magerun2(pi) {
6
+ pi.registerTool({
7
+ name: "magerun2",
8
+ label: "n98-magerun2",
9
+ description: "Preview or explicitly run one structured n98-magerun2 subcommand in the selected environment. Use magento_php_repl for dev:console.",
10
+ parameters: Type.Object({
11
+ args: Type.Array(Type.String(), { minItems: 1 }),
12
+ stdin: Type.Optional(Type.String()),
13
+ execute: Type.Optional(Type.Boolean()),
14
+ }),
15
+ async execute(_id, params, signal, _update, ctx) {
16
+ const state = await runtime.boot(ctx.cwd);
17
+ const refusal = magerun2Refusal(params.args, state.selected.environment.type === "ssh" && !state.selected.environment.writable);
18
+ if (refusal)
19
+ return text(refusal);
20
+ const capability = state.magerun2;
21
+ if (!capability?.executable)
22
+ return text(capability?.diagnostic ?? "n98-magerun2 is unavailable.");
23
+ const preview = quotedMagerunPreview(capability.executable, params.args);
24
+ if (!params.execute)
25
+ return text(`Preview: ${preview}\nSet execute=true only after the user directly requests execution.`);
26
+ const result = await runMagerun2(capability, state.selected.environment, state.backend, ctx.cwd, params.args, params.stdin, signal);
27
+ return text(`Exit status: ${result.exitCode}${result.truncated ? " (output truncated)" : ""}\n${result.output}`);
28
+ },
29
+ });
30
+ }
31
+ export function magerun2Refusal(args, readOnlySsh) {
32
+ if (args.includes("dev:console"))
33
+ return "Refused: dev:console belongs to magento_php_repl, which accepts a typed snippet and optional area.";
34
+ if (readOnlySsh)
35
+ return "Refused: generic magerun2 is unavailable for read-only SSH because arbitrary subcommands cannot be classified safely.";
36
+ return undefined;
37
+ }
@@ -0,0 +1,9 @@
1
+ import type { ExtensionAPI, ProviderConfig } from "@earendil-works/pi-coding-agent";
2
+ /** Change these constants when MerchantDuo's hosted services change; never expose URL configuration. */
3
+ export declare const KNOWLEDGE_MCP_BASE_URL = "https://merchantduo.invalid/mcp";
4
+ export declare const MERCHANTDUO_LLM_BASE_URL = "https://merchantduo.invalid/v1";
5
+ export declare const MERCHANTDUO_LLM_PROVIDER = "merchantduo";
6
+ export declare const MERCHANTDUO_LLM_MODEL = "merchantduo-mock";
7
+ export declare function mcpToken(env?: NodeJS.ProcessEnv): string | undefined;
8
+ export declare function llmProviderConfig(env?: NodeJS.ProcessEnv): ProviderConfig | undefined;
9
+ export default function optionalServices(pi: ExtensionAPI): void;
@@ -0,0 +1,79 @@
1
+ import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
2
+ import { Type } from "typebox";
3
+ import { text } from "#integrations/pi/session";
4
+ /** Change these constants when MerchantDuo's hosted services change; never expose URL configuration. */
5
+ export const KNOWLEDGE_MCP_BASE_URL = "https://merchantduo.invalid/mcp";
6
+ export const MERCHANTDUO_LLM_BASE_URL = "https://merchantduo.invalid/v1";
7
+ export const MERCHANTDUO_LLM_PROVIDER = "merchantduo";
8
+ export const MERCHANTDUO_LLM_MODEL = "merchantduo-mock";
9
+ export function mcpToken(env = process.env) {
10
+ return env.MERCHANTDUO_MCP_TOKEN || undefined;
11
+ }
12
+ export function llmProviderConfig(env = process.env) {
13
+ if (!env.MERCHANTDUO_LLM_TOKEN)
14
+ return undefined;
15
+ return {
16
+ name: "MerchantDuo",
17
+ baseUrl: MERCHANTDUO_LLM_BASE_URL,
18
+ apiKey: "$MERCHANTDUO_LLM_TOKEN",
19
+ api: "openai-completions",
20
+ models: [{
21
+ id: MERCHANTDUO_LLM_MODEL,
22
+ name: "MerchantDuo",
23
+ input: ["text"],
24
+ reasoning: false,
25
+ contextWindow: 32_768,
26
+ maxTokens: 4_096,
27
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
28
+ }],
29
+ };
30
+ }
31
+ async function withKnowledgeClient(token, run) {
32
+ const transport = new StreamableHTTPClientTransport(new URL(KNOWLEDGE_MCP_BASE_URL), {
33
+ authProvider: { token: async () => token },
34
+ });
35
+ const client = new Client({ name: "merchantduo", version: "0.2.0" });
36
+ try {
37
+ await client.connect(transport);
38
+ return await run(client);
39
+ }
40
+ finally {
41
+ await client.close();
42
+ }
43
+ }
44
+ export default function optionalServices(pi) {
45
+ const provider = llmProviderConfig();
46
+ if (provider)
47
+ pi.registerProvider(MERCHANTDUO_LLM_PROVIDER, provider);
48
+ const token = mcpToken();
49
+ if (!token)
50
+ return;
51
+ pi.registerTool({
52
+ name: "merchantduo_knowledge",
53
+ label: "MerchantDuo knowledge",
54
+ description: "Use the built-in read-only MerchantDuo knowledge MCP. Omit tool to list its tools, then pass a listed tool name and a JSON object of arguments.",
55
+ parameters: Type.Object({ tool: Type.Optional(Type.String()), arguments: Type.Optional(Type.String()) }),
56
+ async execute(_id, params) {
57
+ try {
58
+ const result = await withKnowledgeClient(token, async (client) => {
59
+ if (!params.tool)
60
+ return await client.listTools();
61
+ const tools = await client.listTools();
62
+ const selected = tools.tools.find((tool) => tool.name === params.tool);
63
+ if (!selected)
64
+ throw new Error(`unknown MCP tool: ${params.tool}`);
65
+ if (!selected.annotations?.readOnlyHint)
66
+ throw new Error(`refused non-read-only MCP tool: ${params.tool}`);
67
+ const arguments_ = params.arguments ? JSON.parse(params.arguments) : {};
68
+ if (!arguments_ || Array.isArray(arguments_) || typeof arguments_ !== "object")
69
+ throw new Error("arguments must be a JSON object");
70
+ return await client.callTool({ name: params.tool, arguments: arguments_ });
71
+ });
72
+ return text(JSON.stringify(result));
73
+ }
74
+ catch (error) {
75
+ return text(`MerchantDuo knowledge MCP unavailable: ${error.message}`);
76
+ }
77
+ },
78
+ });
79
+ }
@@ -1,6 +1,6 @@
1
1
  import { runtime } from "#integrations/pi/session";
2
2
  export function requiresExecuteConfirmation(toolName, input) {
3
- return (toolName === "mage2gen_generate_module" || toolName === "magento_workflow") && input.execute === true;
3
+ return ["environment_start", "environment_stop"].includes(toolName) || ((toolName === "mage2gen_generate_module" || toolName === "magento_workflow" || toolName === "magerun2") && input.execute === true);
4
4
  }
5
5
  export function requiresSshConfirmation(environment, toolName) {
6
6
  return environment.type === "ssh" && environment.writable && ["write", "edit", "bash"].includes(toolName);
@@ -10,6 +10,8 @@ export default function permissions(pi) {
10
10
  pi.on("tool_call", async (event, ctx) => {
11
11
  const state = await runtime.boot(ctx.cwd);
12
12
  const input = event.input;
13
+ if (state.environmentStatus === "stopped" && !["environment_status", "environment_start", "environment_stop", "environment_set_status"].includes(event.toolName))
14
+ return { block: true, reason: "MerchantDuo: selected environment is stopped. Start it first." };
13
15
  if (requiresExecuteConfirmation(event.toolName, input)) {
14
16
  const ok = ctx.hasUI && await ctx.ui.confirm("Confirm Magento action", `Execute ${event.toolName}?`);
15
17
  return ok ? undefined : { block: true, reason: "Cancelled: confirmation required." };
@@ -58,21 +58,22 @@ export default function testing(pi) {
58
58
  const state = await runtime.boot(ctx.cwd);
59
59
  if (state.selected.environment.type === "ssh" && !state.selected.environment.writable)
60
60
  return text("PHP REPL is unavailable for read-only SSH.");
61
- const executable = state.testing?.phpRepl.executable;
61
+ const executable = state.magerun2?.executable;
62
62
  if (!executable)
63
- return text(state.testing?.phpRepl.reason ?? "PHP REPL unavailable.");
64
- const args = [executable, "dev:console"];
63
+ return text(state.magerun2?.diagnostic ?? "PHP REPL unavailable.");
64
+ const args = ["dev:console"];
65
65
  if (params.area)
66
66
  args.push(`--area=${params.area}`);
67
67
  if (state.selected.environment.type === "local") {
68
- const result = await host.run("sh", ["-lc", `${quote(executable)} dev:console${params.area ? ` --area=${params.area}` : ""}`], { cwd: ctx.cwd, signal, input: params.snippet });
68
+ const command = state.backend.command([executable, ...args]);
69
+ const result = await host.run(command.file, command.args, { cwd: command.cwd ?? ctx.cwd, signal, input: params.snippet });
69
70
  return text(bound(result.output));
70
71
  }
71
- const result = await state.backend.run(args, { input: params.snippet, signal });
72
+ const result = await state.backend.run([executable, ...args], { input: params.snippet, signal });
72
73
  return text(bound(`${result.stdout}${result.stderr}`));
73
74
  } });
74
- registerNavigate(pi, "navigate-frontend", "frontend");
75
- registerNavigate(pi, "navigate-adminhtml", "adminhtml");
75
+ registerNavigate(pi, "duo-navigate-frontend", "frontend");
76
+ registerNavigate(pi, "duo-navigate-adminhtml", "adminhtml");
76
77
  }
77
78
  function registerNavigate(pi, name, target) { pi.registerCommand(name, { description: `Open the discovered Magento ${target} URL in the default browser.`, handler: async (args, ctx) => { const state = await runtime.boot(ctx.cwd); const base = target === "frontend" ? state.testing?.frontendUrl : state.testing?.adminUrl; const url = targetUrl(base, args.trim() || "/"); if (!url) {
78
79
  ctx.ui.notify("URL unavailable or path is not same-origin.", "error");
@@ -84,5 +85,4 @@ function registerNavigate(pi, name, target) { pi.registerCommand(name, { descrip
84
85
  function targetUrl(base, path) { if (!relativePath(path))
85
86
  return undefined; const url = joinedUrl(base, path); return url && sameOrigin(base, url) ? url : undefined; }
86
87
  function projectId(cwd) { return cwd.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "").slice(-80) || "project"; }
87
- function quote(value) { return `'${value.replace(/'/g, "'\\''")}'`; }
88
88
  const BunOrNode = { async write(path, value) { const { writeFile } = await import("node:fs/promises"); await writeFile(path, value, "utf8"); } };
@@ -1,6 +1,6 @@
1
1
  import { refreshStatus } from "#app/status";
2
2
  import { runtime } from "#integrations/pi/session";
3
- export default function theme(pi) { pi.registerCommand("theme", { description: "Select all, any, or a detected Magento theme.", handler: async (args, ctx) => { const state = await runtime.boot(ctx.cwd); const value = args.trim(); const choices = ["all", "any", ...state.magento.themes.map((theme) => theme.code)]; if (!choices.includes(value)) {
3
+ export default function theme(pi) { pi.registerCommand("duo-switch-theme", { description: "Select all, any, or a detected Magento theme.", handler: async (args, ctx) => { const state = await runtime.boot(ctx.cwd); const value = args.trim(); const choices = ["all", "any", ...state.magento.themes.map((theme) => theme.code)]; if (!choices.includes(value)) {
4
4
  ctx.ui.notify(`Choose one of: ${choices.join(", ")}`, "error");
5
5
  return;
6
6
  } const changed = await runtime.changeTheme(ctx.cwd, value); refreshStatus(ctx, changed); ctx.ui.notify(`Theme scope set to ${value}.`, "info"); } }); }
@@ -6,7 +6,7 @@ export default function workflows(pi) {
6
6
  pi.registerTool({ name: "magento_workflow", label: "Magento workflow", description: "Preview or explicitly execute a supported Magento operational action.", parameters: Type.Object({ action: Type.String(), execute: Type.Optional(Type.Boolean()) }), async execute(_id, params, _signal, _update, ctx) {
7
7
  const state = await runtime.boot(ctx.cwd);
8
8
  if (params.action === "status")
9
- return text(JSON.stringify({ snapshot: state.magento, testing: state.testing, pendingFiles: runtime.changes.files(), workflows: builtInWorkflows.map(({ id, description }) => ({ id, description })) }, null, 2));
9
+ return text(JSON.stringify({ snapshot: state.magento, testing: state.testing, magerun2: state.magerun2, pendingFiles: runtime.changes.files(), workflows: builtInWorkflows.map(({ id, description }) => ({ id, description })) }, null, 2));
10
10
  if (params.action === "syntax-check")
11
11
  return text((await syntaxCheck(state.backend, runtime.changes.files())).join("\n") || "No supported changed files require syntax checks.");
12
12
  const workflow = builtInWorkflows.find(({ id }) => id === params.action);
@@ -4,7 +4,6 @@ import type { TestingOptions, TestingSnapshot } from "#testing/model";
4
4
  export declare function discoverTesting(environment: Environment, backend: EnvironmentBackend, cwd: string, options: TestingOptions, tools?: {
5
5
  http?: string;
6
6
  browser?: string;
7
- magerun?: string;
8
7
  }): Promise<TestingSnapshot>;
9
8
  export declare function wardenRouting(projectRoot: string): Promise<string | undefined>;
10
9
  export declare function findExecutable(candidates: string[]): Promise<string | undefined>;
@@ -32,16 +32,13 @@ export async function discoverTesting(environment, backend, cwd, options, tools
32
32
  const browser = tools.browser
33
33
  ? { available: true, executable: tools.browser }
34
34
  : { available: false, reason: "Install Chromium or Chrome." };
35
- const phpRepl = tools.magerun
36
- ? { available: true, executable: tools.magerun }
37
- : { available: false, reason: "n98-magerun2 is unavailable." };
38
35
  if (!frontendUrl)
39
36
  diagnostics.push("Frontend URL unavailable.");
40
37
  if (!adminUrl)
41
38
  diagnostics.push("Admin URL unavailable.");
42
39
  diagnostics.push("URLs are discovered but unverified; no startup HTTP request was made.");
43
40
  const http = tools.http ? { available: true, executable: tools.http } : { available: false, reason: "Install curl." };
44
- return { frontendUrl, adminUrl, http, browser, phpRepl, diagnostics };
41
+ return { frontendUrl, adminUrl, http, browser, diagnostics };
45
42
  }
46
43
  export async function wardenRouting(projectRoot) {
47
44
  try {
@@ -1,6 +1,7 @@
1
1
  export type HostProcessResult = {
2
2
  output: string;
3
3
  code: number;
4
+ truncated?: boolean;
4
5
  };
5
6
  export interface HostProcessRunner {
6
7
  run(file: string, args: string[], options: {
@@ -7,16 +7,27 @@ export class NodeHostProcessRunner {
7
7
  return new Promise((done) => {
8
8
  const child = spawn(file, args, { cwd: options.cwd, signal: options.signal });
9
9
  let output = "";
10
+ let truncated = false;
10
11
  let settled = false;
11
12
  const finish = (result) => { if (!settled) {
12
13
  settled = true;
13
14
  done(result);
14
15
  } };
15
- const add = (data) => { output = bound(output + data.toString()); };
16
+ const add = (data) => {
17
+ if (truncated)
18
+ return;
19
+ const next = output + data.toString();
20
+ if (next.length > MAX_OUTPUT) {
21
+ output = `${next.slice(0, MAX_OUTPUT)}\n[output truncated]`;
22
+ truncated = true;
23
+ }
24
+ else
25
+ output = next;
26
+ };
16
27
  child.stdout.on("data", add);
17
28
  child.stderr.on("data", add);
18
- child.on("error", (error) => finish({ output: error.message, code: 127 }));
19
- child.on("close", (code) => finish({ output, code: code ?? 1 }));
29
+ child.on("error", (error) => finish({ output: error.message, code: 127, truncated }));
30
+ child.on("close", (code) => finish({ output, code: code ?? 1, truncated }));
20
31
  if (options.input !== undefined)
21
32
  child.stdin.end(options.input);
22
33
  });
@@ -1,5 +1,4 @@
1
1
  export * from "#testing/model";
2
2
  export * from "#testing/url";
3
3
  export * from "#testing/discovery";
4
- export * from "#testing/magerun";
5
4
  export * from "#testing/host";
@@ -1,5 +1,4 @@
1
1
  export * from "#testing/model";
2
2
  export * from "#testing/url";
3
3
  export * from "#testing/discovery";
4
- export * from "#testing/magerun";
5
4
  export * from "#testing/host";
@@ -8,7 +8,6 @@ export type TestingSnapshot = {
8
8
  adminUrl?: string;
9
9
  http: Capability;
10
10
  browser: Capability;
11
- phpRepl: Capability;
12
11
  diagnostics: string[];
13
12
  };
14
13
  export type TestingOptions = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@merchantduo/code",
3
- "version": "0.2.0-beta.2",
3
+ "version": "0.2.0-beta.3",
4
4
  "private": false,
5
5
  "description": "Magento-native Pi-based Coding Agent",
6
6
  "type": "module",
@@ -40,11 +40,14 @@
40
40
  "pi": {
41
41
  "extensions": [
42
42
  "./dist/integrations/pi/context.js",
43
+ "./dist/integrations/pi/optional-services.js",
43
44
  "./dist/integrations/pi/permissions.js",
44
45
  "./dist/integrations/pi/workspace.js",
46
+ "./dist/integrations/pi/environment.js",
45
47
  "./dist/integrations/pi/theme.js",
46
48
  "./dist/integrations/pi/mage2gen.js",
47
49
  "./dist/integrations/pi/workflows.js",
50
+ "./dist/integrations/pi/magerun2.js",
48
51
  "./dist/integrations/pi/testing.js"
49
52
  ],
50
53
  "skills": [
@@ -54,6 +57,7 @@
54
57
  },
55
58
  "dependencies": {
56
59
  "@earendil-works/pi-coding-agent": "0.84.1",
60
+ "@modelcontextprotocol/client": "2.0.0",
57
61
  "typebox": "1.3.7",
58
62
  "yaml": "^2.8.1",
59
63
  "zod": "^3.24.4"
@@ -0,0 +1,22 @@
1
+ ---
2
+ name: magerun2
3
+ description: Use structured n98-magerun2 commands for Magento operations that MerchantDuo does not model as a workflow.
4
+ ---
5
+
6
+ # Universal magerun2
7
+
8
+ MerchantDuo discovers magerun2 once for the selected environment. Local uses a project binary, PATH binary, or a verified official PHAR cache; Warden uses its PHP service; SSH uses its remote binary. The capability and diagnostic appear in `magento_workflow` status.
9
+
10
+ Use `magento_workflow` for its supported cache, deployment, indexing, and test operations. Use `magerun2` only for a command that the workflow does not model. Pass exact structured arguments, never a shell command string. For example:
11
+
12
+ ```json
13
+ {"args":["list"]}
14
+ ```
15
+
16
+ ```json
17
+ {"args":["help","sys:cron:list"]}
18
+ ```
19
+
20
+ Without `execute: true`, the tool returns an exact quoted preview. Set `execute: true` only after the user directly asks to run the command; MerchantDuo then provides its one confirmation dialog. `stdin` is optional for subcommands that require it.
21
+
22
+ Do not call `dev:console` through this tool. Use `magento_php_repl` with its typed `snippet` and optional `area`. Generic magerun2 is refused on read-only SSH because arbitrary subcommands cannot be safely classified.
@@ -1,8 +0,0 @@
1
- import type { EnvironmentBackend } from "#environments/backend";
2
- import type { Environment } from "#environments/model";
3
- export declare function discoverMagerun(environment: Environment, backend: EnvironmentBackend, cwd: string): Promise<{
4
- executable?: string;
5
- diagnostic?: string;
6
- }>;
7
- /** Install the official PHAR outside the Magento project; verify the release API SHA-256 digest before publishing it. */
8
- export declare function installMagerun(home: string, _cwd: string, request?: typeof fetch): Promise<string>;