@merchantduo/code 0.2.0-beta.4 → 0.3.0-beta.0
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 +4 -4
- package/dist/app/native-operations.d.ts +19 -0
- package/dist/app/native-operations.js +50 -0
- package/dist/app/runtime.d.ts +2 -0
- package/dist/app/runtime.js +19 -2
- package/dist/app/status.d.ts +1 -0
- package/dist/app/status.js +3 -1
- package/dist/app/system-prompt.js +2 -0
- package/dist/environments/backend.d.ts +3 -1
- package/dist/environments/backend.js +6 -1
- package/dist/features/magerun2/service.d.ts +2 -1
- package/dist/features/magerun2/service.js +4 -2
- package/dist/integrations/pi/context.d.ts +2 -0
- package/dist/integrations/pi/context.js +36 -6
- package/dist/integrations/pi/workspace.d.ts +16 -0
- package/dist/integrations/pi/workspace.js +42 -4
- package/dist/magento/inspector.d.ts +3 -3
- package/dist/magento/inspector.js +16 -11
- package/dist/magento/model.d.ts +0 -1
- package/dist/testing/discovery.d.ts +3 -3
- package/dist/testing/discovery.js +10 -4
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -6,17 +6,17 @@ MerchantDuo is a Pi coding agent for Magento 2.4 teams. It starts from the store
|
|
|
6
6
|
|
|
7
7
|
- Works with local checkouts, Warden, and SSH environments.
|
|
8
8
|
- Detects Magento edition, version, PHP version, deployment mode, cache types, and registered theme inheritance.
|
|
9
|
-
- Ships Magento 2.4, Luma, Hyva, Mage2Gen, and
|
|
9
|
+
- Ships Magento 2.4, Luma, Hyva, Mage2Gen, testing, and persistent-goal skills.
|
|
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
|
|
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 the Magento version, 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 session theme and permission switching, lifecycle, and
|
|
19
|
+
- [Command reference](docs/commands.md) covers session theme and permission switching, lifecycle, navigation, and bundled persistent-goal 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
|
|
@@ -73,7 +73,7 @@ The interactive flow discovers available source, database, and service evidence,
|
|
|
73
73
|
|
|
74
74
|
## Install and start
|
|
75
75
|
|
|
76
|
-
MerchantDuo requires Node.js 22.19 or later, Bash, and Python 3. HTTP/browser testing additionally needs curl and Chrome or Chromium
|
|
76
|
+
MerchantDuo requires Node.js 22.19 or later, Bash, and Python 3. HTTP/browser testing additionally needs curl and Chrome or Chromium. The bundled `fetch_content` internet-research tool runs over host HTTP and has no browser or Python-runner prerequisite. Warden and SSH environments need Warden and OpenSSH respectively.
|
|
77
77
|
|
|
78
78
|
```sh
|
|
79
79
|
pnpm install --frozen-lockfile
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CommandResult, CommandSpec } from "#environments/executor";
|
|
2
|
+
export type NativeLogContext = {
|
|
3
|
+
project: string;
|
|
4
|
+
session: string;
|
|
5
|
+
environment: string;
|
|
6
|
+
};
|
|
7
|
+
/** Append-only, fail-soft evidence for automatic native probes only. */
|
|
8
|
+
export declare class NativeProbeLog {
|
|
9
|
+
#private;
|
|
10
|
+
readonly path: string;
|
|
11
|
+
constructor(path?: string);
|
|
12
|
+
get warning(): string | undefined;
|
|
13
|
+
start(context: NativeLogContext): Promise<void>;
|
|
14
|
+
record(spec: CommandSpec, result: CommandResult): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
export declare function runNativeProbe(log: NativeProbeLog, executor: {
|
|
17
|
+
command(argv: string[], target?: string): CommandSpec;
|
|
18
|
+
run(argv: string[], options?: Parameters<import("#environments/executor").CommandExecutor["run"]>[1]): Promise<CommandResult>;
|
|
19
|
+
}, argv: string[], options?: Parameters<import("#environments/executor").CommandExecutor["run"]>[1]): Promise<CommandResult>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { appendFile, chmod, mkdir } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
const MAX_STREAM = 8_000;
|
|
5
|
+
/** Append-only, fail-soft evidence for automatic native probes only. */
|
|
6
|
+
export class NativeProbeLog {
|
|
7
|
+
path;
|
|
8
|
+
#started = false;
|
|
9
|
+
#warning;
|
|
10
|
+
constructor(path = join(homedir(), ".merchantduo", "native-operations.log")) {
|
|
11
|
+
this.path = path;
|
|
12
|
+
}
|
|
13
|
+
get warning() { return this.#warning; }
|
|
14
|
+
async start(context) {
|
|
15
|
+
if (this.#started)
|
|
16
|
+
return;
|
|
17
|
+
this.#started = true;
|
|
18
|
+
await this.#append(`\n=== MerchantDuo native session ${new Date().toISOString()} ===\nsession: ${redact(context.session)}\nproject: ${redact(context.project)}\nenvironment: ${redact(context.environment)}\n`);
|
|
19
|
+
}
|
|
20
|
+
async record(spec, result) {
|
|
21
|
+
const stdout = stream(result.stdout);
|
|
22
|
+
const stderr = stream(result.stderr);
|
|
23
|
+
await this.#append(`\n[${new Date().toISOString()}]\ncommand: ${redact([spec.file, ...spec.args].join(" "))}\nexit_code: ${result.exitCode}\nstdout:\n${stdout.value}${result.truncated || stdout.truncated ? "\n[stdout truncated]" : ""}\nstderr:\n${stderr.value}${result.truncated || stderr.truncated ? "\n[stderr truncated]" : ""}\n`);
|
|
24
|
+
}
|
|
25
|
+
async #append(record) {
|
|
26
|
+
try {
|
|
27
|
+
const directory = dirname(this.path);
|
|
28
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
29
|
+
await chmod(directory, 0o700);
|
|
30
|
+
await appendFile(this.path, record, { encoding: "utf8", mode: 0o600 });
|
|
31
|
+
await chmod(this.path, 0o600);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
this.#warning = "Native operation logging is unavailable.";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function runNativeProbe(log, executor, argv, options) {
|
|
39
|
+
const spec = executor.command(argv, options?.target);
|
|
40
|
+
const result = await executor.run(argv, options);
|
|
41
|
+
await log.record(spec, result);
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
function stream(value) {
|
|
45
|
+
const redacted = redact(value);
|
|
46
|
+
return { value: redacted.slice(0, MAX_STREAM), truncated: redacted.length > MAX_STREAM };
|
|
47
|
+
}
|
|
48
|
+
function redact(value) {
|
|
49
|
+
return value.replace(/((?:password|passwd|secret|token|api[_-]?key)\s*(?:=|:|=>)\s*["']?)[^\s"']+/gi, "$1[redacted]");
|
|
50
|
+
}
|
package/dist/app/runtime.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { type MagentoInspector, type MagentoSnapshot } from "#magento/index";
|
|
|
7
7
|
import { ChangeTracker } from "#workflows/change-tracker";
|
|
8
8
|
import type { TestingSnapshot } from "#testing/model";
|
|
9
9
|
import type { Magerun2Capability } from "#features/magerun2/index";
|
|
10
|
+
import { NativeProbeLog } from "#app/native-operations";
|
|
10
11
|
export type SessionState = {
|
|
11
12
|
config: MerchantConfig;
|
|
12
13
|
configPath?: string;
|
|
@@ -15,6 +16,7 @@ export type SessionState = {
|
|
|
15
16
|
environment: Environment;
|
|
16
17
|
};
|
|
17
18
|
backend: EnvironmentBackend;
|
|
19
|
+
nativeLog: NativeProbeLog;
|
|
18
20
|
environmentStatus: EnvironmentStatus;
|
|
19
21
|
permissionMode: PermissionMode;
|
|
20
22
|
magento: MagentoSnapshot;
|
package/dist/app/runtime.js
CHANGED
|
@@ -3,6 +3,8 @@ import { EnvironmentBackend } from "#environments/backend";
|
|
|
3
3
|
import { resolvePermissionMode } from "#app/permission-mode";
|
|
4
4
|
import { DefaultMagentoInspector, } from "#magento/index";
|
|
5
5
|
import { ChangeTracker } from "#workflows/change-tracker";
|
|
6
|
+
import { NativeProbeLog } from "#app/native-operations";
|
|
7
|
+
import { wardenRunning } from "#environments/backend";
|
|
6
8
|
export class MerchantDuoRuntime {
|
|
7
9
|
inspector;
|
|
8
10
|
#state;
|
|
@@ -20,14 +22,17 @@ export class MerchantDuoRuntime {
|
|
|
20
22
|
});
|
|
21
23
|
const selected = selectEnvironment(loaded.config, cwd, process.env.MERCHANTDUO_SELECTED_ENV);
|
|
22
24
|
const backend = new EnvironmentBackend(selected.environment, cwd);
|
|
23
|
-
const
|
|
25
|
+
const nativeLog = new NativeProbeLog();
|
|
26
|
+
await nativeLog.start({ project: cwd, session: `${process.pid}`, environment: selected.name });
|
|
27
|
+
const environmentStatus = await nativeStatus(backend, nativeLog);
|
|
24
28
|
this.#state = {
|
|
25
29
|
...loaded,
|
|
26
30
|
selected,
|
|
27
31
|
backend,
|
|
32
|
+
nativeLog,
|
|
28
33
|
environmentStatus,
|
|
29
34
|
permissionMode: resolvePermissionMode(selected.environment, process.env.MERCHANTDUO_PERMISSION_MODE),
|
|
30
|
-
magento: environmentStatus === "running" ? await this.inspector.inspect(backend) : stoppedMagentoSnapshot(),
|
|
35
|
+
magento: environmentStatus === "running" ? await this.inspector.inspect(backend, (argv, result) => nativeLog.record(backend.command(argv), result)) : stoppedMagentoSnapshot(),
|
|
31
36
|
};
|
|
32
37
|
return this.#state;
|
|
33
38
|
}
|
|
@@ -102,3 +107,15 @@ export class MerchantDuoRuntime {
|
|
|
102
107
|
function stoppedMagentoSnapshot() {
|
|
103
108
|
return { cacheTypes: [], warnings: ["Environment is stopped"], evidence: [], themes: [] };
|
|
104
109
|
}
|
|
110
|
+
async function nativeStatus(backend, log) {
|
|
111
|
+
if (backend.environment.type === "ssh")
|
|
112
|
+
return "running";
|
|
113
|
+
if (backend.environment.type === "local")
|
|
114
|
+
return "unknown";
|
|
115
|
+
const spec = backend.lifecycleCommand("status");
|
|
116
|
+
if (!spec)
|
|
117
|
+
return "stopped";
|
|
118
|
+
const result = await backend.runCommand(spec);
|
|
119
|
+
await log.record(spec, result);
|
|
120
|
+
return result.exitCode === 0 && wardenRunning(result.stdout) ? "running" : "stopped";
|
|
121
|
+
}
|
package/dist/app/status.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { SessionState } from "#app/runtime";
|
|
3
|
+
export declare function setInitializingStatus(ctx: ExtensionContext): void;
|
|
3
4
|
export declare function refreshStatus(ctx: ExtensionContext, state: SessionState): void;
|
package/dist/app/status.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
export function setInitializingStatus(ctx) {
|
|
2
|
+
ctx.ui.setStatus("merchantduo", "Initializing Magento context…");
|
|
3
|
+
}
|
|
1
4
|
export function refreshStatus(ctx, state) {
|
|
2
5
|
const parts = [
|
|
3
|
-
`MerchantDuo ${process.env.MERCHANTDUO_PACKAGE_VERSION ?? "?"}`,
|
|
4
6
|
`M${state.magento.version ?? "?"}`,
|
|
5
7
|
`${state.selected.name} (${state.environmentStatus})`,
|
|
6
8
|
state.permissionMode === "read-only" ? "r-o" : state.permissionMode,
|
|
@@ -5,6 +5,8 @@ export function mage2genRoot() {
|
|
|
5
5
|
export function merchantDuoSystemPrompt(mage2genPath = mage2genRoot()) {
|
|
6
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.
|
|
7
7
|
|
|
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.
|
|
9
|
+
|
|
8
10
|
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.
|
|
9
11
|
|
|
10
12
|
For every request to create a new Magento module, call the \`mage2gen_generate_module\` tool to create the initial module scaffolding before writing custom files. The tool uses Mage2Gen at ${mage2genPath} and requires Python 3; it generates only into the selected Magento project, then inspect its output and adapt it to the task. Do not hand-create standard module boilerplate when Mage2Gen can create it. Skip Mage2Gen only when it cannot represent the requested module, and state the concrete reason before proceeding manually. Do not use it for a small targeted edit to an existing module.
|
|
@@ -11,8 +11,10 @@ export declare class EnvironmentBackend implements CommandExecutor {
|
|
|
11
11
|
path(path: string): string;
|
|
12
12
|
command(argv: string[], target?: string): CommandSpec;
|
|
13
13
|
run(argv: string[], options?: RunOptions): Promise<CommandResult>;
|
|
14
|
+
/** Execute a pre-routed adapter command, used by automatic lifecycle probing. */
|
|
15
|
+
runCommand(spec: CommandSpec, options?: RunOptions): Promise<CommandResult>;
|
|
14
16
|
lifecycleCommand(action: "status" | "start" | "stop"): CommandSpec | undefined;
|
|
15
|
-
status(): Promise<EnvironmentStatus>;
|
|
17
|
+
status(onCommand?: (spec: CommandSpec, result: CommandResult) => void): Promise<EnvironmentStatus>;
|
|
16
18
|
start(): Promise<CommandResult | undefined>;
|
|
17
19
|
stop(): Promise<CommandResult | undefined>;
|
|
18
20
|
}
|
|
@@ -22,12 +22,16 @@ export class EnvironmentBackend {
|
|
|
22
22
|
}
|
|
23
23
|
async run(argv, options = {}) {
|
|
24
24
|
const spec = this.command(argv, options.target);
|
|
25
|
+
return this.runCommand(spec, options);
|
|
26
|
+
}
|
|
27
|
+
/** Execute a pre-routed adapter command, used by automatic lifecycle probing. */
|
|
28
|
+
runCommand(spec, options = {}) {
|
|
25
29
|
return this.#runSpec(spec, options);
|
|
26
30
|
}
|
|
27
31
|
lifecycleCommand(action) {
|
|
28
32
|
return this.#adapter.lifecycleCommand?.(action);
|
|
29
33
|
}
|
|
30
|
-
async status() {
|
|
34
|
+
async status(onCommand) {
|
|
31
35
|
if (this.environment.type === "ssh")
|
|
32
36
|
return "running";
|
|
33
37
|
if (this.environment.type === "local")
|
|
@@ -36,6 +40,7 @@ export class EnvironmentBackend {
|
|
|
36
40
|
if (!spec)
|
|
37
41
|
return "stopped";
|
|
38
42
|
const output = await this.#runSpec(spec);
|
|
43
|
+
onCommand?.(spec, output);
|
|
39
44
|
return output.exitCode === 0 && wardenRunning(output.stdout) ? "running" : "stopped";
|
|
40
45
|
}
|
|
41
46
|
async start() {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { EnvironmentBackend } from "#environments/backend";
|
|
2
|
+
import type { CommandExecutor, CommandResult } from "#environments/executor";
|
|
2
3
|
import type { Environment } from "#environments/model";
|
|
3
4
|
import { type HostProcessRunner } from "#testing/host";
|
|
4
5
|
import type { Magerun2Capability, Magerun2Result } from "#features/magerun2/model";
|
|
5
6
|
/** Resolve magerun once for the selected Magento environment without altering the project. */
|
|
6
|
-
export declare function discoverMagerun2(environment: Environment, backend:
|
|
7
|
+
export declare function discoverMagerun2(environment: Environment, backend: CommandExecutor, cwd: string, record?: (argv: string[], result: CommandResult) => Promise<void>): Promise<Magerun2Capability>;
|
|
7
8
|
/** Run a structured argument vector. Local commands are host processes; Warden and SSH use their selected backend. */
|
|
8
9
|
export declare function runMagerun2(capability: Magerun2Capability, environment: Environment, backend: EnvironmentBackend, cwd: string, args: string[], stdin?: string, signal?: AbortSignal, host?: HostProcessRunner): Promise<Magerun2Result>;
|
|
9
10
|
/** Stable display only: this is never passed to a shell. */
|
|
@@ -6,14 +6,16 @@ import { NodeHostProcessRunner } from "#testing/host";
|
|
|
6
6
|
const RELEASE_URL = "https://api.github.com/repos/netz98/n98-magerun2/releases/latest";
|
|
7
7
|
const MAX_OUTPUT = 64 * 1024;
|
|
8
8
|
/** Resolve magerun once for the selected Magento environment without altering the project. */
|
|
9
|
-
export async function discoverMagerun2(environment, backend, cwd) {
|
|
9
|
+
export async function discoverMagerun2(environment, backend, cwd, record) {
|
|
10
10
|
if (environment.type === "warden")
|
|
11
11
|
return { available: true, executable: "/usr/local/bin/mr" };
|
|
12
12
|
const candidates = environment.type === "local"
|
|
13
13
|
? ["vendor/bin/n98-magerun2", "bin/n98-magerun2", "n98-magerun2", "magerun2"]
|
|
14
14
|
: ["n98-magerun2", "magerun2"];
|
|
15
15
|
for (const candidate of candidates) {
|
|
16
|
-
const
|
|
16
|
+
const argv = ["sh", "-lc", `command -v -- ${candidate} 2>/dev/null || { test -x ${candidate} && printf '%s' ${candidate}; }`];
|
|
17
|
+
const result = await backend.run(argv);
|
|
18
|
+
await record?.(argv, result);
|
|
17
19
|
const executable = result.stdout.trim();
|
|
18
20
|
if (executable)
|
|
19
21
|
return { available: true, executable };
|
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { runtime } from "#integrations/pi/session";
|
|
2
3
|
export default function context(pi: ExtensionAPI): void;
|
|
4
|
+
export declare function statusMessage(state: Awaited<ReturnType<typeof runtime.boot>>): string;
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { refreshStatus } from "#app/status";
|
|
1
|
+
import { refreshStatus, setInitializingStatus } from "#app/status";
|
|
2
2
|
import { merchantDuoSystemPrompt } from "#app/system-prompt";
|
|
3
3
|
import { discoverTesting } from "#testing/index";
|
|
4
4
|
import { discoverMagerun2 } from "#features/magerun2/index";
|
|
5
|
-
import { discoverHostExecutable } from "#testing/host";
|
|
5
|
+
import { discoverHostExecutable, NodeHostProcessRunner } from "#testing/host";
|
|
6
6
|
import { runtime, sessionEvents } from "#integrations/pi/session";
|
|
7
7
|
export default function context(pi) {
|
|
8
8
|
pi.on("session_start", async (_event, ctx) => {
|
|
9
9
|
runtime.resetInjection();
|
|
10
|
+
setInitializingStatus(ctx);
|
|
10
11
|
const state = await runtime.boot(ctx.cwd);
|
|
11
12
|
if (state.environmentStatus === "stopped") {
|
|
12
13
|
refreshStatus(ctx, state);
|
|
@@ -14,23 +15,34 @@ export default function context(pi) {
|
|
|
14
15
|
pi.appendEntry("merchantduo.context", { environment: state.selected.name, environmentStatus: state.environmentStatus, permissionMode: state.permissionMode });
|
|
15
16
|
return;
|
|
16
17
|
}
|
|
17
|
-
const
|
|
18
|
+
const record = (argv, result) => state.nativeLog.record(state.backend.command(argv), result);
|
|
19
|
+
const magerun2 = await discoverMagerun2(state.selected.environment, state.backend, ctx.cwd, record);
|
|
18
20
|
// HTTP and Chromium deliberately run on the host against Warden's routed URL.
|
|
19
21
|
// Do not use EnvironmentBackend here: its Warden adapter would probe php-fpm.
|
|
20
22
|
// Prefer non-Snap Chrome: Snap Chromium cannot write MerchantDuo artifacts
|
|
21
23
|
// and its private /tmp makes generated output unavailable to the host.
|
|
24
|
+
const hostRunner = new NodeHostProcessRunner();
|
|
25
|
+
const nativeHost = { run: async (file, args, options) => {
|
|
26
|
+
const result = await hostRunner.run(file, args, options);
|
|
27
|
+
await state.nativeLog.record({ file, args, cwd: options.cwd }, { stdout: result.output, stderr: "", exitCode: result.code, truncated: result.truncated ?? false });
|
|
28
|
+
return result;
|
|
29
|
+
} };
|
|
22
30
|
const [http, browser] = await Promise.all([
|
|
23
|
-
discoverHostExecutable(["curl"]),
|
|
24
|
-
discoverHostExecutable(["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"]),
|
|
31
|
+
discoverHostExecutable(["curl"], nativeHost),
|
|
32
|
+
discoverHostExecutable(["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"], nativeHost),
|
|
25
33
|
]);
|
|
26
34
|
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 }));
|
|
35
|
+
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 }, record));
|
|
28
36
|
refreshStatus(ctx, state);
|
|
29
37
|
sessionEvents.emit("merchantduo.context", state);
|
|
30
38
|
pi.appendEntry("merchantduo.context", { environment: state.selected.name, environmentStatus: state.environmentStatus, permissionMode: state.permissionMode, testing: state.testing, magerun2: state.magerun2 });
|
|
31
39
|
});
|
|
32
40
|
pi.on("session_compact", () => runtime.resetInjection());
|
|
33
41
|
pi.on("session_shutdown", () => sessionEvents.clear());
|
|
42
|
+
pi.registerCommand("duo-status", { description: "Show the current MerchantDuo session status.", handler: async (_args, ctx) => {
|
|
43
|
+
const state = await runtime.boot(ctx.cwd);
|
|
44
|
+
pi.sendMessage({ customType: "merchantduo.status", content: statusMessage(state), display: true, details: {} });
|
|
45
|
+
} });
|
|
34
46
|
pi.on("tool_call", (event) => {
|
|
35
47
|
if (event.toolName !== "write" && event.toolName !== "edit")
|
|
36
48
|
return;
|
|
@@ -50,3 +62,21 @@ export default function context(pi) {
|
|
|
50
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."}` };
|
|
51
63
|
});
|
|
52
64
|
}
|
|
65
|
+
export function statusMessage(state) {
|
|
66
|
+
const lines = [
|
|
67
|
+
`Environment: ${state.selected.name} (${state.environmentStatus})`,
|
|
68
|
+
`Permission mode: ${state.permissionMode}`,
|
|
69
|
+
`Magento: ${state.magento.edition ?? "unknown"} ${state.magento.version ?? "version unavailable"}`,
|
|
70
|
+
`PHP: ${state.magento.phpVersion ?? "unavailable"}`,
|
|
71
|
+
`Deploy mode: ${state.magento.mode ?? "unavailable"}`,
|
|
72
|
+
`Active theme: ${state.config.activeTheme}`,
|
|
73
|
+
`Frontend URL: ${state.testing?.frontendUrl ?? "unavailable"}`,
|
|
74
|
+
`Admin URL: ${state.testing?.adminUrl ?? "unavailable"}`,
|
|
75
|
+
`Capabilities: HTTP ${state.testing?.http.available ? "available" : "unavailable"}; browser ${state.testing?.browser.available ? "available" : "unavailable"}; magerun2 ${state.magerun2?.available ? "available" : "unavailable"}`,
|
|
76
|
+
...state.magento.warnings.map((warning) => `Warning: ${warning}`),
|
|
77
|
+
...(state.testing?.diagnostics.map((warning) => `Warning: ${warning}`) ?? []),
|
|
78
|
+
...(state.magerun2?.diagnostic ? [`Warning: ${state.magerun2.diagnostic}`] : []),
|
|
79
|
+
...(state.nativeLog.warning ? [`Warning: ${state.nativeLog.warning}`] : []),
|
|
80
|
+
];
|
|
81
|
+
return lines.join("\n");
|
|
82
|
+
}
|
|
@@ -2,4 +2,20 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import type { EnvironmentBackend } from "#environments/backend";
|
|
3
3
|
/** Replaces Pi's native workspace tools with selected-environment operations. */
|
|
4
4
|
export default function workspace(pi: ExtensionAPI): void;
|
|
5
|
+
type SearchParameters = {
|
|
6
|
+
pattern: string;
|
|
7
|
+
glob?: string;
|
|
8
|
+
ignoreCase?: boolean;
|
|
9
|
+
literal?: boolean;
|
|
10
|
+
context?: number;
|
|
11
|
+
limit?: number;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Search the selected backend without making its runtime image depend on ripgrep:
|
|
15
|
+
* use rg when present, otherwise enumerate regular files and pass them to grep.
|
|
16
|
+
*/
|
|
17
|
+
export declare function searchCommand(params: SearchParameters, path: string): string;
|
|
5
18
|
export declare function workspacePath(backend: EnvironmentBackend, cwd: string, candidate: string): string;
|
|
19
|
+
/** Return only a declared MerchantDuo skill asset; project files remain environment-routed. */
|
|
20
|
+
export declare function packagedSkillPath(candidate: string): string | undefined;
|
|
21
|
+
export {};
|
|
@@ -1,16 +1,34 @@
|
|
|
1
|
+
import { access as hostAccess, readFile as hostReadFile } from "node:fs/promises";
|
|
1
2
|
import { relative, resolve } from "node:path";
|
|
2
3
|
import { Type } from "typebox";
|
|
3
4
|
import { createEditToolDefinition, createReadToolDefinition, createWriteToolDefinition, } from "@earendil-works/pi-coding-agent";
|
|
4
5
|
import { runtime } from "#integrations/pi/session";
|
|
6
|
+
import { packagePath } from "#shared/package-paths";
|
|
5
7
|
const shell = (value) => `'${value.replace(/'/g, "'\\''")}'`;
|
|
6
8
|
const result = (value) => ({ content: [{ type: "text", text: value }], details: {} });
|
|
9
|
+
const packageSkillRoots = [
|
|
10
|
+
packagePath("skills"),
|
|
11
|
+
packagePath("vendor", "hyva-ai-tools", "skills"),
|
|
12
|
+
];
|
|
7
13
|
/** Replaces Pi's native workspace tools with selected-environment operations. */
|
|
8
14
|
export default function workspace(pi) {
|
|
9
15
|
registerFileTools(pi);
|
|
10
16
|
pi.registerTool({ name: "bash", label: "bash", description: "Run a shell command in the selected MerchantDuo environment.", parameters: Type.Object({ command: Type.String(), timeout: Type.Optional(Type.Number()) }), async execute(_id, params, signal, _update, ctx) { const state = await runtime.boot(ctx.cwd); const output = await state.backend.run(["sh", "-lc", params.command], { signal, timeoutMs: params.timeout }); return result(`${output.stdout}${output.stderr}`); } });
|
|
11
17
|
pi.registerTool({ name: "ls", label: "ls", description: "List a selected-environment directory.", parameters: Type.Object({ path: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()) }), async execute(_id, params, signal, _update, ctx) { const state = await runtime.boot(ctx.cwd); const path = workspacePath(state.backend, ctx.cwd, params.path ?? "."); const output = await state.backend.run(["sh", "-lc", `find ${shell(path)} -mindepth 1 -maxdepth 1 -printf '%f\\n' | head -n ${Math.max(1, Math.min(params.limit ?? 200, 1000))}`], { signal }); return result(output.stdout || output.stderr); } });
|
|
12
18
|
pi.registerTool({ name: "find", label: "find", description: "Find files in the selected MerchantDuo environment.", parameters: Type.Object({ pattern: Type.String(), path: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()) }), async execute(_id, params, signal, _update, ctx) { const state = await runtime.boot(ctx.cwd); const path = workspacePath(state.backend, ctx.cwd, params.path ?? "."); const limit = Math.max(1, Math.min(params.limit ?? 1000, 5000)); const output = await state.backend.run(["sh", "-lc", `find ${shell(path)} -path '*/node_modules/*' -prune -o -path '*/.git/*' -prune -o -name ${shell(params.pattern)} -print | head -n ${limit}`], { signal }); return result(output.stdout || output.stderr); } });
|
|
13
|
-
pi.registerTool({ name: "grep", label: "grep", description: "Search text
|
|
19
|
+
pi.registerTool({ name: "grep", label: "grep", description: "Search text with ripgrep or a backend-local grep fallback in the selected MerchantDuo environment.", parameters: Type.Object({ pattern: Type.String(), path: Type.Optional(Type.String()), glob: Type.Optional(Type.String()), ignoreCase: Type.Optional(Type.Boolean()), literal: Type.Optional(Type.Boolean()), context: Type.Optional(Type.Number()), limit: Type.Optional(Type.Number()) }), async execute(_id, params, signal, _update, ctx) { const state = await runtime.boot(ctx.cwd); const path = workspacePath(state.backend, ctx.cwd, params.path ?? "."); const output = await state.backend.run(["sh", "-lc", searchCommand(params, path)], { signal }); return result(output.stdout || output.stderr); } });
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Search the selected backend without making its runtime image depend on ripgrep:
|
|
23
|
+
* use rg when present, otherwise enumerate regular files and pass them to grep.
|
|
24
|
+
*/
|
|
25
|
+
export function searchCommand(params, path) {
|
|
26
|
+
const limit = Math.max(1, Math.min(params.limit ?? 100, 1000));
|
|
27
|
+
const context = params.context ? Math.min(params.context, 20) : undefined;
|
|
28
|
+
const rgFlags = ["--line-number", "--color=never", `--max-count=${limit}`, params.ignoreCase ? "--ignore-case" : "", params.literal ? "--fixed-strings" : "", context ? `--context=${context}` : "", params.glob ? `--glob ${shell(params.glob)}` : ""].filter(Boolean).join(" ");
|
|
29
|
+
const grepFlags = ["-n", params.ignoreCase ? "-i" : "", params.literal ? "-F" : "", context ? `-C ${context}` : ""].filter(Boolean).join(" ");
|
|
30
|
+
const glob = params.glob ? `-name ${shell(params.glob)} ` : "";
|
|
31
|
+
return `if command -v rg >/dev/null 2>&1; then rg ${rgFlags} -- ${shell(params.pattern)} ${shell(path)}; else find ${shell(path)} \\( -path '*/node_modules/*' -o -path '*/.git/*' \\) -prune -o -type f ${glob}-exec grep ${grepFlags} -- ${shell(params.pattern)} {} + 2>/dev/null | head -n ${limit}; fi`;
|
|
14
32
|
}
|
|
15
33
|
function registerFileTools(pi) {
|
|
16
34
|
const localRead = createReadToolDefinition(process.cwd());
|
|
@@ -21,13 +39,33 @@ function registerFileTools(pi) {
|
|
|
21
39
|
pi.registerTool({ ...localEdit, async execute(id, params, signal, update, ctx) { const state = await runtime.boot(ctx.cwd); return createEditToolDefinition(ctx.cwd, { operations: fileOps(state.backend, ctx.cwd) }).execute(id, params, signal, update, ctx); } });
|
|
22
40
|
}
|
|
23
41
|
function fileOps(backend, cwd) {
|
|
24
|
-
const readFile = async (path) => {
|
|
25
|
-
|
|
42
|
+
const readFile = async (path) => {
|
|
43
|
+
const skillPath = packagedSkillPath(path);
|
|
44
|
+
if (skillPath)
|
|
45
|
+
return hostReadFile(skillPath);
|
|
46
|
+
const target = workspacePath(backend, cwd, path);
|
|
47
|
+
const response = await backend.run(["sh", "-lc", `base64 < ${shell(target)}`]);
|
|
48
|
+
if (response.exitCode)
|
|
49
|
+
throw new Error(response.stderr || "Cannot read file");
|
|
50
|
+
return Buffer.from(response.stdout.replace(/\s/g, ""), "base64");
|
|
51
|
+
};
|
|
26
52
|
const writeFile = async (path, content) => { const target = workspacePath(backend, cwd, path); const response = await backend.run(["sh", "-lc", `base64 -d > ${shell(target)}`], { input: Buffer.from(content).toString("base64") }); if (response.exitCode)
|
|
27
53
|
throw new Error(response.stderr || "Cannot write file"); };
|
|
28
|
-
return { readFile, writeFile, access: async (path) => { const
|
|
54
|
+
return { readFile, writeFile, access: async (path) => { const skillPath = packagedSkillPath(path); if (skillPath) {
|
|
55
|
+
await hostAccess(skillPath);
|
|
56
|
+
return;
|
|
57
|
+
} const response = await backend.run(["sh", "-lc", `test -r ${shell(workspacePath(backend, cwd, path))}`]); if (response.exitCode)
|
|
29
58
|
throw new Error("Path is inaccessible"); }, mkdir: async (path) => { const response = await backend.run(["mkdir", "-p", workspacePath(backend, cwd, path)]); if (response.exitCode)
|
|
30
59
|
throw new Error(response.stderr || "Cannot create directory"); } };
|
|
31
60
|
}
|
|
32
61
|
export function workspacePath(backend, cwd, candidate) { const full = resolve(cwd, candidate); const value = relative(resolve(cwd), full); if (value === ".." || value.startsWith("../"))
|
|
33
62
|
throw new Error(`Path escapes workspace: ${candidate}`); return backend.path(value); }
|
|
63
|
+
/** Return only a declared MerchantDuo skill asset; project files remain environment-routed. */
|
|
64
|
+
export function packagedSkillPath(candidate) {
|
|
65
|
+
const full = resolve(candidate);
|
|
66
|
+
return packageSkillRoots.some((root) => contains(root, full)) ? full : undefined;
|
|
67
|
+
}
|
|
68
|
+
function contains(root, candidate) {
|
|
69
|
+
const value = relative(root, candidate);
|
|
70
|
+
return value === "" || (value !== ".." && !value.startsWith("../"));
|
|
71
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { CommandExecutor } from "#environments/executor";
|
|
1
|
+
import type { CommandExecutor, CommandResult } from "#environments/executor";
|
|
2
2
|
import type { MagentoSnapshot } from "#magento/model";
|
|
3
3
|
export interface MagentoInspector {
|
|
4
|
-
inspect(executor: CommandExecutor): Promise<MagentoSnapshot>;
|
|
4
|
+
inspect(executor: CommandExecutor, record?: (argv: string[], result: CommandResult) => Promise<void>): Promise<MagentoSnapshot>;
|
|
5
5
|
}
|
|
6
6
|
/** Ordered, fail-soft Magento inspection. New probes can be extracted without changing consumers. */
|
|
7
7
|
export declare class DefaultMagentoInspector implements MagentoInspector {
|
|
8
|
-
inspect(executor: CommandExecutor): Promise<MagentoSnapshot>;
|
|
8
|
+
inspect(executor: CommandExecutor, record?: (argv: string[], result: CommandResult) => Promise<void>): Promise<MagentoSnapshot>;
|
|
9
9
|
}
|
|
@@ -2,13 +2,18 @@ import { parseDeployMode } from "#magento/deploy-mode";
|
|
|
2
2
|
import { parseRegisteredThemes, resolveThemes } from "#magento/themes";
|
|
3
3
|
/** Ordered, fail-soft Magento inspection. New probes can be extracted without changing consumers. */
|
|
4
4
|
export class DefaultMagentoInspector {
|
|
5
|
-
async inspect(executor) {
|
|
5
|
+
async inspect(executor, record) {
|
|
6
6
|
const warnings = [];
|
|
7
7
|
const evidence = [];
|
|
8
|
-
const
|
|
8
|
+
const run = async (argv) => {
|
|
9
|
+
const result = await executor.run(argv);
|
|
10
|
+
await record?.(argv, result);
|
|
11
|
+
return result;
|
|
12
|
+
};
|
|
13
|
+
const composer = await run([
|
|
9
14
|
"sh",
|
|
10
15
|
"-lc",
|
|
11
|
-
|
|
16
|
+
`test -f composer.lock && php -r '$x=json_decode(file_get_contents("composer.lock"), true, flags: JSON_THROW_ON_ERROR); echo json_encode($x["packages"] ?? []);'`,
|
|
12
17
|
]);
|
|
13
18
|
const match = composer.stdout.match(/magento\/(product-community-edition|product-enterprise-edition).*?"version":"([^"]+)/s);
|
|
14
19
|
const edition = match?.[1] === "product-enterprise-edition"
|
|
@@ -19,20 +24,21 @@ export class DefaultMagentoInspector {
|
|
|
19
24
|
const composerVersion = match?.[2];
|
|
20
25
|
if (composerVersion)
|
|
21
26
|
evidence.push("composer.lock");
|
|
22
|
-
const cli = await
|
|
27
|
+
const cli = await run(["bin/magento", "--version"]);
|
|
23
28
|
const cliVersion = cli.stdout.match(/(?:Magento|Adobe Commerce)[^0-9]*(\d+\.\d+\.\d+(?:-[^\s]+)?)/)?.[1];
|
|
24
29
|
if (cliVersion)
|
|
25
30
|
evidence.push("bin/magento --version");
|
|
31
|
+
if (!composerVersion && !cliVersion)
|
|
32
|
+
warnings.push("Magento version unavailable");
|
|
26
33
|
if (composerVersion &&
|
|
27
34
|
cliVersion &&
|
|
28
35
|
!cliVersion.startsWith(composerVersion))
|
|
29
36
|
warnings.push(`Composer (${composerVersion}) and CLI (${cliVersion}) disagree`);
|
|
30
|
-
const [php, mode, cache,
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
executor.run(["sh", "-lc", themeDiscoveryScript]),
|
|
37
|
+
const [php, mode, cache, registeredThemes] = await Promise.all([
|
|
38
|
+
run(["php", "-r", "echo PHP_VERSION;"]),
|
|
39
|
+
run(["bin/magento", "deploy:mode:show"]),
|
|
40
|
+
run(["bin/magento", "cache:status", "--no-ansi"]),
|
|
41
|
+
run(["sh", "-lc", themeDiscoveryScript]),
|
|
36
42
|
]);
|
|
37
43
|
return {
|
|
38
44
|
version: composerVersion ?? cliVersion,
|
|
@@ -43,7 +49,6 @@ export class DefaultMagentoInspector {
|
|
|
43
49
|
.split("\n")
|
|
44
50
|
.map((line) => line.match(/^([^:]+):/)?.[1])
|
|
45
51
|
.filter((value) => Boolean(value)),
|
|
46
|
-
modulesEnabled: modules.stdout.split("\n").filter(Boolean).length || undefined,
|
|
47
52
|
warnings,
|
|
48
53
|
evidence,
|
|
49
54
|
themes: resolveThemes(parseRegisteredThemes(registeredThemes.stdout)),
|
package/dist/magento/model.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { CommandExecutor, CommandResult } from "#environments/executor";
|
|
2
2
|
import type { Environment } from "#environments/model";
|
|
3
3
|
import type { TestingOptions, TestingSnapshot } from "#testing/model";
|
|
4
|
-
export declare function discoverTesting(environment: Environment, backend:
|
|
4
|
+
export declare function discoverTesting(environment: Environment, backend: CommandExecutor, cwd: string, options: TestingOptions, tools?: {
|
|
5
5
|
http?: string;
|
|
6
6
|
browser?: string;
|
|
7
|
-
}): Promise<TestingSnapshot>;
|
|
7
|
+
}, record?: (argv: string[], result: CommandResult) => Promise<void>): Promise<TestingSnapshot>;
|
|
8
8
|
export declare function wardenRouting(projectRoot: string): Promise<string | undefined>;
|
|
9
9
|
export declare function findExecutable(candidates: string[]): Promise<string | undefined>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { access, readFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { canonicalUrl, joinedUrl } from "#testing/url";
|
|
4
|
-
export async function discoverTesting(environment, backend, cwd, options, tools = {}) {
|
|
4
|
+
export async function discoverTesting(environment, backend, cwd, options, tools = {}, record) {
|
|
5
5
|
const diagnostics = [];
|
|
6
6
|
let frontendUrl = canonicalUrl(options.frontendUrl ?? "");
|
|
7
7
|
let adminUrl = canonicalUrl(options.adminUrl ?? "");
|
|
@@ -17,15 +17,21 @@ export async function discoverTesting(environment, backend, cwd, options, tools
|
|
|
17
17
|
diagnostics.push("Warden routing was not found in .env.");
|
|
18
18
|
}
|
|
19
19
|
if (!frontendUrl && environment.type !== "warden") {
|
|
20
|
-
const
|
|
20
|
+
const secure = ["bin/magento", "config:show", "web/secure/base_url"];
|
|
21
|
+
const result = await backend.run(secure);
|
|
22
|
+
await record?.(secure, result);
|
|
21
23
|
frontendUrl = canonicalUrl(result.stdout.trim());
|
|
22
24
|
if (!frontendUrl) {
|
|
23
|
-
const
|
|
25
|
+
const unsecure = ["bin/magento", "config:show", "web/unsecure/base_url"];
|
|
26
|
+
const fallback = await backend.run(unsecure);
|
|
27
|
+
await record?.(unsecure, fallback);
|
|
24
28
|
frontendUrl = canonicalUrl(fallback.stdout.trim());
|
|
25
29
|
}
|
|
26
30
|
}
|
|
27
31
|
if (!adminUrl) {
|
|
28
|
-
const
|
|
32
|
+
const argv = ["bin/magento", "info:adminuri"];
|
|
33
|
+
const result = await backend.run(argv);
|
|
34
|
+
await record?.(argv, result);
|
|
29
35
|
const path = result.stdout.trim().split(/\s+/).pop();
|
|
30
36
|
adminUrl = joinedUrl(frontendUrl, path?.startsWith("/") ? path : `/${path ?? ""}`);
|
|
31
37
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@merchantduo/code",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0-beta.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Magento-native Pi-based Coding Agent",
|
|
6
6
|
"type": "module",
|
|
@@ -48,7 +48,9 @@
|
|
|
48
48
|
"./dist/integrations/pi/mage2gen.js",
|
|
49
49
|
"./dist/integrations/pi/workflows.js",
|
|
50
50
|
"./dist/integrations/pi/magerun2.js",
|
|
51
|
-
"./dist/integrations/pi/testing.js"
|
|
51
|
+
"./dist/integrations/pi/testing.js",
|
|
52
|
+
"./node_modules/pi-web-access",
|
|
53
|
+
"./node_modules/pi-goal"
|
|
52
54
|
],
|
|
53
55
|
"skills": [
|
|
54
56
|
"./skills",
|
|
@@ -58,6 +60,8 @@
|
|
|
58
60
|
"dependencies": {
|
|
59
61
|
"@earendil-works/pi-coding-agent": "0.84.1",
|
|
60
62
|
"@modelcontextprotocol/client": "2.0.0",
|
|
63
|
+
"pi-goal": "0.1.7",
|
|
64
|
+
"pi-web-access": "0.22.0",
|
|
61
65
|
"typebox": "1.3.7",
|
|
62
66
|
"yaml": "^2.8.1",
|
|
63
67
|
"zod": "^3.24.4"
|