@merchantduo/code 0.3.0-beta.0 → 0.3.0-beta.2

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 (42) hide show
  1. package/README.md +5 -5
  2. package/dist/app/system-prompt.d.ts +2 -1
  3. package/dist/app/system-prompt.js +10 -4
  4. package/dist/cli/arguments.d.ts +0 -3
  5. package/dist/cli/arguments.js +5 -13
  6. package/dist/cli/commands/agent.d.ts +7 -0
  7. package/dist/cli/commands/agent.js +46 -1
  8. package/dist/cli/commands/init.d.ts +14 -1
  9. package/dist/cli/commands/init.js +36 -2
  10. package/dist/cli/commands/provision.d.ts +10 -5
  11. package/dist/cli/commands/provision.js +22 -33
  12. package/dist/cli/main.js +1 -1
  13. package/dist/config/index.d.ts +2 -1
  14. package/dist/config/index.js +2 -1
  15. package/dist/config/loader.js +1 -5
  16. package/dist/config/operator-profile.d.ts +5 -0
  17. package/dist/config/operator-profile.js +28 -0
  18. package/dist/config/schema.d.ts +2 -0
  19. package/dist/config/schema.js +6 -0
  20. package/dist/environments/adapters/warden.d.ts +7 -1
  21. package/dist/environments/adapters/warden.js +24 -3
  22. package/dist/integrations/pi/context.js +3 -1
  23. package/dist/integrations/pi/permissions.d.ts +1 -0
  24. package/dist/integrations/pi/permissions.js +10 -3
  25. package/dist/integrations/pi/workspace.d.ts +1 -0
  26. package/dist/integrations/pi/workspace.js +36 -6
  27. package/dist/workflows/post-edit.d.ts +7 -0
  28. package/dist/workflows/post-edit.js +51 -0
  29. package/package.json +1 -2
  30. package/skills/magento-2.4/SKILL.md +241 -2
  31. package/dist/provision/advisor.d.ts +0 -12
  32. package/dist/provision/advisor.js +0 -28
  33. package/dist/provision/discovery.d.ts +0 -12
  34. package/dist/provision/discovery.js +0 -61
  35. package/dist/provision/interview.d.ts +0 -9
  36. package/dist/provision/interview.js +0 -29
  37. package/dist/provision/model.d.ts +0 -82
  38. package/dist/provision/model.js +0 -5
  39. package/dist/provision/planner.d.ts +0 -4
  40. package/dist/provision/planner.js +0 -51
  41. package/dist/provision/runner.d.ts +0 -35
  42. package/dist/provision/runner.js +0 -98
package/README.md CHANGED
@@ -48,7 +48,7 @@ environments:
48
48
  root: /var/www/html
49
49
  ```
50
50
 
51
- Local uses the checkout directly. Warden routes project and Magento execution through its configured service. Local and Warden sessions start in `normal`; SSH starts in `read-only`. Use `/duo-switch-permissions <read-only|normal|yolo>` to change only the current session, or `merchantduo --yolo` to start in `yolo`. Configure SSH user, port, and key through an OpenSSH host alias:
51
+ Local uses the checkout directly. Warden routes project and Magento execution through its configured service. Local and Warden sessions start in `normal`; SSH starts in `read-only`. Run `/duo-switch-permissions` to choose a mode interactively, or provide `<read-only|normal|yolo>` directly to change only the current session; `merchantduo --yolo` starts in `yolo`. Configure SSH user, port, and key through an OpenSSH host alias:
52
52
 
53
53
  ```sshconfig
54
54
  Host shop-stage
@@ -58,7 +58,7 @@ Host shop-stage
58
58
  IdentityFile ~/.ssh/id_ed25519
59
59
  ```
60
60
 
61
- 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.
61
+ Use that alias as `host: shop-stage`. Every `merchantduo init` asks “Choose what fits you most” and saves the selected role under the current user's `~/.merchantduo/operator-profile.yaml`: store admin, backend developer, frontend developer, or full-stack developer. It then offers local first and Warden when its project `.env` is detected; it prints an SSH example instead of writing an unused remote environment. The role is optional to load and gives the agent context for explanations and recommendations only; it does not change tools or permissions. Project configuration contains no user identity and is safe to share in a repository.
62
62
 
63
63
  ## Provisioning
64
64
 
@@ -66,10 +66,10 @@ Provision a local or Warden developer store from an empty directory or an existi
66
66
 
67
67
  ```sh
68
68
  merchantduo provision
69
- merchantduo provision --env warden --source https://git.example.test/store.git --database ./backup.sql.gz --yes
69
+ merchantduo provision --env warden
70
70
  ```
71
71
 
72
- 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.
72
+ This opens plain Pi in the host shell, even when the project defaults to Warden. It shares MerchantDuo’s configured Pi authentication, models, and user settings, but does not load the MerchantDuo package or initialize Magento context. The agent first inspects the real project, Warden/local runtime, Composer platform requirements, Magento state, and source/database evidence. It does not run a fixed Composer, database, Magento, lifecycle, or web-server sequence. Before a material mutation, it explains the evidence and intended effect and waits for confirmation. In particular, lock-file PHP incompatibility is diagnosed rather than bypassed with `composer update`. Unlike normal MerchantDuo sessions, provisioning may inspect and configure Magento deployment configuration such as `app/etc/env.php` and `app/etc/config.php` when needed; it must not reveal credentials or tokens in chat, commands, logs, or generated files.
73
73
 
74
74
  ## Install and start
75
75
 
@@ -86,7 +86,7 @@ Review the generated `.merchantduo.yaml` before the first session. MerchantDuo r
86
86
 
87
87
  ## Safety and optional services
88
88
 
89
- 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).
89
+ Normal MerchantDuo sessions do not load `app/etc/env.php`, credentials, or tokens into agent context. The separate provisioning session may configure Magento deployment files, but never reveals their secret values. MerchantDuo 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).
90
90
 
91
91
  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.
92
92
 
@@ -1,2 +1,3 @@
1
+ import type { OperatorRole } from "#config/index";
1
2
  export declare function mage2genRoot(): string;
2
- export declare function merchantDuoSystemPrompt(mage2genPath?: string): string;
3
+ export declare function merchantDuoSystemPrompt(operatorRole: OperatorRole | undefined, mage2genPath?: string): string;
@@ -2,10 +2,16 @@ import { packagePath } from "#shared/package-paths";
2
2
  export function mage2genRoot() {
3
3
  return packagePath("vendor/mage2gen");
4
4
  }
5
- export function merchantDuoSystemPrompt(mage2genPath = mage2genRoot()) {
6
- return `You are MerchantDuo, a senior Magento 2.4 architect and implementation partner. Work directly in the selected MerchantDuo environment and theme scope. Before changing code, inspect the relevant module, theme inheritance chain, configuration, and established local conventions. Make focused, production-quality changes; do not preserve obsolete compatibility paths or add speculative abstractions.
5
+ // Source: MAGENTO_SYSTEM_PROMPT.md. Keep this contract verbatim in every Magento session.
6
+ const generalMagentoEngineeringPrompt = "# Magento Engineering\n\nFor Magento Open Source / Adobe Commerce work, optimize for the **smallest Magento-native, maintainable, upgrade-safe change**, not merely a working PHP implementation.\n\nBefore version-sensitive decisions, inspect the actual project when possible: Magento edition/version, `composer.lock`, PHP version, installed modules, theme, Hyvä packages, and relevant third-party versions. Never assume Luma, Hyvä, Commerce features, or extension APIs are present.\n\nPrefer documented Magento mechanisms over broad overrides or custom infrastructure. Prefer composition over inheritance. Treat non-`@api` Magento/vendor internals as upgrade-sensitive.\n\nDo not blindly apply folklore such as \"always use repositories\", \"never use preferences\", or \"plugins are always better\". Choose mechanisms according to their intended scope and blast radius.\n\nDefault engineering expectations:\n\n- constructor DI for normal dependencies; no direct `ObjectManager` in feature/business code;\n- declarative XML/configuration before runtime PHP when Magento provides the capability;\n- narrow plugins/events/composition before broad class replacement;\n- business logic in reusable services, not controllers/plugins/observers/resolvers/templates;\n- explicit request/customer/store/quote context passed into services instead of deep session/request dependencies;\n- batch-oriented data access; avoid N+1 and repository/entity loads in large loops;\n- respect FPC, cache identities, indexers/MView, retries, concurrency, ACL, ownership, escaping, CSRF, and production compilation;\n- never edit `vendor/` directly.\n\nFor non-trivial Magento architecture, implementation, extension, review, or refactoring tasks, load the `magento-extension-best-practices` skill before deciding the implementation approach. This especially applies to DI, plugins/preferences/events, service contracts and persistence, schema/XML, cache/indexers/queues, Admin UI, Luma/Hyvä frontend, REST/GraphQL, security, and testing.";
7
+ export function merchantDuoSystemPrompt(operatorRole, mage2genPath = mage2genRoot()) {
8
+ return `${generalMagentoEngineeringPrompt}
7
9
 
8
- Ground every design and implementation decision in existing source before introducing a pattern. Find the closest applicable example and read its surrounding call sites, configuration, and tests. Search Magento core under vendor/magento first; if it has no suitable example, search other installed vendor modules or project app/code modules. Adapt the applicable pattern rather than copying blindly, and explain any material intentional difference. Only when no relevant precedent exists may you introduce a new pattern, and state that finding before doing so.
10
+ You are MerchantDuo, a senior Magento 2.4 architect and implementation partner. Work directly in the selected MerchantDuo environment and theme scope. Before changing code, inspect the relevant module, theme inheritance chain, configuration, and established local conventions. Make focused, production-quality changes; do not preserve obsolete compatibility paths or add speculative abstractions.
11
+
12
+ ${operatorRole ? `The operator is a ${operatorRole.replace(/-/g, " ")}. Use this only as context for framing explanations and recommendations; it does not limit available tools, permissions, or supported work.
13
+
14
+ ` : ""}Ground every design and implementation decision in existing source before introducing a pattern. Find the closest applicable example and read its surrounding call sites, configuration, and tests. Search Magento core under vendor/magento first; if it has no suitable example, search other installed vendor modules or project app/code modules. Adapt the applicable pattern rather than copying blindly, and explain any material intentional difference. Only when no relevant precedent exists may you introduce a new pattern, and state that finding before doing so.
9
15
 
10
16
  Use Magento service contracts, dependency injection, declarative schema/data patches, layout XML, and theme fallback mechanisms when they are the correct native extension points. Respect the active theme scope: all means cover every detected theme, any means identify the relevant theme from the request and code before theme-specific edits, and a concrete theme limits work to that theme and its inheritance chain. Do not use Luma RequireJS/Knockout conventions for Hyva work.
11
17
 
@@ -19,5 +25,5 @@ Session permission mode is shown in the startup context and can be changed only
19
25
 
20
26
  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.
21
27
 
22
- 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.`;
28
+ 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. In Warden sessions, use container paths under the configured root, normally \`/var/www/html\`; if a tool maps a host path, follow its warning. Successful writes and edits return post-edit hints. Fix any reported Magento XML XSD validation warning before moving on. Never inspect app/etc/env.php or credentials.`;
23
29
  }
@@ -2,9 +2,6 @@ export type CliArguments = {
2
2
  command?: "init" | "doctor" | "provision";
3
3
  environment?: string;
4
4
  configPath?: string;
5
- source?: string;
6
- database?: string;
7
- yes: boolean;
8
5
  yolo: boolean;
9
6
  piArguments: string[];
10
7
  };
@@ -4,29 +4,19 @@ export function parseArguments(args) {
4
4
  const piArguments = [];
5
5
  let environment;
6
6
  let configPath;
7
- let source;
8
- let database;
9
- let yes = false;
10
7
  let yolo = false;
11
8
  for (let index = 0; index < input.length; index += 1) {
12
9
  const value = input[index];
13
- if (value === "--env" || value === "--config" || value === "--source" || value === "--database") {
10
+ if (value === "--env" || value === "--config") {
14
11
  const argument = input[index + 1];
15
12
  if (!argument)
16
13
  throw new Error(`${value} requires a value`);
17
14
  if (value === "--env")
18
15
  environment = argument;
19
- else if (value === "--config")
20
- configPath = argument;
21
- else if (value === "--source")
22
- source = argument;
23
16
  else
24
- database = argument;
17
+ configPath = argument;
25
18
  index += 1;
26
19
  }
27
- else if (value === "--yes") {
28
- yes = true;
29
- }
30
20
  else if (value === "--yolo") {
31
21
  yolo = true;
32
22
  }
@@ -36,5 +26,7 @@ export function parseArguments(args) {
36
26
  }
37
27
  if (command && yolo)
38
28
  throw new Error("--yolo is only available when starting an agent session");
39
- return { command, environment, configPath, source, database, yes, yolo, piArguments };
29
+ if (command === "provision" && (configPath || piArguments.length))
30
+ throw new Error("merchantduo provision supports only --env local or --env warden");
31
+ return { command, environment, configPath, yolo, piArguments };
40
32
  }
@@ -1,3 +1,10 @@
1
1
  import type { PermissionMode } from "#app/permission-mode";
2
+ type ProvisionEnvironment = "local" | "warden";
3
+ export declare function provisioningPrompt(environment: ProvisionEnvironment): string;
2
4
  export declare function launchAgent(piArguments: string[], selectedEnvironment: string, configPath?: string, permissionMode?: PermissionMode): Promise<number>;
5
+ /** Launch a non-persistent provisioning session with exactly the requested environment. */
6
+ export declare function launchProvisioningAgent(environment: ProvisionEnvironment): Promise<number>;
7
+ /** Preserve Pi connection preferences but never load environment-bound MerchantDuo packages. */
8
+ export declare function provisioningSettings(settings: Record<string, unknown>): Record<string, unknown>;
3
9
  export declare function agentEnvironment(base: NodeJS.ProcessEnv, piDirectory: string, selectedEnvironment: string, configPath: string | undefined, version: string, permissionMode?: PermissionMode): NodeJS.ProcessEnv;
10
+ export {};
@@ -1,9 +1,14 @@
1
1
  import { spawn } from "node:child_process";
2
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, symlink, writeFile } from "node:fs/promises";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { merchantHome } from "#config/paths";
6
6
  import { packagePath } from "#shared/package-paths";
7
+ export function provisioningPrompt(environment) {
8
+ return `Start an agentic Magento provisioning investigation for the selected ${environment} environment. You are running plain Pi in the host shell, not inside a MerchantDuo selected-environment shell. Use host tools to inspect Warden/local project evidence and explicitly route any environment-specific command only after identifying its correct runtime. Do not follow a predetermined command sequence. First inspect the project, its documentation and scripts, the selected environment configuration and status, Composer metadata and lock-file platform requirements, actual runtime versions, Magento state, and available database/source evidence. Search for the smallest project-specific path to a runnable store.
9
+
10
+ Before any material mutation, explain the evidence, the precise intended effect, risks, and the smallest proposed action, then wait for the user's confirmation. Do not run Composer, import a database, run Magento setup commands, start or stop services, create a virtual host, or modify dependencies merely because they are customary. Never use composer update to bypass a lock-file or PHP-platform mismatch. If compatibility fails, identify the exact incompatible packages and recommend the required environment or dependency decision instead. When project evidence requires it, inspect and configure Magento deployment configuration including app/etc/env.php, app/etc/config.php, and related files. Treat their values as sensitive: never disclose, echo, log, or copy credentials or tokens into chat, commands, or persisted artifacts.`;
11
+ }
7
12
  export async function launchAgent(piArguments, selectedEnvironment, configPath, permissionMode) {
8
13
  const state = merchantHome();
9
14
  await mkdir(state, { recursive: true, mode: 0o700 });
@@ -25,6 +30,46 @@ export async function launchAgent(piArguments, selectedEnvironment, configPath,
25
30
  child.on("exit", (code) => resolveExit(code ?? 1));
26
31
  });
27
32
  }
33
+ /** Launch a non-persistent provisioning session with exactly the requested environment. */
34
+ export async function launchProvisioningAgent(environment) {
35
+ const state = merchantHome();
36
+ const merchantPi = resolve(state, "pi");
37
+ const provisionPi = resolve(state, "provision", "pi");
38
+ await mkdir(provisionPi, { recursive: true, mode: 0o700 });
39
+ await linkConnectionFile(merchantPi, provisionPi, "auth.json");
40
+ await linkConnectionFile(merchantPi, provisionPi, "models.json");
41
+ await linkConnectionFile(merchantPi, provisionPi, "models-store.json");
42
+ await writeFile(resolve(provisionPi, "settings.json"), `${JSON.stringify(provisioningSettings(JSON.parse(await readFile(resolve(merchantPi, "settings.json"), "utf8"))), null, 2)}\n`, { mode: 0o600 });
43
+ return launchPlainPi(["--no-extensions", provisioningPrompt(environment)], provisionPi);
44
+ }
45
+ /** Preserve Pi connection preferences but never load environment-bound MerchantDuo packages. */
46
+ export function provisioningSettings(settings) {
47
+ const { packages: _packages, ...connectionSettings } = settings;
48
+ return connectionSettings;
49
+ }
50
+ async function linkConnectionFile(sourceDirectory, targetDirectory, name) {
51
+ const source = resolve(sourceDirectory, name);
52
+ const target = resolve(targetDirectory, name);
53
+ try {
54
+ await symlink(source, target);
55
+ }
56
+ catch (error) {
57
+ if (error.code !== "EEXIST")
58
+ throw error;
59
+ }
60
+ }
61
+ async function launchPlainPi(piArguments, piDirectory) {
62
+ const piEntry = fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"));
63
+ const piCli = resolve(dirname(piEntry), "cli.js");
64
+ return new Promise((resolveExit, reject) => {
65
+ const child = spawn(process.execPath, [piCli, ...piArguments], {
66
+ stdio: "inherit",
67
+ env: { ...process.env, PI_CODING_AGENT_DIR: piDirectory },
68
+ });
69
+ child.on("error", reject);
70
+ child.on("exit", (code) => resolveExit(code ?? 1));
71
+ });
72
+ }
28
73
  export function agentEnvironment(base, piDirectory, selectedEnvironment, configPath, version, permissionMode) {
29
74
  return { ...base, PI_CODING_AGENT_DIR: piDirectory, MERCHANTDUO_SELECTED_ENV: selectedEnvironment, MERCHANTDUO_CONFIG_PATH: configPath ?? "", MERCHANTDUO_PACKAGE_VERSION: version, ...(permissionMode ? { MERCHANTDUO_PERMISSION_MODE: permissionMode } : {}) };
30
75
  }
@@ -1,9 +1,22 @@
1
+ import { type OperatorRole } from "#config/index";
2
+ export declare const operatorRoleChoices: ReadonlyArray<{
3
+ value: OperatorRole;
4
+ label: string;
5
+ }>;
1
6
  export interface InitInterview {
2
7
  confirm(question: string): Promise<boolean>;
8
+ choose(question: string, choices: ReadonlyArray<{
9
+ value: string;
10
+ label: string;
11
+ }>): Promise<string>;
3
12
  }
4
13
  export declare class TerminalInitInterview implements InitInterview {
5
14
  confirm(question: string): Promise<boolean>;
15
+ choose(question: string, choices: ReadonlyArray<{
16
+ value: string;
17
+ label: string;
18
+ }>): Promise<string>;
6
19
  }
7
20
  export declare function hasWardenEnvironment(cwd: string): Promise<boolean>;
8
- export declare function initProject(cwd: string, interview?: InitInterview, output?: (message: string) => void): Promise<void>;
21
+ export declare function initProject(cwd: string, interview?: InitInterview, output?: (message: string) => void, env?: NodeJS.ProcessEnv): Promise<void>;
9
22
  export declare const remoteEnvironmentExample = "\nTo add a remote environment later, add this under environments:\n\n stage:\n type: ssh\n host: shop-stage\n root: /var/www/html\n\nSSH sessions start read-only. Use /duo-switch-permissions normal or yolo only for the current session. Configure its user, port, and identity through ~/.ssh/config:\n\n Host shop-stage\n HostName stage.example.com\n User deploy\n Port 22\n IdentityFile ~/.ssh/id_ed25519";
@@ -3,6 +3,13 @@ import { resolve } from "node:path";
3
3
  import { createInterface } from "node:readline/promises";
4
4
  import { stdin, stdout } from "node:process";
5
5
  import { stringify } from "yaml";
6
+ import { operatorRoles, saveOperatorRole } from "#config/index";
7
+ export const operatorRoleChoices = [
8
+ { value: "store-admin", label: "I am STORE ADMIN" },
9
+ { value: "backend-developer", label: "I am BACKEND developer" },
10
+ { value: "frontend-developer", label: "I am FRONTEND developer" },
11
+ { value: "full-stack-developer", label: "I am FULL STACK developer" },
12
+ ];
6
13
  export class TerminalInitInterview {
7
14
  async confirm(question) {
8
15
  const terminal = createInterface({ input: stdin, output: stdout });
@@ -14,6 +21,20 @@ export class TerminalInitInterview {
14
21
  terminal.close();
15
22
  }
16
23
  }
24
+ async choose(question, choices) {
25
+ const terminal = createInterface({ input: stdin, output: stdout });
26
+ try {
27
+ stdout.write(`${question}\n${choices.map((choice, index) => ` ${index + 1}. ${choice.label}`).join("\n")}\n`);
28
+ const answer = (await terminal.question(`Choose [1-${choices.length}] `)).trim();
29
+ const selected = choices[Number(answer) - 1];
30
+ if (!selected)
31
+ throw new Error(`Choose a number from 1 to ${choices.length}`);
32
+ return selected.value;
33
+ }
34
+ finally {
35
+ terminal.close();
36
+ }
37
+ }
17
38
  }
18
39
  export async function hasWardenEnvironment(cwd) {
19
40
  try {
@@ -26,16 +47,23 @@ export async function hasWardenEnvironment(cwd) {
26
47
  throw error;
27
48
  }
28
49
  }
29
- export async function initProject(cwd, interview = new TerminalInitInterview(), output = console.log) {
50
+ export async function initProject(cwd, interview = new TerminalInitInterview(), output = console.log, env = process.env) {
30
51
  const path = resolve(cwd, ".merchantduo.yaml");
52
+ let exists = false;
31
53
  try {
32
54
  await access(path);
33
- throw new Error(`${path} already exists`);
55
+ exists = true;
34
56
  }
35
57
  catch (error) {
36
58
  if (error.code !== "ENOENT")
37
59
  throw error;
38
60
  }
61
+ // Always refresh the caller's optional identity context, even for an existing project.
62
+ await saveOperatorRole(await chooseOperatorRole(interview), env);
63
+ if (exists) {
64
+ output("Updated the current user's operator role");
65
+ return;
66
+ }
39
67
  const environments = {};
40
68
  if (await interview.confirm("Add the local Magento checkout environment?")) {
41
69
  environments.local = { type: "local", root: "." };
@@ -65,4 +93,10 @@ export async function initProject(cwd, interview = new TerminalInitInterview(),
65
93
  output(`Created ${path}`);
66
94
  output(remoteEnvironmentExample);
67
95
  }
96
+ async function chooseOperatorRole(interview) {
97
+ const selected = await interview.choose("Choose what fits you most", operatorRoleChoices);
98
+ if (!operatorRoles.includes(selected))
99
+ throw new Error("Choose a listed operator role");
100
+ return selected;
101
+ }
68
102
  export const remoteEnvironmentExample = `\nTo add a remote environment later, add this under environments:\n\n stage:\n type: ssh\n host: shop-stage\n root: /var/www/html\n\nSSH sessions start read-only. Use /duo-switch-permissions normal or yolo only for the current session. Configure its user, port, and identity through ~/.ssh/config:\n\n Host shop-stage\n HostName stage.example.com\n User deploy\n Port 22\n IdentityFile ~/.ssh/id_ed25519`;
@@ -1,6 +1,11 @@
1
- export declare function provision(cwd: string, options: {
1
+ export type ProvisionEnvironment = "local" | "warden";
2
+ export interface ProvisionEnvironmentInterview {
3
+ choose(): Promise<ProvisionEnvironment>;
4
+ }
5
+ export declare class TerminalProvisionEnvironmentInterview implements ProvisionEnvironmentInterview {
6
+ choose(): Promise<ProvisionEnvironment>;
7
+ }
8
+ /** Starts a Pi investigation session; provisioning actions remain agent- and user-directed. */
9
+ export declare function provision(options: {
2
10
  environment?: string;
3
- source?: string;
4
- database?: string;
5
- yes: boolean;
6
- }): Promise<void>;
11
+ }, interview?: ProvisionEnvironmentInterview, launch?: (environment: ProvisionEnvironment) => Promise<number>): Promise<void>;
@@ -1,35 +1,24 @@
1
- import { EnvironmentBackend } from "#environments/backend";
2
- import { ProvisionAdvisor } from "#provision/advisor";
3
- import { discoverProvision } from "#provision/discovery";
4
- import { TerminalProvisionInterview } from "#provision/interview";
5
- import { createProvisionPlan } from "#provision/planner";
6
- import { BackendProvisionRunner, DefaultProvisionExecutor } from "#provision/runner";
7
- function source(value) { if (!value)
8
- return undefined; if (value === "existing")
9
- return value; return /^https?:\/\//.test(value) ? (value.endsWith(".git") ? `git:${value}` : `https:${value}`) : `archive:${value}`; }
10
- function database(value) { if (!value)
11
- return undefined; if (value === "current")
12
- return value; return /^https?:\/\//.test(value) ? `https:${value}` : `dump:${value}`; }
13
- export async function provision(cwd, options) {
14
- if (options.environment && options.environment !== "local" && options.environment !== "warden")
15
- throw new Error("--env must be local or warden");
16
- const snapshot = await discoverProvision(cwd);
17
- const initial = { environment: options.environment, source: source(options.source), database: database(options.database) };
18
- const interview = new TerminalProvisionInterview();
19
- const input = options.yes ? initial : await interview.resolve(snapshot, initial);
20
- const advised = await new ProvisionAdvisor().advise(snapshot, createProvisionPlan(snapshot, input));
21
- if (advised.diagnostic)
22
- console.error(`merchantduo: ${advised.diagnostic}`);
23
- if (advised.plan.blocked.length)
24
- throw new Error(`Provision requires input:\n- ${advised.plan.blocked.join("\n- ")}`);
25
- if (!options.yes && !(await interview.approve(advised.plan))) {
26
- console.log("Provision cancelled.");
27
- return;
1
+ import { createInterface } from "node:readline/promises";
2
+ import { stdin, stdout } from "node:process";
3
+ import { launchProvisioningAgent } from "#cli/commands/agent";
4
+ export class TerminalProvisionEnvironmentInterview {
5
+ async choose() {
6
+ const terminal = createInterface({ input: stdin, output: stdout });
7
+ try {
8
+ const answer = (await terminal.question("Environment (warden/local): ")).trim();
9
+ if (answer === "warden" || answer === "local")
10
+ return answer;
11
+ throw new Error("Choose environment warden or local");
12
+ }
13
+ finally {
14
+ terminal.close();
15
+ }
28
16
  }
29
- const hostBackend = new EnvironmentBackend({ type: "local", root: "." }, cwd);
30
- const magentoBackend = new EnvironmentBackend(input.environment === "warden" ? { type: "warden", projectRoot: ".", root: "/var/www/html", filesService: "php-fpm", targets: { default: "php-fpm" } } : { type: "local", root: "." }, cwd);
31
- const executor = new DefaultProvisionExecutor(new BackendProvisionRunner(hostBackend), cwd, input.environment, new BackendProvisionRunner(magentoBackend));
32
- for (const action of advised.plan.actions)
33
- await executor.execute(action, input);
34
- console.log("Provision completed.");
17
+ }
18
+ /** Starts a Pi investigation session; provisioning actions remain agent- and user-directed. */
19
+ export async function provision(options, interview = new TerminalProvisionEnvironmentInterview(), launch = launchProvisioningAgent) {
20
+ const environment = options.environment ?? await interview.choose();
21
+ if (environment !== "local" && environment !== "warden")
22
+ throw new Error("--env must be local or warden");
23
+ process.exitCode = await launch(environment);
35
24
  }
package/dist/cli/main.js CHANGED
@@ -12,7 +12,7 @@ async function main() {
12
12
  if (args.command === "init")
13
13
  return initProject(cwd);
14
14
  if (args.command === "provision")
15
- return provision(cwd, args);
15
+ return provision(args);
16
16
  const loaded = await loadConfig(cwd, { config: args.configPath });
17
17
  const selected = selectEnvironment(loaded.config, cwd, args.environment);
18
18
  if (loaded.configPath && !(await isTrusted(loaded.configPath))) {
@@ -2,8 +2,9 @@ import type { Environment } from "#environments/model";
2
2
  import type { MerchantConfig } from "#config/schema";
3
3
  export { loadConfig, interpolate, type LoadedConfig } from "#config/loader";
4
4
  export { merchantHome } from "#config/paths";
5
+ export { loadOperatorRole, saveOperatorRole } from "#config/operator-profile";
5
6
  export { setProjectTheme } from "#config/project-store";
6
- export { ConfigSchema, type MerchantConfig, } from "#config/schema";
7
+ export { ConfigSchema, type MerchantConfig, operatorRoles, type OperatorRole, } from "#config/schema";
7
8
  export { configDigest, isTrusted, trustProject } from "#config/trust-store";
8
9
  export declare function selectEnvironment(config: MerchantConfig, cwd: string, requested?: string, env?: NodeJS.ProcessEnv): {
9
10
  name: string;
@@ -1,7 +1,8 @@
1
1
  export { loadConfig, interpolate } from "#config/loader";
2
2
  export { merchantHome } from "#config/paths";
3
+ export { loadOperatorRole, saveOperatorRole } from "#config/operator-profile";
3
4
  export { setProjectTheme } from "#config/project-store";
4
- export { ConfigSchema, } from "#config/schema";
5
+ export { ConfigSchema, operatorRoles, } from "#config/schema";
5
6
  export { configDigest, isTrusted, trustProject } from "#config/trust-store";
6
7
  export function selectEnvironment(config, cwd, requested, env = process.env) {
7
8
  const name = requested ?? env.MERCHANTDUO_ENV ?? config.defaultEnvironment;
@@ -49,11 +49,7 @@ export async function loadConfig(cwd, options = {}) {
49
49
  },
50
50
  };
51
51
  if (!globalConfig && !projectConfig)
52
- return {
53
- config: ConfigSchema.parse({
54
- environments: { local: { type: "local", root: "." } },
55
- }),
56
- };
52
+ throw new Error("No MerchantDuo configuration found; run merchantduo init first");
57
53
  return {
58
54
  config: ConfigSchema.parse(expand(raw, env)),
59
55
  configPath: projectConfig ? resolve(projectPath) : undefined,
@@ -0,0 +1,5 @@
1
+ import { type OperatorRole } from "#config/schema";
2
+ /** Read optional user-only agent context without involving repository configuration. */
3
+ export declare function loadOperatorRole(env?: NodeJS.ProcessEnv): Promise<OperatorRole | undefined>;
4
+ /** Persist the explicitly chosen role in the current user's MerchantDuo state. */
5
+ export declare function saveOperatorRole(role: OperatorRole, env?: NodeJS.ProcessEnv): Promise<void>;
@@ -0,0 +1,28 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { parse, stringify } from "yaml";
4
+ import { merchantHome } from "#config/paths";
5
+ import { operatorRoles } from "#config/schema";
6
+ const profileFile = "operator-profile.yaml";
7
+ /** Read optional user-only agent context without involving repository configuration. */
8
+ export async function loadOperatorRole(env = process.env) {
9
+ const path = resolve(merchantHome(env), profileFile);
10
+ try {
11
+ const profile = parse(await readFile(path, "utf8"));
12
+ const role = profile?.operatorRole;
13
+ if (!operatorRoles.includes(role))
14
+ throw new Error(`Invalid operator role in ${path}`);
15
+ return role;
16
+ }
17
+ catch (error) {
18
+ if (error.code === "ENOENT")
19
+ return undefined;
20
+ throw error;
21
+ }
22
+ }
23
+ /** Persist the explicitly chosen role in the current user's MerchantDuo state. */
24
+ export async function saveOperatorRole(role, env = process.env) {
25
+ const directory = merchantHome(env);
26
+ await mkdir(directory, { recursive: true, mode: 0o700 });
27
+ await writeFile(resolve(directory, profileFile), stringify({ operatorRole: role }), { mode: 0o600 });
28
+ }
@@ -1,5 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import type { Environment } from "#environments/model";
3
+ export declare const operatorRoles: readonly ["store-admin", "backend-developer", "frontend-developer", "full-stack-developer"];
4
+ export type OperatorRole = (typeof operatorRoles)[number];
3
5
  export declare const ConfigSchema: z.ZodObject<{
4
6
  defaultEnvironment: z.ZodOptional<z.ZodString>;
5
7
  activeTheme: z.ZodDefault<z.ZodString>;
@@ -1,4 +1,10 @@
1
1
  import { z } from "zod";
2
+ export const operatorRoles = [
3
+ "store-admin",
4
+ "backend-developer",
5
+ "frontend-developer",
6
+ "full-stack-developer",
7
+ ];
2
8
  const testing = z
3
9
  .object({
4
10
  frontendUrl: z.string().url().optional(),
@@ -1,10 +1,16 @@
1
1
  import type { EnvironmentAdapter } from "#environments/adapter";
2
2
  import type { WardenEnvironment } from "#environments/model";
3
3
  export declare class WardenAdapter implements EnvironmentAdapter {
4
+ #private;
4
5
  readonly environment: WardenEnvironment;
5
6
  readonly cwd: string;
6
7
  constructor(environment: WardenEnvironment, cwd: string);
7
- path(path: string): string;
8
+ /**
9
+ * Resolve a path into the Warden container before a routed command runs.
10
+ * Relative paths are container-relative; absolute paths may be either
11
+ * canonical container paths or host paths below the Warden project root.
12
+ */
13
+ path(candidate: string): string;
8
14
  command(argv: string[], target?: string): {
9
15
  file: string;
10
16
  args: string[];
@@ -1,13 +1,30 @@
1
- import { resolve } from "node:path";
1
+ import { isAbsolute, relative, resolve, sep } from "node:path";
2
+ import { contain } from "#environments/path-policy";
2
3
  export class WardenAdapter {
3
4
  environment;
4
5
  cwd;
6
+ #hostRoot;
7
+ #containerRoot;
5
8
  constructor(environment, cwd) {
6
9
  this.environment = environment;
7
10
  this.cwd = cwd;
11
+ this.#hostRoot = resolve(cwd, environment.projectRoot);
12
+ this.#containerRoot = resolve(environment.root);
8
13
  }
9
- path(path) {
10
- return path.startsWith("/") ? path : `${this.environment.root}/${path}`;
14
+ /**
15
+ * Resolve a path into the Warden container before a routed command runs.
16
+ * Relative paths are container-relative; absolute paths may be either
17
+ * canonical container paths or host paths below the Warden project root.
18
+ */
19
+ path(candidate) {
20
+ if (!isAbsolute(candidate))
21
+ return contain(this.#containerRoot, candidate);
22
+ const absolute = resolve(candidate);
23
+ if (within(this.#containerRoot, absolute))
24
+ return absolute;
25
+ if (within(this.#hostRoot, absolute))
26
+ return contain(this.#containerRoot, relative(this.#hostRoot, absolute));
27
+ throw new Error(`Path is outside the Warden project and container roots: ${candidate}`);
11
28
  }
12
29
  command(argv, target) {
13
30
  const service = target
@@ -29,3 +46,7 @@ export class WardenAdapter {
29
46
  return { file: "warden", args: actionArgs, cwd: resolve(this.cwd, this.environment.projectRoot) };
30
47
  }
31
48
  }
49
+ function within(root, candidate) {
50
+ const value = relative(root, candidate);
51
+ return value === "" || (value !== ".." && !value.startsWith(`..${sep}`));
52
+ }
@@ -1,5 +1,6 @@
1
1
  import { refreshStatus, setInitializingStatus } from "#app/status";
2
2
  import { merchantDuoSystemPrompt } from "#app/system-prompt";
3
+ import { loadOperatorRole } from "#config/index";
3
4
  import { discoverTesting } from "#testing/index";
4
5
  import { discoverMagerun2 } from "#features/magerun2/index";
5
6
  import { discoverHostExecutable, NodeHostProcessRunner } from "#testing/host";
@@ -58,8 +59,9 @@ export default function context(pi) {
58
59
  });
59
60
  pi.on("before_agent_start", async (event, ctx) => {
60
61
  const state = await runtime.boot(ctx.cwd);
62
+ const operatorRole = await loadOperatorRole();
61
63
  const testing = state.testing;
62
- return { systemPrompt: `${event.systemPrompt}\n${merchantDuoSystemPrompt()}\nEnvironment: ${state.selected.name} is ${state.environmentStatus}; permission mode=${state.permissionMode}. ${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."}` };
64
+ return { systemPrompt: `${event.systemPrompt}\n${merchantDuoSystemPrompt(operatorRole)}\nEnvironment: ${state.selected.name} is ${state.environmentStatus}; permission mode=${state.permissionMode}. ${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."}` };
63
65
  });
64
66
  }
65
67
  export function statusMessage(state) {
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { type PermissionMode } from "#app/permission-mode";
3
3
  import type { Environment } from "#environments/model";
4
+ export declare function permissionChoices(): PermissionMode[];
4
5
  export declare function requiresExecuteConfirmation(toolName: string, input: Record<string, unknown>): boolean;
5
6
  /** Return block/confirmation behavior; callers retain all hard capability constraints. */
6
7
  export declare function permissionDecision(mode: PermissionMode, environment: Environment, toolName: string, input: Record<string, unknown>): "allow" | "block" | "confirm";
@@ -3,6 +3,9 @@ import { refreshStatus } from "#app/status";
3
3
  import { runtime } from "#integrations/pi/session";
4
4
  const workspaceMutations = new Set(["write", "edit"]);
5
5
  const stoppedAllowed = new Set(["environment_status", "environment_start", "environment_stop", "environment_set_status"]);
6
+ export function permissionChoices() {
7
+ return [...permissionModes];
8
+ }
6
9
  export function requiresExecuteConfirmation(toolName, input) {
7
10
  return ["environment_start", "environment_stop"].includes(toolName) || ((toolName === "mage2gen_generate_module" || toolName === "magento_workflow" || toolName === "magerun2") && input.execute === true);
8
11
  }
@@ -26,9 +29,13 @@ async function confirm(ctx, title, message) {
26
29
  /** The sole MerchantDuo confirmation gate. YOLO removes dialogs, never hard constraints. */
27
30
  export default function permissions(pi) {
28
31
  pi.registerCommand("duo-switch-permissions", { description: "Set this session's permission mode: read-only, normal, or yolo.", handler: async (args, ctx) => {
29
- const mode = args.trim();
30
- if (!permissionModes.includes(mode)) {
31
- ctx.ui.notify("Choose one of: read-only, normal, yolo", "error");
32
+ const choices = permissionChoices();
33
+ const typed = args.trim();
34
+ const mode = typed || (ctx.hasUI ? await ctx.ui.select("Select session permission mode", choices) : undefined);
35
+ if (!mode)
36
+ return;
37
+ if (!choices.includes(mode)) {
38
+ ctx.ui.notify(`Choose one of: ${choices.join(", ")}`, "error");
32
39
  return;
33
40
  }
34
41
  await runtime.boot(ctx.cwd);