@merchantduo/code 0.2.0-beta.2 → 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 +26 -120
- package/dist/app/permission-mode.d.ts +5 -0
- package/dist/app/permission-mode.js +10 -0
- package/dist/app/runtime.d.ts +11 -1
- package/dist/app/runtime.js +52 -4
- package/dist/app/status.js +9 -7
- package/dist/app/system-prompt.js +7 -1
- package/dist/cli/arguments.d.ts +1 -0
- package/dist/cli/arguments.js +7 -1
- package/dist/cli/commands/agent.d.ts +3 -1
- package/dist/cli/commands/agent.js +5 -8
- package/dist/cli/commands/doctor.js +2 -2
- package/dist/cli/commands/init.d.ts +1 -1
- package/dist/cli/commands/init.js +1 -1
- package/dist/cli/main.js +1 -1
- package/dist/config/schema.d.ts +0 -5
- package/dist/config/schema.js +0 -1
- package/dist/environments/adapter.d.ts +1 -0
- package/dist/environments/adapters/warden.d.ts +5 -0
- package/dist/environments/adapters/warden.js +7 -0
- package/dist/environments/backend.d.ts +7 -0
- package/dist/environments/backend.js +31 -0
- package/dist/environments/model.d.ts +2 -2
- package/dist/environments/model.js +1 -3
- package/dist/features/magerun2/index.d.ts +2 -0
- package/dist/features/magerun2/index.js +2 -0
- package/dist/features/magerun2/model.d.ts +10 -0
- package/dist/features/magerun2/model.js +1 -0
- package/dist/features/magerun2/service.d.ts +12 -0
- package/dist/{testing/magerun.js → features/magerun2/service.js} +34 -13
- package/dist/integrations/pi/context.js +13 -7
- package/dist/integrations/pi/environment.d.ts +3 -0
- package/dist/integrations/pi/environment.js +64 -0
- package/dist/integrations/pi/magerun2.d.ts +4 -0
- package/dist/integrations/pi/magerun2.js +35 -0
- package/dist/integrations/pi/optional-services.d.ts +9 -0
- package/dist/integrations/pi/optional-services.js +79 -0
- package/dist/integrations/pi/permissions.d.ts +4 -2
- package/dist/integrations/pi/permissions.js +48 -21
- package/dist/integrations/pi/testing.js +8 -10
- package/dist/integrations/pi/theme.d.ts +2 -0
- package/dist/integrations/pi/theme.js +23 -4
- package/dist/integrations/pi/workflows.js +1 -1
- package/dist/magento/prompt.d.ts +2 -1
- package/dist/magento/prompt.js +2 -2
- package/dist/testing/discovery.d.ts +0 -1
- package/dist/testing/discovery.js +1 -4
- package/dist/testing/host.d.ts +1 -0
- package/dist/testing/host.js +14 -3
- package/dist/testing/index.d.ts +0 -1
- package/dist/testing/index.js +0 -1
- package/dist/testing/model.d.ts +0 -1
- package/package.json +5 -1
- package/skills/magerun2/SKILL.md +22 -0
- package/dist/testing/magerun.d.ts +0 -8
|
@@ -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;
|
|
@@ -20,9 +22,7 @@ export type SshEnvironment = {
|
|
|
20
22
|
type: "ssh";
|
|
21
23
|
host: string;
|
|
22
24
|
root: string;
|
|
23
|
-
writable: boolean;
|
|
24
25
|
testing?: EnvironmentTesting;
|
|
25
26
|
};
|
|
26
27
|
export type Environment = LocalEnvironment | WardenEnvironment | SshEnvironment;
|
|
27
|
-
export declare function environmentWritable(environment: Environment): boolean;
|
|
28
28
|
export {};
|
|
@@ -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
|
-
|
|
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 {
|
|
22
|
+
return { available: false, diagnostic: "Install n98-magerun2 on the remote host." };
|
|
20
23
|
try {
|
|
21
|
-
return { executable: await
|
|
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
|
-
/**
|
|
28
|
-
export async function
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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, permissionMode: state.permissionMode });
|
|
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.
|
|
20
|
-
|
|
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, permissionMode: state.permissionMode, 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()}\
|
|
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."}` };
|
|
45
51
|
});
|
|
46
52
|
}
|
|
@@ -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[]): string | undefined;
|
|
@@ -0,0 +1,35 @@
|
|
|
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);
|
|
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) {
|
|
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
|
+
return undefined;
|
|
35
|
+
}
|
|
@@ -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,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
|
-
|
|
5
|
-
|
|
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,35 +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
|
-
return (toolName === "mage2gen_generate_module" || toolName === "magento_workflow") && input.execute === true;
|
|
7
|
+
return ["environment_start", "environment_stop"].includes(toolName) || ((toolName === "mage2gen_generate_module" || toolName === "magento_workflow" || toolName === "magerun2") && input.execute === true);
|
|
4
8
|
}
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
if (
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return ok ? undefined : { block: true, reason: "Cancelled: SSH confirmation required." };
|
|
22
|
-
}
|
|
41
|
+
if (state.environmentStatus === "stopped" && !stoppedAllowed.has(event.toolName))
|
|
42
|
+
return { block: true, reason: "MerchantDuo: selected environment is stopped. Start it first." };
|
|
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." };
|
|
23
50
|
});
|
|
24
51
|
pi.on("user_bash", async (event, ctx) => {
|
|
25
52
|
const state = await runtime.boot(ctx.cwd);
|
|
26
|
-
if (state.
|
|
27
|
-
return;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const ok = ctx.hasUI && await ctx.ui.confirm("SSH command", `Run command on ${state.selected.environment.host}?`);
|
|
31
|
-
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?`)))
|
|
32
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;
|
|
33
60
|
const result = await state.backend.run(["sh", "-lc", event.command]);
|
|
34
61
|
return { result: { output: `${result.stdout}${result.stderr}`, exitCode: result.exitCode, cancelled: false, truncated: result.truncated } };
|
|
35
62
|
});
|
|
@@ -56,23 +56,22 @@ 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
|
-
|
|
60
|
-
return text("PHP REPL is unavailable for read-only SSH.");
|
|
61
|
-
const executable = state.testing?.phpRepl.executable;
|
|
59
|
+
const executable = state.magerun2?.executable;
|
|
62
60
|
if (!executable)
|
|
63
|
-
return text(state.
|
|
64
|
-
const args = [
|
|
61
|
+
return text(state.magerun2?.diagnostic ?? "PHP REPL unavailable.");
|
|
62
|
+
const args = ["dev:console"];
|
|
65
63
|
if (params.area)
|
|
66
64
|
args.push(`--area=${params.area}`);
|
|
67
65
|
if (state.selected.environment.type === "local") {
|
|
68
|
-
const
|
|
66
|
+
const command = state.backend.command([executable, ...args]);
|
|
67
|
+
const result = await host.run(command.file, command.args, { cwd: command.cwd ?? ctx.cwd, signal, input: params.snippet });
|
|
69
68
|
return text(bound(result.output));
|
|
70
69
|
}
|
|
71
|
-
const result = await state.backend.run(args, { input: params.snippet, signal });
|
|
70
|
+
const result = await state.backend.run([executable, ...args], { input: params.snippet, signal });
|
|
72
71
|
return text(bound(`${result.stdout}${result.stderr}`));
|
|
73
72
|
} });
|
|
74
|
-
registerNavigate(pi, "navigate-frontend", "frontend");
|
|
75
|
-
registerNavigate(pi, "navigate-adminhtml", "adminhtml");
|
|
73
|
+
registerNavigate(pi, "duo-navigate-frontend", "frontend");
|
|
74
|
+
registerNavigate(pi, "duo-navigate-adminhtml", "adminhtml");
|
|
76
75
|
}
|
|
77
76
|
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
77
|
ctx.ui.notify("URL unavailable or path is not same-origin.", "error");
|
|
@@ -84,5 +83,4 @@ function registerNavigate(pi, name, target) { pi.registerCommand(name, { descrip
|
|
|
84
83
|
function targetUrl(base, path) { if (!relativePath(path))
|
|
85
84
|
return undefined; const url = joinedUrl(base, path); return url && sameOrigin(base, url) ? url : undefined; }
|
|
86
85
|
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
86
|
const BunOrNode = { async write(path, value) { const { writeFile } = await import("node:fs/promises"); await writeFile(path, value, "utf8"); } };
|
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
import { refreshStatus } from "#app/status";
|
|
2
2
|
import { runtime } from "#integrations/pi/session";
|
|
3
|
-
export
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
+
}
|