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

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.
package/README.md CHANGED
@@ -10,13 +10,13 @@ MerchantDuo is a Pi coding agent for Magento 2.4 teams. It starts from the store
10
10
  - Provides guided local/Warden store provisioning.
11
11
  - Keeps remote writes and Magento operations behind explicit execution boundaries.
12
12
 
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.
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, session permission mode, an explicit theme scope when one is selected, and the frontend URL when available.
14
14
 
15
15
  ## Commands and tools
16
16
 
17
17
  Extension slash commands are namespaced with `duo-` to avoid collisions with other Pi extensions.
18
18
 
19
- - [Command reference](docs/commands.md) covers `/duo-switch-theme`, lifecycle, and navigation commands.
19
+ - [Command reference](docs/commands.md) covers session theme and permission switching, lifecycle, and navigation commands.
20
20
  - [Tool reference](docs/tools.md) covers workspace, environment, Magento workflow, magerun2, testing, PHP-console, and optional knowledge tools, including parameters and confirmation boundaries.
21
21
 
22
22
  ## Environments
@@ -46,10 +46,9 @@ environments:
46
46
  type: ssh
47
47
  host: shop-stage
48
48
  root: /var/www/html
49
- writable: false
50
49
  ```
51
50
 
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:
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:
53
52
 
54
53
  ```sshconfig
55
54
  Host shop-stage
@@ -0,0 +1,5 @@
1
+ import type { Environment } from "#environments/model";
2
+ export declare const permissionModes: readonly ["read-only", "normal", "yolo"];
3
+ export type PermissionMode = (typeof permissionModes)[number];
4
+ /** Resolve one non-persistent session policy. Invalid launch input is rejected, never relaxed. */
5
+ export declare function resolvePermissionMode(environment: Environment, override?: string): PermissionMode;
@@ -0,0 +1,10 @@
1
+ export const permissionModes = ["read-only", "normal", "yolo"];
2
+ /** Resolve one non-persistent session policy. Invalid launch input is rejected, never relaxed. */
3
+ export function resolvePermissionMode(environment, override) {
4
+ if (override !== undefined) {
5
+ if (!permissionModes.includes(override))
6
+ throw new Error(`Invalid MERCHANTDUO_PERMISSION_MODE: ${override}. Expected read-only, normal, or yolo.`);
7
+ return override;
8
+ }
9
+ return environment.type === "ssh" ? "read-only" : "normal";
10
+ }
@@ -1,6 +1,7 @@
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 PermissionMode } from "#app/permission-mode";
4
5
  import type { EnvironmentStatus } from "#environments/model";
5
6
  import { type MagentoInspector, type MagentoSnapshot } from "#magento/index";
6
7
  import { ChangeTracker } from "#workflows/change-tracker";
@@ -15,6 +16,7 @@ export type SessionState = {
15
16
  };
16
17
  backend: EnvironmentBackend;
17
18
  environmentStatus: EnvironmentStatus;
19
+ permissionMode: PermissionMode;
18
20
  magento: MagentoSnapshot;
19
21
  testing?: TestingSnapshot;
20
22
  magerun2?: Magerun2Capability;
@@ -29,7 +31,7 @@ export declare class MerchantDuoRuntime {
29
31
  resetInjection(): void;
30
32
  injectionKey(skillId: string, theme: string): string;
31
33
  changeTheme(cwd: string, activeTheme: string): Promise<SessionState>;
32
- writable(state: SessionState): boolean;
34
+ setPermissionMode(mode: PermissionMode): SessionState;
33
35
  setTesting(snapshot: TestingSnapshot): void;
34
36
  setMagerun2(capability: Magerun2Capability): void;
35
37
  setEnvironmentStatus(status: EnvironmentStatus): SessionState;
@@ -1,6 +1,6 @@
1
1
  import { loadConfig, selectEnvironment, setProjectTheme, } from "#config/index";
2
2
  import { EnvironmentBackend } from "#environments/backend";
3
- import { environmentWritable } from "#environments/model";
3
+ import { resolvePermissionMode } from "#app/permission-mode";
4
4
  import { DefaultMagentoInspector, } from "#magento/index";
5
5
  import { ChangeTracker } from "#workflows/change-tracker";
6
6
  export class MerchantDuoRuntime {
@@ -26,6 +26,7 @@ export class MerchantDuoRuntime {
26
26
  selected,
27
27
  backend,
28
28
  environmentStatus,
29
+ permissionMode: resolvePermissionMode(selected.environment, process.env.MERCHANTDUO_PERMISSION_MODE),
29
30
  magento: environmentStatus === "running" ? await this.inspector.inspect(backend) : stoppedMagentoSnapshot(),
30
31
  };
31
32
  return this.#state;
@@ -47,8 +48,11 @@ export class MerchantDuoRuntime {
47
48
  this.resetInjection();
48
49
  return state;
49
50
  }
50
- writable(state) {
51
- return environmentWritable(state.selected.environment);
51
+ setPermissionMode(mode) {
52
+ if (!this.#state)
53
+ throw new Error("Session has not started");
54
+ this.#state.permissionMode = mode;
55
+ return this.#state;
52
56
  }
53
57
  setTesting(snapshot) {
54
58
  if (!this.#state)
@@ -1,15 +1,9 @@
1
1
  export function refreshStatus(ctx, state) {
2
- const environment = state.selected.environment;
3
- const writePolicy = environment.type === "ssh"
4
- ? environment.writable
5
- ? "✎+?"
6
- : "RO"
7
- : "✎";
8
2
  const parts = [
9
3
  `MerchantDuo ${process.env.MERCHANTDUO_PACKAGE_VERSION ?? "?"}`,
10
4
  `M${state.magento.version ?? "?"}`,
11
5
  `${state.selected.name} (${state.environmentStatus})`,
12
- writePolicy,
6
+ state.permissionMode === "read-only" ? "r-o" : state.permissionMode,
13
7
  ...(state.config.activeTheme === "all" ? [] : [state.config.activeTheme]),
14
8
  ...(state.testing?.frontendUrl ? [state.testing.frontendUrl] : []),
15
9
  ];
@@ -13,6 +13,8 @@ After every change, inspect the changed module and files and choose the smallest
13
13
 
14
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
15
 
16
+ Session permission mode is shown in the startup context and can be changed only with \`/duo-switch-permissions read-only|normal|yolo\`. Read-only blocks direct write/edit but an approved shell or explicit operation may still mutate. Normal retains ordinary local/Warden direct workspace behavior and SSH confirmations. Yolo removes MerchantDuo dialogs only; it never removes the explicit \`execute: true\` boundary, stopped-environment block, SSH lifecycle restriction, Mage2Gen SSH restriction, remote workflow restriction, PHP-console boundary, or credential and URL protections.
17
+
16
18
  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
19
 
18
20
  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.`;
@@ -5,6 +5,7 @@ export type CliArguments = {
5
5
  source?: string;
6
6
  database?: string;
7
7
  yes: boolean;
8
+ yolo: boolean;
8
9
  piArguments: string[];
9
10
  };
10
11
  export declare function parseArguments(args: string[]): CliArguments;
@@ -7,6 +7,7 @@ export function parseArguments(args) {
7
7
  let source;
8
8
  let database;
9
9
  let yes = false;
10
+ let yolo = false;
10
11
  for (let index = 0; index < input.length; index += 1) {
11
12
  const value = input[index];
12
13
  if (value === "--env" || value === "--config" || value === "--source" || value === "--database") {
@@ -26,9 +27,14 @@ export function parseArguments(args) {
26
27
  else if (value === "--yes") {
27
28
  yes = true;
28
29
  }
30
+ else if (value === "--yolo") {
31
+ yolo = true;
32
+ }
29
33
  else {
30
34
  piArguments.push(value);
31
35
  }
32
36
  }
33
- return { command, environment, configPath, source, database, yes, piArguments };
37
+ if (command && yolo)
38
+ throw new Error("--yolo is only available when starting an agent session");
39
+ return { command, environment, configPath, source, database, yes, yolo, piArguments };
34
40
  }
@@ -1 +1,3 @@
1
- export declare function launchAgent(piArguments: string[], selectedEnvironment: string, configPath?: string): Promise<number>;
1
+ import type { PermissionMode } from "#app/permission-mode";
2
+ export declare function launchAgent(piArguments: string[], selectedEnvironment: string, configPath?: string, permissionMode?: PermissionMode): Promise<number>;
3
+ export declare function agentEnvironment(base: NodeJS.ProcessEnv, piDirectory: string, selectedEnvironment: string, configPath: string | undefined, version: string, permissionMode?: PermissionMode): NodeJS.ProcessEnv;
@@ -4,7 +4,7 @@ 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 async function launchAgent(piArguments, selectedEnvironment, configPath) {
7
+ export async function launchAgent(piArguments, selectedEnvironment, configPath, permissionMode) {
8
8
  const state = merchantHome();
9
9
  await mkdir(state, { recursive: true, mode: 0o700 });
10
10
  await mkdir(resolve(state, "pi"), { recursive: true, mode: 0o700 });
@@ -19,15 +19,12 @@ export async function launchAgent(piArguments, selectedEnvironment, configPath)
19
19
  ...piArguments,
20
20
  ], {
21
21
  stdio: "inherit",
22
- env: {
23
- ...process.env,
24
- PI_CODING_AGENT_DIR: resolve(state, "pi"),
25
- MERCHANTDUO_SELECTED_ENV: selectedEnvironment,
26
- MERCHANTDUO_CONFIG_PATH: configPath ?? "",
27
- MERCHANTDUO_PACKAGE_VERSION: packageJson.version,
28
- },
22
+ env: agentEnvironment(process.env, resolve(state, "pi"), selectedEnvironment, configPath, packageJson.version, permissionMode),
29
23
  });
30
24
  child.on("error", reject);
31
25
  child.on("exit", (code) => resolveExit(code ?? 1));
32
26
  });
33
27
  }
28
+ export function agentEnvironment(base, piDirectory, selectedEnvironment, configPath, version, permissionMode) {
29
+ 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
+ }
@@ -1,12 +1,12 @@
1
1
  import { EnvironmentBackend } from "#environments/backend";
2
- import { environmentWritable } from "#environments/model";
2
+ import { resolvePermissionMode } from "#app/permission-mode";
3
3
  import { DefaultMagentoInspector } from "#magento/inspector";
4
4
  export async function doctor(cwd, name, environment, config) {
5
5
  const magento = await new DefaultMagentoInspector().inspect(new EnvironmentBackend(environment, cwd));
6
6
  console.log(JSON.stringify({
7
7
  environment: name,
8
8
  type: environment.type,
9
- writable: environmentWritable(environment),
9
+ defaultPermissionMode: resolvePermissionMode(environment),
10
10
  magento,
11
11
  activeTheme: config.activeTheme,
12
12
  knowledge: { connected: false },
@@ -6,4 +6,4 @@ export declare class TerminalInitInterview implements InitInterview {
6
6
  }
7
7
  export declare function hasWardenEnvironment(cwd: string): Promise<boolean>;
8
8
  export declare function initProject(cwd: string, interview?: InitInterview, output?: (message: string) => void): Promise<void>;
9
- 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 writable: false\n\nConfigure 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";
9
+ 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";
@@ -65,4 +65,4 @@ export async function initProject(cwd, interview = new TerminalInitInterview(),
65
65
  output(`Created ${path}`);
66
66
  output(remoteEnvironmentExample);
67
67
  }
68
- 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 writable: false\n\nConfigure 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`;
68
+ 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`;
package/dist/cli/main.js CHANGED
@@ -23,7 +23,7 @@ async function main() {
23
23
  }
24
24
  if (args.command === "doctor")
25
25
  return doctor(cwd, selected.name, selected.environment, loaded.config);
26
- process.exitCode = await launchAgent(args.piArguments, selected.name, loaded.configPath);
26
+ process.exitCode = await launchAgent(args.piArguments, selected.name, loaded.configPath, args.yolo ? "yolo" : undefined);
27
27
  }
28
28
  main().catch((error) => {
29
29
  console.error(`merchantduo: ${error.message}`);
@@ -93,7 +93,6 @@ export declare const ConfigSchema: z.ZodObject<{
93
93
  type: z.ZodLiteral<"ssh">;
94
94
  host: z.ZodString;
95
95
  root: z.ZodString;
96
- writable: z.ZodDefault<z.ZodBoolean>;
97
96
  testing: z.ZodOptional<z.ZodObject<{
98
97
  frontendUrl: z.ZodOptional<z.ZodString>;
99
98
  adminUrl: z.ZodOptional<z.ZodString>;
@@ -111,7 +110,6 @@ export declare const ConfigSchema: z.ZodObject<{
111
110
  type: "ssh";
112
111
  root: string;
113
112
  host: string;
114
- writable: boolean;
115
113
  testing?: {
116
114
  allowInsecureTls: boolean;
117
115
  frontendUrl?: string | undefined;
@@ -126,7 +124,6 @@ export declare const ConfigSchema: z.ZodObject<{
126
124
  adminUrl?: string | undefined;
127
125
  allowInsecureTls?: boolean | undefined;
128
126
  } | undefined;
129
- writable?: boolean | undefined;
130
127
  }>]>>>;
131
128
  }, "strict", z.ZodTypeAny, {
132
129
  testing: {
@@ -158,7 +155,6 @@ export declare const ConfigSchema: z.ZodObject<{
158
155
  type: "ssh";
159
156
  root: string;
160
157
  host: string;
161
- writable: boolean;
162
158
  testing?: {
163
159
  allowInsecureTls: boolean;
164
160
  frontendUrl?: string | undefined;
@@ -202,7 +198,6 @@ export declare const ConfigSchema: z.ZodObject<{
202
198
  adminUrl?: string | undefined;
203
199
  allowInsecureTls?: boolean | undefined;
204
200
  } | undefined;
205
- writable?: boolean | undefined;
206
201
  }> | undefined;
207
202
  }>;
208
203
  export type MerchantConfig = Omit<z.infer<typeof ConfigSchema>, "environments"> & {
@@ -22,7 +22,6 @@ const ssh = z
22
22
  type: z.literal("ssh"),
23
23
  host: z.string().min(1),
24
24
  root: z.string(),
25
- writable: z.boolean().default(false),
26
25
  testing: testing.optional(),
27
26
  })
28
27
  .strict();
@@ -22,9 +22,7 @@ export type SshEnvironment = {
22
22
  type: "ssh";
23
23
  host: string;
24
24
  root: string;
25
- writable: boolean;
26
25
  testing?: EnvironmentTesting;
27
26
  };
28
27
  export type Environment = LocalEnvironment | WardenEnvironment | SshEnvironment;
29
- export declare function environmentWritable(environment: Environment): boolean;
30
28
  export {};
@@ -1,3 +1 @@
1
- export function environmentWritable(environment) {
2
- return environment.type !== "ssh" || environment.writable;
3
- }
1
+ export {};
@@ -19,7 +19,7 @@ export async function discoverMagerun2(environment, backend, cwd) {
19
19
  return { available: true, executable };
20
20
  }
21
21
  if (environment.type === "ssh")
22
- return { available: false, diagnostic: environment.writable ? "Install n98-magerun2 on the remote host." : "Magerun2 is disabled for read-only SSH." };
22
+ return { available: false, diagnostic: "Install n98-magerun2 on the remote host." };
23
23
  try {
24
24
  return { available: true, executable: await installMagerun2(merchantHome(), cwd) };
25
25
  }
@@ -11,7 +11,7 @@ export default function context(pi) {
11
11
  if (state.environmentStatus === "stopped") {
12
12
  refreshStatus(ctx, state);
13
13
  sessionEvents.emit("merchantduo.context", state);
14
- pi.appendEntry("merchantduo.context", { environment: state.selected.name, environmentStatus: state.environmentStatus });
14
+ pi.appendEntry("merchantduo.context", { environment: state.selected.name, environmentStatus: state.environmentStatus, permissionMode: state.permissionMode });
15
15
  return;
16
16
  }
17
17
  const magerun2 = await discoverMagerun2(state.selected.environment, state.backend, ctx.cwd);
@@ -27,7 +27,7 @@ export default function context(pi) {
27
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 }));
28
28
  refreshStatus(ctx, state);
29
29
  sessionEvents.emit("merchantduo.context", state);
30
- pi.appendEntry("merchantduo.context", { environment: state.selected.name, environmentStatus: state.environmentStatus, testing: state.testing, magerun2: state.magerun2 });
30
+ pi.appendEntry("merchantduo.context", { environment: state.selected.name, environmentStatus: state.environmentStatus, permissionMode: state.permissionMode, testing: state.testing, magerun2: state.magerun2 });
31
31
  });
32
32
  pi.on("session_compact", () => runtime.resetInjection());
33
33
  pi.on("session_shutdown", () => sessionEvents.clear());
@@ -47,6 +47,6 @@ export default function context(pi) {
47
47
  pi.on("before_agent_start", async (event, ctx) => {
48
48
  const state = await runtime.boot(ctx.cwd);
49
49
  const testing = state.testing;
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."}` };
50
+ 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."}` };
51
51
  });
52
52
  }
@@ -1,4 +1,4 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  /** Generic magerun2 is intentionally separate from the typed PHP console. */
3
3
  export default function magerun2(pi: ExtensionAPI): void;
4
- export declare function magerun2Refusal(args: string[], readOnlySsh: boolean): string | undefined;
4
+ export declare function magerun2Refusal(args: string[]): string | undefined;
@@ -14,7 +14,7 @@ export default function magerun2(pi) {
14
14
  }),
15
15
  async execute(_id, params, signal, _update, ctx) {
16
16
  const state = await runtime.boot(ctx.cwd);
17
- const refusal = magerun2Refusal(params.args, state.selected.environment.type === "ssh" && !state.selected.environment.writable);
17
+ const refusal = magerun2Refusal(params.args);
18
18
  if (refusal)
19
19
  return text(refusal);
20
20
  const capability = state.magerun2;
@@ -28,10 +28,8 @@ export default function magerun2(pi) {
28
28
  },
29
29
  });
30
30
  }
31
- export function magerun2Refusal(args, readOnlySsh) {
31
+ export function magerun2Refusal(args) {
32
32
  if (args.includes("dev:console"))
33
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
34
  return undefined;
37
35
  }
@@ -1,6 +1,8 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { type PermissionMode } from "#app/permission-mode";
2
3
  import type { Environment } from "#environments/model";
3
4
  export declare function requiresExecuteConfirmation(toolName: string, input: Record<string, unknown>): boolean;
4
- export declare function requiresSshConfirmation(environment: Environment, toolName: string): boolean;
5
- /** The sole MerchantDuo confirmation gate. Explicit test/navigation tools are intentionally excluded. */
5
+ /** Return block/confirmation behavior; callers retain all hard capability constraints. */
6
+ export declare function permissionDecision(mode: PermissionMode, environment: Environment, toolName: string, input: Record<string, unknown>): "allow" | "block" | "confirm";
7
+ /** The sole MerchantDuo confirmation gate. YOLO removes dialogs, never hard constraints. */
6
8
  export default function permissions(pi: ExtensionAPI): void;
@@ -1,37 +1,62 @@
1
+ import { permissionModes } from "#app/permission-mode";
2
+ import { refreshStatus } from "#app/status";
1
3
  import { runtime } from "#integrations/pi/session";
4
+ const workspaceMutations = new Set(["write", "edit"]);
5
+ const stoppedAllowed = new Set(["environment_status", "environment_start", "environment_stop", "environment_set_status"]);
2
6
  export function requiresExecuteConfirmation(toolName, input) {
3
7
  return ["environment_start", "environment_stop"].includes(toolName) || ((toolName === "mage2gen_generate_module" || toolName === "magento_workflow" || toolName === "magerun2") && input.execute === true);
4
8
  }
5
- export function requiresSshConfirmation(environment, toolName) {
6
- return environment.type === "ssh" && environment.writable && ["write", "edit", "bash"].includes(toolName);
9
+ /** Return block/confirmation behavior; callers retain all hard capability constraints. */
10
+ export function permissionDecision(mode, environment, toolName, input) {
11
+ if (mode === "yolo")
12
+ return "allow";
13
+ if (mode === "read-only" && workspaceMutations.has(toolName))
14
+ return "block";
15
+ if (mode === "read-only" && (toolName === "bash" || toolName === "magento_php_repl" || requiresExecuteConfirmation(toolName, input)))
16
+ return "confirm";
17
+ if (requiresExecuteConfirmation(toolName, input))
18
+ return "confirm";
19
+ if (mode === "normal" && environment.type === "ssh" && (workspaceMutations.has(toolName) || toolName === "bash"))
20
+ return "confirm";
21
+ return "allow";
7
22
  }
8
- /** The sole MerchantDuo confirmation gate. Explicit test/navigation tools are intentionally excluded. */
23
+ async function confirm(ctx, title, message) {
24
+ return ctx.hasUI && await ctx.ui.confirm(title, message);
25
+ }
26
+ /** The sole MerchantDuo confirmation gate. YOLO removes dialogs, never hard constraints. */
9
27
  export default function permissions(pi) {
28
+ 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
+ return;
33
+ }
34
+ await runtime.boot(ctx.cwd);
35
+ const state = runtime.setPermissionMode(mode);
36
+ refreshStatus(ctx, state);
37
+ ctx.ui.notify(`Session permission mode set to ${mode}. It will reset when this session ends.`, "info");
38
+ } });
10
39
  pi.on("tool_call", async (event, ctx) => {
11
40
  const state = await runtime.boot(ctx.cwd);
12
- const input = event.input;
13
- if (state.environmentStatus === "stopped" && !["environment_status", "environment_start", "environment_stop", "environment_set_status"].includes(event.toolName))
41
+ if (state.environmentStatus === "stopped" && !stoppedAllowed.has(event.toolName))
14
42
  return { block: true, reason: "MerchantDuo: selected environment is stopped. Start it first." };
15
- if (requiresExecuteConfirmation(event.toolName, input)) {
16
- const ok = ctx.hasUI && await ctx.ui.confirm("Confirm Magento action", `Execute ${event.toolName}?`);
17
- return ok ? undefined : { block: true, reason: "Cancelled: confirmation required." };
18
- }
19
- if (state.selected.environment.type === "ssh" && ["write", "edit", "bash"].includes(event.toolName)) {
20
- if (!state.selected.environment.writable)
21
- return { block: true, reason: "MerchantDuo: this SSH environment is read-only." };
22
- const ok = !requiresSshConfirmation(state.selected.environment, event.toolName) || (ctx.hasUI && await ctx.ui.confirm("SSH workspace action", `Run ${event.toolName} on ${state.selected.environment.host}?`));
23
- return ok ? undefined : { block: true, reason: "Cancelled: SSH confirmation required." };
24
- }
43
+ const decision = permissionDecision(state.permissionMode, state.selected.environment, event.toolName, event.input);
44
+ if (decision === "allow")
45
+ return;
46
+ if (decision === "block")
47
+ return { block: true, reason: "MerchantDuo: read-only mode blocks direct workspace writes and edits." };
48
+ const ok = await confirm(ctx, "Confirm Magento action", `Run ${event.toolName} in ${state.permissionMode} mode?`);
49
+ return ok ? undefined : { block: true, reason: "Cancelled: confirmation required." };
25
50
  });
26
51
  pi.on("user_bash", async (event, ctx) => {
27
52
  const state = await runtime.boot(ctx.cwd);
28
- if (state.selected.environment.type !== "ssh")
29
- return;
30
- if (!state.selected.environment.writable)
31
- return { result: { output: "MerchantDuo: this SSH environment is read-only.", exitCode: 1, cancelled: true, truncated: false } };
32
- const ok = ctx.hasUI && await ctx.ui.confirm("SSH command", `Run command on ${state.selected.environment.host}?`);
33
- if (!ok)
53
+ if (state.environmentStatus === "stopped")
54
+ return { result: { output: "MerchantDuo: selected environment is stopped. Start it first.", exitCode: 1, cancelled: true, truncated: false } };
55
+ const decision = state.permissionMode === "yolo" ? "allow" : state.permissionMode === "read-only" || state.selected.environment.type === "ssh" ? "confirm" : "allow";
56
+ if (decision === "confirm" && !(await confirm(ctx, "Confirm shell command", `Run command in ${state.permissionMode} mode?`)))
34
57
  return { result: { output: "Cancelled: confirmation required.", exitCode: 1, cancelled: true, truncated: false } };
58
+ if (decision === "allow" && state.selected.environment.type !== "ssh" && state.permissionMode !== "read-only")
59
+ return;
35
60
  const result = await state.backend.run(["sh", "-lc", event.command]);
36
61
  return { result: { output: `${result.stdout}${result.stderr}`, exitCode: result.exitCode, cancelled: false, truncated: result.truncated } };
37
62
  });
@@ -56,8 +56,6 @@ export default function testing(pi) {
56
56
  } });
57
57
  pi.registerTool({ name: "magento_php_repl", label: "Magento PHP console", description: "Explicit one-shot magerun2 PHP snippet execution.", parameters: Type.Object({ snippet: Type.String({ minLength: 1 }), area: Type.Optional(Type.Union([Type.Literal("adminhtml"), Type.Literal("frontend"), Type.Literal("crontab"), Type.Literal("webapi_rest"), Type.Literal("graphql")])) }), async execute(_id, params, signal, _update, ctx) {
58
58
  const state = await runtime.boot(ctx.cwd);
59
- if (state.selected.environment.type === "ssh" && !state.selected.environment.writable)
60
- return text("PHP REPL is unavailable for read-only SSH.");
61
59
  const executable = state.magerun2?.executable;
62
60
  if (!executable)
63
61
  return text(state.magerun2?.diagnostic ?? "PHP REPL unavailable.");
@@ -1,2 +1,4 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { SessionState } from "#app/runtime";
3
+ export declare function themeChoices(state: SessionState): string[];
2
4
  export default function theme(pi: ExtensionAPI): void;
@@ -1,6 +1,25 @@
1
1
  import { refreshStatus } from "#app/status";
2
2
  import { runtime } from "#integrations/pi/session";
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
- ctx.ui.notify(`Choose one of: ${choices.join(", ")}`, "error");
5
- return;
6
- } const changed = await runtime.changeTheme(ctx.cwd, value); refreshStatus(ctx, changed); ctx.ui.notify(`Theme scope set to ${value}.`, "info"); } }); }
3
+ export function themeChoices(state) {
4
+ return ["all", "any", ...state.magento.themes.map((theme) => theme.code)];
5
+ }
6
+ export default function theme(pi) {
7
+ pi.registerCommand("duo-switch-theme", {
8
+ description: "Choose a detected Magento theme, all, or any.",
9
+ handler: async (args, ctx) => {
10
+ const state = await runtime.boot(ctx.cwd);
11
+ const choices = themeChoices(state);
12
+ const typed = args.trim();
13
+ const value = typed || (ctx.hasUI ? await ctx.ui.select("Select Magento theme scope", choices) : undefined);
14
+ if (!value)
15
+ return;
16
+ if (!choices.includes(value)) {
17
+ ctx.ui.notify(`Choose one of: ${choices.join(", ")}`, "error");
18
+ return;
19
+ }
20
+ const changed = await runtime.changeTheme(ctx.cwd, value);
21
+ refreshStatus(ctx, changed);
22
+ ctx.ui.notify(`Theme scope set to ${value}.`, "info");
23
+ },
24
+ });
25
+ }
@@ -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, magerun2: state.magerun2, pendingFiles: runtime.changes.files(), workflows: builtInWorkflows.map(({ id, description }) => ({ id, description })) }, null, 2));
9
+ return text(JSON.stringify({ snapshot: state.magento, permissionMode: state.permissionMode, 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);
@@ -1,2 +1,3 @@
1
1
  import type { MagentoSnapshot } from "#magento/model";
2
- export declare function snapshotPrompt(envName: string, writable: boolean, status: string, snapshot: MagentoSnapshot): string;
2
+ import type { PermissionMode } from "#app/permission-mode";
3
+ export declare function snapshotPrompt(envName: string, permissionMode: PermissionMode, status: string, snapshot: MagentoSnapshot): string;
@@ -1,3 +1,3 @@
1
- export function snapshotPrompt(envName, writable, status, snapshot) {
2
- return `[MerchantDuo: environment=${envName}; write policy=${writable ? "writable" : "read-only"}; Magento=${snapshot.edition ?? "unknown"} ${snapshot.version ?? "unknown"}; PHP=${snapshot.phpVersion ?? "unknown"}; mode=${snapshot.mode ?? "unknown"}; knowledge=${status}; pending actions are controlled by magento_workflow. Do not inspect app/etc/env.php or credentials.${snapshot.warnings.length ? ` Warnings: ${snapshot.warnings.join("; ")}` : ""}]`;
1
+ export function snapshotPrompt(envName, permissionMode, status, snapshot) {
2
+ return `[MerchantDuo: environment=${envName}; permission mode=${permissionMode}; Magento=${snapshot.edition ?? "unknown"} ${snapshot.version ?? "unknown"}; PHP=${snapshot.phpVersion ?? "unknown"}; mode=${snapshot.mode ?? "unknown"}; knowledge=${status}; pending actions are controlled by magento_workflow. Do not inspect app/etc/env.php or credentials.${snapshot.warnings.length ? ` Warnings: ${snapshot.warnings.join("; ")}` : ""}]`;
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@merchantduo/code",
3
- "version": "0.2.0-beta.3",
3
+ "version": "0.2.0-beta.4",
4
4
  "private": false,
5
5
  "description": "Magento-native Pi-based Coding Agent",
6
6
  "type": "module",