@evref-bl/pharo-agent 0.0.0-stage → 0.2.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/.agents/plugins/marketplace.json +17 -0
- package/.claude-plugin/marketplace.json +12 -0
- package/.claude-plugin/plugin.json +18 -0
- package/LICENSE +7 -0
- package/README.md +60 -2
- package/bin/pharo-agent.js +7 -0
- package/package.json +37 -4
- package/plugin.json +24 -0
- package/skills/pharo-setup/SKILL.md +27 -0
- package/src/cli.js +165 -0
- package/src/gateway.js +53 -0
- package/src/images.js +95 -0
- package/src/init.js +176 -0
- package/src/live.js +88 -0
- package/src/project.js +88 -0
- package/src/runtime.js +67 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "evref-bl",
|
|
3
|
+
"interface": {
|
|
4
|
+
"displayName": "Evref-BL"
|
|
5
|
+
},
|
|
6
|
+
"plugins": [
|
|
7
|
+
{
|
|
8
|
+
"name": "pharo-agent",
|
|
9
|
+
"source": "./",
|
|
10
|
+
"policy": {
|
|
11
|
+
"installation": "AVAILABLE",
|
|
12
|
+
"authentication": "ON_INSTALL"
|
|
13
|
+
},
|
|
14
|
+
"category": "Developer Tools"
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pharo-agent",
|
|
3
|
+
"displayName": "Pharo Agent",
|
|
4
|
+
"version": "0.2.0",
|
|
5
|
+
"description": "Terminal-first Pharo image setup for coding agents.",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Evref-BL",
|
|
8
|
+
"url": "https://github.com/Evref-BL"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/Evref-BL/pharo-agent-plugin",
|
|
11
|
+
"repository": "https://github.com/Evref-BL/pharo-agent-plugin",
|
|
12
|
+
"license": "Apache-2.0",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"pharo",
|
|
15
|
+
"smalltalk",
|
|
16
|
+
"mcp"
|
|
17
|
+
]
|
|
18
|
+
}
|
package/LICENSE
ADDED
package/README.md
CHANGED
|
@@ -1,3 +1,61 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pharo-agent
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`pharo-agent` is a terminal-first Pharo integration for Codex, Claude Code, and
|
|
4
|
+
OpenCode. It has one small setup skill; a project receives the full Pharo
|
|
5
|
+
guidance only after setup.
|
|
6
|
+
|
|
7
|
+
Initialize the current directory for one selected agent host:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npx --yes @evref-bl/pharo-agent init --host codex
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
It writes a small conditional instruction block, `pharo.AGENTS.md`, and copies
|
|
14
|
+
on-demand Pharo workflow skills and references from the pinned MCP template
|
|
15
|
+
package. It does not install an agent-host MCP server.
|
|
16
|
+
|
|
17
|
+
`init` acts on the current directory and never searches above it. If that
|
|
18
|
+
directory has no `plexus.project.json`, it creates one that declares the
|
|
19
|
+
project and no image; an existing one is left untouched. It refuses to run
|
|
20
|
+
inside an enclosing PLexus project rather than initializing that project root.
|
|
21
|
+
|
|
22
|
+
Register the image the project needs before the first `open`:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
pharo-agent image add dev --template 'Pharo 13.0 - 64bit (stable)'
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`image add` registers a template-created image with the pinned MCP load policy
|
|
29
|
+
and makes it active when no other image is. `image list`, `image use <id>`,
|
|
30
|
+
and `image remove <id>` cover the rest; an open image must be closed before
|
|
31
|
+
its declaration changes.
|
|
32
|
+
|
|
33
|
+
`open` starts the project image through PLexus, loads PIFS, and mounts its
|
|
34
|
+
projection below `.pharo-agent/mounts`. `close` unmounts the projection and
|
|
35
|
+
closes the PLexus image. `pharo` calls any exposed MCP tool through the same
|
|
36
|
+
project route, while `mcp` exposes that tool surface as a command-scoped stdio
|
|
37
|
+
MCP server.
|
|
38
|
+
|
|
39
|
+
Pass `--mount-root <path>` to `init` to use a different projection root.
|
|
40
|
+
|
|
41
|
+
## Requirements
|
|
42
|
+
|
|
43
|
+
PIFS currently supports the macOS workflow and needs
|
|
44
|
+
[fuse-t](https://www.fuse-t.org/). Its matching daemon is downloaded by PIFS;
|
|
45
|
+
Go is not required at runtime.
|
|
46
|
+
|
|
47
|
+
The CLI pins released MCP and PLexus dependencies. PLexus, in turn, pins the
|
|
48
|
+
MCP release used to prepare the image; PIFS is loaded at its own exact release
|
|
49
|
+
from the image.
|
|
50
|
+
|
|
51
|
+
## Host setup
|
|
52
|
+
|
|
53
|
+
Codex and Claude Code can install the repository as a plugin. Its only global
|
|
54
|
+
skill initializes the current project; it does not add an MCP server or the
|
|
55
|
+
Pharo workflow catalogue to every chat. OpenCode uses the same CLI command
|
|
56
|
+
with `--host opencode`.
|
|
57
|
+
|
|
58
|
+
After initialization, the selected host guide says to read
|
|
59
|
+
`pharo.AGENTS.md` only for Pharo work. That guide points to local workflow
|
|
60
|
+
skills and references on demand. In the chat that runs setup, read that file
|
|
61
|
+
before beginning Pharo work; later chats load the conditional guidance normally.
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evref-bl/pharo-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Terminal-first Pharo integration for coding agents.",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Evref-BL/pharo-agent-plugin.git"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"bin": {
|
|
13
|
+
"pharo-agent": "bin/pharo-agent.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"bin",
|
|
17
|
+
"src",
|
|
18
|
+
"skills",
|
|
19
|
+
"plugin.json",
|
|
20
|
+
".agents",
|
|
21
|
+
".claude-plugin",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=22.12.0"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"check": "node --check bin/pharo-agent.js && node --check src/*.js",
|
|
30
|
+
"package:check": "npm pack --dry-run",
|
|
31
|
+
"test": "node --test"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@evref-bl/pharo-mcp": "1.4.1",
|
|
35
|
+
"@evref-bl/plexus": "0.3.0",
|
|
36
|
+
"@evref-bl/plexus-gateway": "0.3.0",
|
|
37
|
+
"@modelcontextprotocol/sdk": "1.32.1"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/plugin.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
|
+
"name": "pharo-agent",
|
|
4
|
+
"version": "0.2.0",
|
|
5
|
+
"description": "Terminal-first Pharo image setup for coding agents.",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Evref-BL",
|
|
8
|
+
"url": "https://github.com/Evref-BL"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/Evref-BL/pharo-agent-plugin",
|
|
11
|
+
"repository": "https://github.com/Evref-BL/pharo-agent-plugin",
|
|
12
|
+
"license": "Apache-2.0",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"pharo",
|
|
15
|
+
"smalltalk",
|
|
16
|
+
"mcp"
|
|
17
|
+
],
|
|
18
|
+
"extensions": {
|
|
19
|
+
"com.openai": {
|
|
20
|
+
"requires_local_executor": true,
|
|
21
|
+
"onboardingSkill": "./skills/pharo-setup/SKILL.md"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pharo-setup
|
|
3
|
+
description: Enable Pharo image tooling in the current Pharo project.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Run this only when the user asks to enable Pharo tooling in this project.
|
|
7
|
+
|
|
8
|
+
From the project root, run the released CLI that matches this plugin:
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
npx --yes @evref-bl/pharo-agent@0.2.0 init --host codex
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Use `--host claude` in Claude Code and `--host opencode` in OpenCode.
|
|
15
|
+
Do not configure an MCP server in the agent host. The command adds only a
|
|
16
|
+
conditional instruction block, so Pharo guidance stays out of unrelated work.
|
|
17
|
+
|
|
18
|
+
Setup initializes the current directory, creating `plexus.project.json` with
|
|
19
|
+
no image when it is absent. Register one before the first `pharo-agent open`:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
pharo-agent image list
|
|
23
|
+
pharo-agent image add dev --template '<launcher template name>'
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
After setup, read `pharo.AGENTS.md` in this chat before doing Pharo work. A
|
|
27
|
+
new chat picks up the conditional project guidance automatically.
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { initialize } from "./init.js";
|
|
2
|
+
import { addImage, listImages, removeImage, useImage } from "./images.js";
|
|
3
|
+
import { callActiveTool, listActiveTools, serveActiveMcp } from "./gateway.js";
|
|
4
|
+
import { closeAgentImage, openAgentImage } from "./live.js";
|
|
5
|
+
import { findProjectRoot } from "./project.js";
|
|
6
|
+
import { readActive } from "./runtime.js";
|
|
7
|
+
|
|
8
|
+
function parseInit(args) {
|
|
9
|
+
let host;
|
|
10
|
+
let mountRoot;
|
|
11
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
12
|
+
const option = args[index];
|
|
13
|
+
if (option === "--host" || option === "--mount-root") {
|
|
14
|
+
const value = args[index + 1];
|
|
15
|
+
if (!value) {
|
|
16
|
+
throw new Error(`${option} requires a value`);
|
|
17
|
+
}
|
|
18
|
+
if (option === "--host") host = value;
|
|
19
|
+
else mountRoot = value;
|
|
20
|
+
index += 1;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
throw new Error(`Unknown init option: ${option}`);
|
|
24
|
+
}
|
|
25
|
+
if (!host) throw new Error("init requires --host <codex|claude|opencode>");
|
|
26
|
+
return { host, mountRoot };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseImageAdd(args) {
|
|
30
|
+
const [id, ...rest] = args;
|
|
31
|
+
if (!id || id.startsWith("--")) {
|
|
32
|
+
throw new Error("image add requires an image id");
|
|
33
|
+
}
|
|
34
|
+
const parsed = { id };
|
|
35
|
+
const options = { "--template": "template", "--category": "category", "--profile": "profile" };
|
|
36
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
37
|
+
const option = rest[index];
|
|
38
|
+
const key = options[option];
|
|
39
|
+
if (!key) {
|
|
40
|
+
throw new Error(`Unknown image add option: ${option}`);
|
|
41
|
+
}
|
|
42
|
+
const value = rest[index + 1];
|
|
43
|
+
if (!value) {
|
|
44
|
+
throw new Error(`${option} requires a value`);
|
|
45
|
+
}
|
|
46
|
+
parsed[key] = value;
|
|
47
|
+
index += 1;
|
|
48
|
+
}
|
|
49
|
+
if (!parsed.template) {
|
|
50
|
+
throw new Error("image add requires --template <name>");
|
|
51
|
+
}
|
|
52
|
+
return parsed;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function usage() {
|
|
56
|
+
return [
|
|
57
|
+
"Usage:",
|
|
58
|
+
" pharo-agent init --host <codex|claude|opencode> [--mount-root <path>]",
|
|
59
|
+
" pharo-agent image list",
|
|
60
|
+
" pharo-agent image add <id> --template <name> [--category <name>] [--profile <id>]",
|
|
61
|
+
" pharo-agent image use <id>",
|
|
62
|
+
" pharo-agent image remove <id>",
|
|
63
|
+
" pharo-agent open",
|
|
64
|
+
" pharo-agent status",
|
|
65
|
+
" pharo-agent close",
|
|
66
|
+
" pharo-agent mcp",
|
|
67
|
+
" pharo-agent pharo list",
|
|
68
|
+
" pharo-agent pharo <tool> [arguments-json]",
|
|
69
|
+
].join("\n");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function requireNoArguments(command, args) {
|
|
73
|
+
if (args.length > 0) {
|
|
74
|
+
throw new Error(`${command} does not accept arguments`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseToolArguments(value) {
|
|
79
|
+
if (value === undefined) return {};
|
|
80
|
+
let parsed;
|
|
81
|
+
try {
|
|
82
|
+
parsed = JSON.parse(value);
|
|
83
|
+
} catch {
|
|
84
|
+
throw new Error("Tool arguments must be a JSON object.");
|
|
85
|
+
}
|
|
86
|
+
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
|
|
87
|
+
throw new Error("Tool arguments must be a JSON object.");
|
|
88
|
+
}
|
|
89
|
+
return parsed;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function main(args) {
|
|
93
|
+
const [command, ...rest] = args;
|
|
94
|
+
if (command === "init") {
|
|
95
|
+
const result = initialize(parseInit(rest));
|
|
96
|
+
console.log(JSON.stringify({ ok: true, ...result }, null, 2));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (!command || command === "--help" || command === "help") {
|
|
100
|
+
console.log(usage());
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const projectRoot = findProjectRoot();
|
|
104
|
+
if (command === "image") {
|
|
105
|
+
const [subcommand, ...imageArgs] = rest;
|
|
106
|
+
if (subcommand === "list") {
|
|
107
|
+
requireNoArguments("image list", imageArgs);
|
|
108
|
+
console.log(JSON.stringify({ ok: true, ...listImages(projectRoot) }, null, 2));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (subcommand === "add") {
|
|
112
|
+
console.log(JSON.stringify({ ok: true, ...addImage(projectRoot, parseImageAdd(imageArgs)) }, null, 2));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (subcommand === "use" || subcommand === "remove") {
|
|
116
|
+
const [id, ...extra] = imageArgs;
|
|
117
|
+
if (!id || extra.length > 0) {
|
|
118
|
+
throw new Error(`image ${subcommand} requires one image id`);
|
|
119
|
+
}
|
|
120
|
+
const result = subcommand === "use" ? useImage(projectRoot, id) : removeImage(projectRoot, id);
|
|
121
|
+
console.log(JSON.stringify({ ok: true, ...result }, null, 2));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
throw new Error(`Unsupported image command: ${subcommand ?? ""}\n${usage()}`);
|
|
125
|
+
}
|
|
126
|
+
if (command === "open") {
|
|
127
|
+
requireNoArguments(command, rest);
|
|
128
|
+
console.log(JSON.stringify({ ok: true, ...(await openAgentImage(projectRoot)) }, null, 2));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (command === "close") {
|
|
132
|
+
requireNoArguments(command, rest);
|
|
133
|
+
console.log(JSON.stringify({ ok: true, ...(await closeAgentImage(projectRoot)) }, null, 2));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (command === "status") {
|
|
137
|
+
requireNoArguments(command, rest);
|
|
138
|
+
console.log(JSON.stringify({ ok: true, ...readActive(projectRoot) }, null, 2));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (command === "mcp") {
|
|
142
|
+
requireNoArguments(command, rest);
|
|
143
|
+
await serveActiveMcp(projectRoot, readActive(projectRoot));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (command === "pharo") {
|
|
147
|
+
const [toolName, argumentsJson, ...extra] = rest;
|
|
148
|
+
if (toolName === "list") {
|
|
149
|
+
if (argumentsJson || extra.length > 0) throw new Error("pharo list does not accept arguments");
|
|
150
|
+
console.log(JSON.stringify(await listActiveTools(projectRoot, readActive(projectRoot)), null, 2));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (!toolName || extra.length > 0) {
|
|
154
|
+
throw new Error("pharo requires one tool name and an optional JSON object");
|
|
155
|
+
}
|
|
156
|
+
console.log(JSON.stringify(await callActiveTool(
|
|
157
|
+
projectRoot,
|
|
158
|
+
readActive(projectRoot),
|
|
159
|
+
toolName,
|
|
160
|
+
parseToolArguments(argumentsJson),
|
|
161
|
+
), null, 2));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
throw new Error(`Unsupported command: ${command}\n${usage()}`);
|
|
165
|
+
}
|
package/src/gateway.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { PlexusGateway } from "@evref-bl/plexus-gateway";
|
|
2
|
+
import { createGatewayServerWithOptions } from "@evref-bl/plexus-gateway";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { readJson, selectRunningImage } from "./runtime.js";
|
|
5
|
+
|
|
6
|
+
export async function activeGateway(projectRoot, active) {
|
|
7
|
+
const state = readJson(active.statePath);
|
|
8
|
+
const { image, mcpServerId } = selectRunningImage(state, active.imageId);
|
|
9
|
+
if (image.id !== active.imageId || mcpServerId !== active.mcpServerId) {
|
|
10
|
+
throw new Error("The active PLexus image changed; run pharo-agent open again.");
|
|
11
|
+
}
|
|
12
|
+
const gateway = new PlexusGateway({
|
|
13
|
+
pharoScope: {
|
|
14
|
+
projectId: state.projectId,
|
|
15
|
+
workspaceId: state.workspaceId,
|
|
16
|
+
targetId: state.targetId,
|
|
17
|
+
},
|
|
18
|
+
pharoToolSchemaMcpServerId: mcpServerId,
|
|
19
|
+
});
|
|
20
|
+
const registered = await gateway.registerProjectRoute({
|
|
21
|
+
projectRoot,
|
|
22
|
+
statePath: active.statePath,
|
|
23
|
+
state,
|
|
24
|
+
});
|
|
25
|
+
if (!registered.ok) {
|
|
26
|
+
throw new Error(registered.error ?? "Cannot register the PLexus route.");
|
|
27
|
+
}
|
|
28
|
+
return { gateway, mcpServerId };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function callActiveTool(projectRoot, active, toolName, argumentsValue) {
|
|
32
|
+
const { gateway, mcpServerId } = await activeGateway(projectRoot, active);
|
|
33
|
+
const result = await gateway.routeToImage({
|
|
34
|
+
mcpServerId,
|
|
35
|
+
toolName,
|
|
36
|
+
arguments: argumentsValue,
|
|
37
|
+
});
|
|
38
|
+
if (!result.ok) {
|
|
39
|
+
throw new Error(result.error ?? `Pharo tool ${toolName} failed.`);
|
|
40
|
+
}
|
|
41
|
+
return result.data;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function listActiveTools(projectRoot, active) {
|
|
45
|
+
const { gateway } = await activeGateway(projectRoot, active);
|
|
46
|
+
return gateway.refreshPharoTools();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function serveActiveMcp(projectRoot, active) {
|
|
50
|
+
const { gateway, mcpServerId } = await activeGateway(projectRoot, active);
|
|
51
|
+
const server = createGatewayServerWithOptions(gateway, { fixedMcpServerId: mcpServerId });
|
|
52
|
+
await server.connect(new StdioServerTransport());
|
|
53
|
+
}
|
package/src/images.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
configuredImages,
|
|
4
|
+
projectIdentity,
|
|
5
|
+
readProjectConfig,
|
|
6
|
+
writeProjectConfig,
|
|
7
|
+
} from "./project.js";
|
|
8
|
+
import { activePath, readActive } from "./runtime.js";
|
|
9
|
+
|
|
10
|
+
export const defaultLoadScript = "pharo/load-mcp.st";
|
|
11
|
+
|
|
12
|
+
function assertNoOpenImage(projectRoot, imageId, action) {
|
|
13
|
+
if (!fs.existsSync(activePath(projectRoot))) return;
|
|
14
|
+
const active = readActive(projectRoot);
|
|
15
|
+
if (active.imageId === imageId) {
|
|
16
|
+
throw new Error(`Image ${imageId} is open. Run pharo-agent close before ${action}.`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function derivedImageName(projectRoot, imageId) {
|
|
21
|
+
const { name } = projectIdentity(projectRoot);
|
|
22
|
+
return `${name}-{workspaceId}-${imageId}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function listImages(projectRoot) {
|
|
26
|
+
return {
|
|
27
|
+
images: configuredImages(projectRoot).map((image) => ({
|
|
28
|
+
id: image.id,
|
|
29
|
+
imageName: image.imageName,
|
|
30
|
+
active: image.active === true,
|
|
31
|
+
templateName: image.create?.templateName,
|
|
32
|
+
templateCategory: image.create?.templateCategory,
|
|
33
|
+
})),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function addImage(projectRoot, { id, template, category, profile }) {
|
|
38
|
+
const config = readProjectConfig(projectRoot);
|
|
39
|
+
const images = Array.isArray(config.images) ? config.images : [];
|
|
40
|
+
if (images.some((image) => image.id === id)) {
|
|
41
|
+
throw new Error(`Image ${id} is already configured.`);
|
|
42
|
+
}
|
|
43
|
+
const imageName = derivedImageName(projectRoot, id);
|
|
44
|
+
if (images.some((image) => image.imageName === imageName)) {
|
|
45
|
+
throw new Error(`Image name ${imageName} is already configured.`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const image = {
|
|
49
|
+
id,
|
|
50
|
+
imageName,
|
|
51
|
+
active: !images.some((other) => other.active === true),
|
|
52
|
+
mcp: { loadScript: defaultLoadScript, loadPolicy: "ifMissing" },
|
|
53
|
+
create: {
|
|
54
|
+
kind: "template",
|
|
55
|
+
...(profile ? { profileId: profile } : {}),
|
|
56
|
+
templateName: template,
|
|
57
|
+
...(category ? { templateCategory: category } : {}),
|
|
58
|
+
cleanupPolicy: "workspace_cleanup_only",
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
writeProjectConfig(projectRoot, { ...config, images: [...images, image] });
|
|
62
|
+
return { id, imageName, active: image.active, templateName: template };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function useImage(projectRoot, id) {
|
|
66
|
+
const config = readProjectConfig(projectRoot);
|
|
67
|
+
const images = Array.isArray(config.images) ? config.images : [];
|
|
68
|
+
if (!images.some((image) => image.id === id)) {
|
|
69
|
+
throw new Error(`Image ${id} is not configured.`);
|
|
70
|
+
}
|
|
71
|
+
for (const image of images) {
|
|
72
|
+
if (image.active === true && image.id !== id) {
|
|
73
|
+
assertNoOpenImage(projectRoot, image.id, "switching the active image");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
writeProjectConfig(projectRoot, {
|
|
77
|
+
...config,
|
|
78
|
+
images: images.map((image) => ({ ...image, active: image.id === id })),
|
|
79
|
+
});
|
|
80
|
+
return { activeImageId: id };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function removeImage(projectRoot, id) {
|
|
84
|
+
const config = readProjectConfig(projectRoot);
|
|
85
|
+
const images = Array.isArray(config.images) ? config.images : [];
|
|
86
|
+
if (!images.some((image) => image.id === id)) {
|
|
87
|
+
throw new Error(`Image ${id} is not configured.`);
|
|
88
|
+
}
|
|
89
|
+
assertNoOpenImage(projectRoot, id, "removing it");
|
|
90
|
+
writeProjectConfig(projectRoot, {
|
|
91
|
+
...config,
|
|
92
|
+
images: images.filter((image) => image.id !== id),
|
|
93
|
+
});
|
|
94
|
+
return { removedImageId: id };
|
|
95
|
+
}
|
package/src/init.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { templateDirectory as mcpTemplateDirectory } from "@evref-bl/pharo-mcp";
|
|
4
|
+
import {
|
|
5
|
+
createProjectConfig,
|
|
6
|
+
enclosingProjectRoot,
|
|
7
|
+
pluginDirectory,
|
|
8
|
+
projectConfigFileName,
|
|
9
|
+
projectConfigPath,
|
|
10
|
+
} from "./project.js";
|
|
11
|
+
|
|
12
|
+
const managedStart = "<!-- pharo-agent:begin -->";
|
|
13
|
+
const managedEnd = "<!-- pharo-agent:end -->";
|
|
14
|
+
|
|
15
|
+
const hostGuide = {
|
|
16
|
+
claude: "CLAUDE.md",
|
|
17
|
+
codex: "AGENTS.md",
|
|
18
|
+
opencode: "AGENTS.md",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function copyDirectory(source, target) {
|
|
22
|
+
fs.mkdirSync(target, { recursive: true });
|
|
23
|
+
for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
|
|
24
|
+
const sourcePath = path.join(source, entry.name);
|
|
25
|
+
const targetPath = path.join(target, entry.name);
|
|
26
|
+
if (entry.isDirectory()) {
|
|
27
|
+
copyDirectory(sourcePath, targetPath);
|
|
28
|
+
} else {
|
|
29
|
+
fs.copyFileSync(sourcePath, targetPath);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function assertManagedBlockCanBeUpdated(filePath) {
|
|
35
|
+
const current = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
|
|
36
|
+
const start = current.indexOf(managedStart);
|
|
37
|
+
const end = current.indexOf(managedEnd);
|
|
38
|
+
if ((start === -1) !== (end === -1) || (end !== -1 && end < start)) {
|
|
39
|
+
throw new Error(`Cannot update incomplete pharo-agent block in ${filePath}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function updateManagedBlock(filePath, content) {
|
|
44
|
+
assertManagedBlockCanBeUpdated(filePath);
|
|
45
|
+
const current = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
|
|
46
|
+
const start = current.indexOf(managedStart);
|
|
47
|
+
const end = current.indexOf(managedEnd);
|
|
48
|
+
|
|
49
|
+
const block = `${managedStart}\n${content}\n${managedEnd}\n`;
|
|
50
|
+
const updated = start === -1
|
|
51
|
+
? `${current}${current && !current.endsWith("\n") ? "\n" : ""}${block}`
|
|
52
|
+
: `${current.slice(0, start)}${block}${current.slice(end + managedEnd.length).replace(/^\n?/, "")}`;
|
|
53
|
+
fs.writeFileSync(filePath, updated);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function writeIfMissing(filePath, content) {
|
|
57
|
+
if (!fs.existsSync(filePath)) {
|
|
58
|
+
fs.writeFileSync(filePath, content);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function assertGeneratedGuideCanBeUpdated(filePath) {
|
|
63
|
+
if (!fs.existsSync(filePath)) return;
|
|
64
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
65
|
+
if (
|
|
66
|
+
content.trimEnd() !== pharoGuide().trimEnd()
|
|
67
|
+
&& (!content.includes(managedStart) || !content.includes(managedEnd))
|
|
68
|
+
) {
|
|
69
|
+
throw new Error(`Refusing to replace user-owned ${filePath}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function guidance() {
|
|
74
|
+
return `# Pharo work\n\nWhen the task uses Pharo, read \`pharo.AGENTS.md\` before acting.\n`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function pharoGuide() {
|
|
78
|
+
return `# Pharo project guide
|
|
79
|
+
|
|
80
|
+
\`plexus.project.json\` declares this project's images. A freshly initialized
|
|
81
|
+
project declares none; register one before the first \`pharo-agent open\`:
|
|
82
|
+
|
|
83
|
+
\`\`\`sh
|
|
84
|
+
pharo-agent image list
|
|
85
|
+
pharo-agent image add dev --template '<launcher template name>'
|
|
86
|
+
\`\`\`
|
|
87
|
+
|
|
88
|
+
\`image add\` makes the first registered image active. \`pharo-agent image use
|
|
89
|
+
<id>\` switches the active image, and \`pharo-agent image remove <id>\` drops a
|
|
90
|
+
declaration. Close an image before changing its declaration.
|
|
91
|
+
|
|
92
|
+
Use \`pharo-agent open\` before live-image work and edit projected source under
|
|
93
|
+
\`.pharo-agent/mounts\`. Use \`pharo-agent pharo list\` to inspect the complete
|
|
94
|
+
live MCP tool surface and \`pharo-agent pharo <tool> [arguments-json]\` to call
|
|
95
|
+
a tool. \`pharo-agent mcp\` exposes the image as a command-scoped stdio MCP
|
|
96
|
+
server.
|
|
97
|
+
|
|
98
|
+
Image, Tonel, Iceberg, disk Git, and host processes are separate states.
|
|
99
|
+
|
|
100
|
+
## Read first
|
|
101
|
+
|
|
102
|
+
- Before Pharo source changes, read the project guidance. If absent, read the
|
|
103
|
+
local [coding rules](.pharo-agent/references/pharo-coding-rules.md) and
|
|
104
|
+
[coding style](.pharo-agent/references/pharo-coding-style.md).
|
|
105
|
+
- Before loading a project, read
|
|
106
|
+
[.pharo-agent/skills/pharo-project-load/SKILL.md](.pharo-agent/skills/pharo-project-load/SKILL.md).
|
|
107
|
+
- Before cross-version work, read
|
|
108
|
+
[.pharo-agent/skills/pharo-version-compat/SKILL.md](.pharo-agent/skills/pharo-version-compat/SKILL.md).
|
|
109
|
+
- Before exporting image changes or changing Git state, read
|
|
110
|
+
[.pharo-agent/skills/pharo-image-git-handoff/SKILL.md](.pharo-agent/skills/pharo-image-git-handoff/SKILL.md).
|
|
111
|
+
- Before reproducing smalltalkCI or GitHub failures, read
|
|
112
|
+
[.pharo-agent/skills/pharo-ci-repro/SKILL.md](.pharo-agent/skills/pharo-ci-repro/SKILL.md).
|
|
113
|
+
- Before recovering a stopped standalone image, read
|
|
114
|
+
[.pharo-agent/skills/pharo-mcp-recovery/SKILL.md](.pharo-agent/skills/pharo-mcp-recovery/SKILL.md).
|
|
115
|
+
- Before debugger sessions or repairs, read the local
|
|
116
|
+
[debugging guide](.pharo-agent/references/debugging.md).
|
|
117
|
+
|
|
118
|
+
## Workflow
|
|
119
|
+
|
|
120
|
+
1. Inspect the tool schema before acting; do not rely on remembered names.
|
|
121
|
+
2. Prefer the least-mutating dedicated tool; use \`image_evaluate\` only when
|
|
122
|
+
no dedicated tool fits.
|
|
123
|
+
3. Verify identity before mutation, make the narrowest change, then run focused
|
|
124
|
+
tests.
|
|
125
|
+
4. Use the image-to-Git handoff skill before export or commit.
|
|
126
|
+
`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function resolveInitProjectRoot(cwd) {
|
|
130
|
+
const projectRoot = path.resolve(cwd);
|
|
131
|
+
if (fs.existsSync(projectConfigPath(projectRoot))) {
|
|
132
|
+
return { projectRoot, createdProjectConfig: false };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const enclosing = enclosingProjectRoot(projectRoot);
|
|
136
|
+
if (enclosing) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`${projectRoot} is inside the PLexus project at ${enclosing}. `
|
|
139
|
+
+ `Initialize that project root, or remove its ${projectConfigFileName}.`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { projectRoot, createdProjectConfig: true };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function initialize({ host, mountRoot, cwd = process.cwd() }) {
|
|
147
|
+
const guideName = hostGuide[host];
|
|
148
|
+
if (!guideName) {
|
|
149
|
+
throw new Error("--host must be codex, claude, or opencode");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const { projectRoot, createdProjectConfig } = resolveInitProjectRoot(cwd);
|
|
153
|
+
const directory = pluginDirectory(projectRoot);
|
|
154
|
+
const configPath = path.join(directory, "config.json");
|
|
155
|
+
const pharoGuidePath = path.join(projectRoot, "pharo.AGENTS.md");
|
|
156
|
+
assertGeneratedGuideCanBeUpdated(pharoGuidePath);
|
|
157
|
+
assertManagedBlockCanBeUpdated(path.join(projectRoot, guideName));
|
|
158
|
+
if (createdProjectConfig) {
|
|
159
|
+
createProjectConfig(projectRoot);
|
|
160
|
+
}
|
|
161
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
162
|
+
if (!fs.existsSync(configPath) || mountRoot !== undefined) {
|
|
163
|
+
fs.writeFileSync(
|
|
164
|
+
configPath,
|
|
165
|
+
`${JSON.stringify({ mountRoot: mountRoot ?? ".pharo-agent/mounts" }, null, 2)}\n`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
writeIfMissing(path.join(directory, ".gitignore"), "mounts/\nruntime/\n");
|
|
169
|
+
updateManagedBlock(pharoGuidePath, pharoGuide());
|
|
170
|
+
const templates = mcpTemplateDirectory();
|
|
171
|
+
copyDirectory(path.join(templates, "pharo-references"), path.join(directory, "references"));
|
|
172
|
+
copyDirectory(path.join(templates, "pharo-skills"), path.join(directory, "skills"));
|
|
173
|
+
updateManagedBlock(path.join(projectRoot, guideName), guidance());
|
|
174
|
+
|
|
175
|
+
return { projectRoot, guideName, createdProjectConfig };
|
|
176
|
+
}
|
package/src/live.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { closeProject, openProject } from "@evref-bl/plexus";
|
|
4
|
+
import { callActiveTool } from "./gateway.js";
|
|
5
|
+
import { configuredActiveImageId } from "./project.js";
|
|
6
|
+
import {
|
|
7
|
+
activePath,
|
|
8
|
+
clearActive,
|
|
9
|
+
readActive,
|
|
10
|
+
readConfig,
|
|
11
|
+
resolveMountPath,
|
|
12
|
+
selectRunningImage,
|
|
13
|
+
smalltalkString,
|
|
14
|
+
writeActive,
|
|
15
|
+
} from "./runtime.js";
|
|
16
|
+
|
|
17
|
+
export const pifsRelease = "v0.4.0";
|
|
18
|
+
|
|
19
|
+
export function pifsStartCode(mountPath, imageName) {
|
|
20
|
+
return `Metacello new
|
|
21
|
+
baseline: 'PharoImageFS';
|
|
22
|
+
repository: 'github://Evref-BL/pharo-image-fs:${pifsRelease}/src';
|
|
23
|
+
load: 'Core'.
|
|
24
|
+
(PIFSServer
|
|
25
|
+
startOn: 0
|
|
26
|
+
mountAt: ${smalltalkString(mountPath)} asFileReference
|
|
27
|
+
named: ${smalltalkString(imageName)}) port`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function resultPrintString(result) {
|
|
31
|
+
if (!result || typeof result !== "object") return undefined;
|
|
32
|
+
if (typeof result.resultPrintString === "string") return result.resultPrintString;
|
|
33
|
+
for (const value of Object.values(result)) {
|
|
34
|
+
const found = resultPrintString(value);
|
|
35
|
+
if (found) return found;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function openAgentImage(projectRoot) {
|
|
41
|
+
if (fs.existsSync(activePath(projectRoot))) {
|
|
42
|
+
return { alreadyOpen: true, ...readActive(projectRoot) };
|
|
43
|
+
}
|
|
44
|
+
const configuredImageId = configuredActiveImageId(projectRoot);
|
|
45
|
+
const opened = await openProject({ projectRoot, imageIds: [configuredImageId] });
|
|
46
|
+
if (!opened.ok || opened.failures.length > 0) {
|
|
47
|
+
throw new Error(opened.failures.map((failure) => failure.message).join("\n") || "PLexus could not open the project image.");
|
|
48
|
+
}
|
|
49
|
+
const { image, mcpServerId } = selectRunningImage(opened.state, configuredImageId);
|
|
50
|
+
const mountPath = resolveMountPath(projectRoot, readConfig(projectRoot), image.id);
|
|
51
|
+
fs.mkdirSync(path.dirname(mountPath), { recursive: true });
|
|
52
|
+
const active = {
|
|
53
|
+
statePath: opened.statePath,
|
|
54
|
+
imageId: image.id,
|
|
55
|
+
mcpServerId,
|
|
56
|
+
mountPath,
|
|
57
|
+
};
|
|
58
|
+
const result = await callActiveTool(projectRoot, active, "image_evaluate", {
|
|
59
|
+
code: pifsStartCode(mountPath, image.imageName),
|
|
60
|
+
});
|
|
61
|
+
const port = Number(resultPrintString(result));
|
|
62
|
+
if (!Number.isInteger(port) || port < 1) {
|
|
63
|
+
throw new Error("PIFS started but did not report its projection port.");
|
|
64
|
+
}
|
|
65
|
+
writeActive(projectRoot, { ...active, projectionPort: port });
|
|
66
|
+
return { imageId: image.id, mountPath, projectionPort: port };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function closeAgentImage(projectRoot) {
|
|
70
|
+
const active = readActive(projectRoot);
|
|
71
|
+
let projectionError;
|
|
72
|
+
try {
|
|
73
|
+
await callActiveTool(projectRoot, active, "image_evaluate", {
|
|
74
|
+
code: `PIFSServer stopOn: ${active.projectionPort}`,
|
|
75
|
+
});
|
|
76
|
+
} catch (error) {
|
|
77
|
+
projectionError = error;
|
|
78
|
+
}
|
|
79
|
+
const closed = await closeProject({ projectRoot, imageIds: [active.imageId] });
|
|
80
|
+
if (!closed.ok || closed.failures.length > 0) {
|
|
81
|
+
throw new Error(closed.failures.map((failure) => failure.message).join("\n") || "PLexus could not close the project image.");
|
|
82
|
+
}
|
|
83
|
+
clearActive(projectRoot);
|
|
84
|
+
if (projectionError) {
|
|
85
|
+
throw new Error(`Image closed, but PIFS cleanup failed: ${projectionError.message}`);
|
|
86
|
+
}
|
|
87
|
+
return { stoppedImages: closed.stoppedImages.map((image) => image.id) };
|
|
88
|
+
}
|
package/src/project.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const projectConfigFileName = "plexus.project.json";
|
|
5
|
+
|
|
6
|
+
export function findProjectRoot(start = process.cwd()) {
|
|
7
|
+
let directory = path.resolve(start);
|
|
8
|
+
|
|
9
|
+
while (true) {
|
|
10
|
+
if (fs.existsSync(path.join(directory, projectConfigFileName))) {
|
|
11
|
+
return directory;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const parent = path.dirname(directory);
|
|
15
|
+
if (parent === directory) {
|
|
16
|
+
throw new Error("No plexus.project.json found above the current directory");
|
|
17
|
+
}
|
|
18
|
+
directory = parent;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function enclosingProjectRoot(start) {
|
|
23
|
+
const directory = path.resolve(start);
|
|
24
|
+
const parent = path.dirname(directory);
|
|
25
|
+
if (parent === directory) return undefined;
|
|
26
|
+
try {
|
|
27
|
+
return findProjectRoot(parent);
|
|
28
|
+
} catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function projectConfigPath(projectRoot) {
|
|
34
|
+
return path.join(projectRoot, projectConfigFileName);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function pluginDirectory(projectRoot) {
|
|
38
|
+
return path.join(projectRoot, ".pharo-agent");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function projectIdentity(projectRoot) {
|
|
42
|
+
const name = path.basename(path.resolve(projectRoot));
|
|
43
|
+
const id = name
|
|
44
|
+
.replaceAll(/[^A-Za-z0-9._-]+/g, "-")
|
|
45
|
+
.replaceAll(/^-+|-+$/g, "")
|
|
46
|
+
.toLowerCase();
|
|
47
|
+
return { id: id || "pharo-project", name };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function readProjectConfig(projectRoot) {
|
|
51
|
+
const configPath = projectConfigPath(projectRoot);
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
54
|
+
} catch (error) {
|
|
55
|
+
throw new Error(`Cannot read ${configPath}: ${error.message}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function writeProjectConfig(projectRoot, config) {
|
|
60
|
+
fs.writeFileSync(projectConfigPath(projectRoot), `${JSON.stringify(config, null, 2)}\n`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function createProjectConfig(projectRoot) {
|
|
64
|
+
const { id, name } = projectIdentity(projectRoot);
|
|
65
|
+
const config = { id, name, images: [] };
|
|
66
|
+
writeProjectConfig(projectRoot, config);
|
|
67
|
+
return config;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function configuredImages(projectRoot) {
|
|
71
|
+
const config = readProjectConfig(projectRoot);
|
|
72
|
+
return Array.isArray(config.images) ? config.images : [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function configuredActiveImageId(projectRoot) {
|
|
76
|
+
const images = configuredImages(projectRoot).filter((image) => image?.active);
|
|
77
|
+
if (images.length === 0) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
"No active image in plexus.project.json. Register one with pharo-agent image add <id> --template <name>.",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
if (images.length !== 1 || typeof images[0].id !== "string") {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`pharo-agent needs exactly one active image in plexus.project.json; found ${images.length}.`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return images[0].id;
|
|
88
|
+
}
|
package/src/runtime.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pluginDirectory } from "./project.js";
|
|
4
|
+
|
|
5
|
+
export function runtimeDirectory(projectRoot) {
|
|
6
|
+
return path.join(pluginDirectory(projectRoot), "runtime");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function activePath(projectRoot) {
|
|
10
|
+
return path.join(runtimeDirectory(projectRoot), "active.json");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function readJson(filePath) {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
16
|
+
} catch (error) {
|
|
17
|
+
throw new Error(`Cannot read ${filePath}: ${error.message}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function readConfig(projectRoot) {
|
|
22
|
+
return readJson(path.join(pluginDirectory(projectRoot), "config.json"));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function readActive(projectRoot) {
|
|
26
|
+
const filePath = activePath(projectRoot);
|
|
27
|
+
if (!fs.existsSync(filePath)) {
|
|
28
|
+
throw new Error("No active pharo-agent image. Run pharo-agent open first.");
|
|
29
|
+
}
|
|
30
|
+
return readJson(filePath);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function writeActive(projectRoot, value) {
|
|
34
|
+
fs.mkdirSync(runtimeDirectory(projectRoot), { recursive: true });
|
|
35
|
+
fs.writeFileSync(activePath(projectRoot), `${JSON.stringify(value, null, 2)}\n`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function clearActive(projectRoot) {
|
|
39
|
+
fs.rmSync(activePath(projectRoot), { force: true });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function resolveMountPath(projectRoot, config, imageId) {
|
|
43
|
+
const root = config.mountRoot ?? ".pharo-agent/mounts";
|
|
44
|
+
return path.resolve(projectRoot, root, imageId);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function selectRunningImage(state, imageId) {
|
|
48
|
+
const images = (state.images ?? []).filter(
|
|
49
|
+
(image) => image.status === "running" && (imageId === undefined || image.id === imageId),
|
|
50
|
+
);
|
|
51
|
+
if (images.length !== 1) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`pharo-agent needs exactly one running image; PLexus reported ${images.length}.`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
const image = images[0];
|
|
57
|
+
if (!Array.isArray(image.mcpServers) || image.mcpServers.length !== 1) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Image ${image.id} needs exactly one MCP endpoint; PLexus reported ${image.mcpServers?.length ?? 0}.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return { image, mcpServerId: image.mcpServers[0].id };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function smalltalkString(value) {
|
|
66
|
+
return `'${String(value).replaceAll("'", "''")}'`;
|
|
67
|
+
}
|