@evref-bl/pharo-agent 0.3.10 → 0.5.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/.claude-plugin/plugin.json +1 -1
- package/README.md +21 -53
- package/package.json +4 -4
- package/plugin.json +1 -1
- package/skills/pharo-setup/SKILL.md +6 -3
- package/src/cli.js +32 -6
- package/src/gateway.js +10 -5
- package/src/hosts.js +29 -0
- package/src/images.js +9 -2
- package/src/init.js +30 -17
- package/src/live.js +121 -26
- package/src/runtime.js +6 -0
package/README.md
CHANGED
|
@@ -1,68 +1,36 @@
|
|
|
1
1
|
# pharo-agent
|
|
2
2
|
|
|
3
|
-
`pharo-agent`
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
`pharo-agent` gives a coding agent a live Pharo image: semantic tools over MCP,
|
|
4
|
+
and the image's source projected as files it can read and edit. It works with
|
|
5
|
+
Claude Code, Codex and OpenCode.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## Setup
|
|
8
8
|
|
|
9
|
-
|
|
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.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`:
|
|
9
|
+
In the project directory:
|
|
23
10
|
|
|
24
11
|
```sh
|
|
25
|
-
pharo-agent
|
|
12
|
+
npx --yes @evref-bl/pharo-agent init --host <claude|codex|opencode>
|
|
26
13
|
```
|
|
27
14
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
`open` starts an image through PLexus, loads PIFS, and mounts its projection
|
|
34
|
-
below `.pharo-agent/mounts`. `close` unmounts the projection and closes the
|
|
35
|
-
PLexus image. `pharo` calls any exposed MCP tool through the same project
|
|
36
|
-
route, while `mcp` exposes that tool surface as a command-scoped stdio MCP
|
|
37
|
-
server.
|
|
38
|
-
|
|
39
|
-
Several images can be open at once. `open`, `close`, `status`, `pharo` and
|
|
40
|
-
`mcp` all accept `--image <id>`; without it the default image answers, so a
|
|
41
|
-
project with one image never mentions an image at all. `open --image <id>`
|
|
42
|
-
adds an image without closing the others, and `close` without `--image` closes
|
|
43
|
-
every image this project opened. Over `mcp`, each Pharo tool takes the same
|
|
44
|
-
optional `image` argument.
|
|
45
|
-
|
|
46
|
-
Pass `--mount-root <path>` to `init` to use a different projection root.
|
|
15
|
+
That is all of it. `init` writes the project's Pharo guidance and copies the
|
|
16
|
+
workflow skills that guidance refers to. From there the agent registers the
|
|
17
|
+
image the project needs, opens it and uses its tools by following what was
|
|
18
|
+
written; ask it for Pharo work as you would for anything else.
|
|
47
19
|
|
|
48
20
|
## Requirements
|
|
49
21
|
|
|
50
|
-
|
|
51
|
-
[fuse-t](https://www.fuse-t.org/). Its matching daemon is downloaded by PIFS;
|
|
52
|
-
Go is not required at runtime.
|
|
22
|
+
Node 22.12 or later.
|
|
53
23
|
|
|
54
|
-
The
|
|
55
|
-
|
|
56
|
-
from the image.
|
|
24
|
+
The source projection currently supports macOS and needs
|
|
25
|
+
[fuse-t](https://www.fuse-t.org/). Its daemon is downloaded for you.
|
|
57
26
|
|
|
58
|
-
##
|
|
27
|
+
## What the agent gets
|
|
59
28
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
Pharo workflow catalogue to every chat. OpenCode uses the same CLI command
|
|
63
|
-
with `--host opencode`.
|
|
29
|
+
Reference, for reading rather than typing: these are the commands the guidance
|
|
30
|
+
hands to the agent.
|
|
64
31
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
32
|
+
- [Hosts](docs/hosts.md) — what `init` writes, and installing as a plugin.
|
|
33
|
+
- [Images](docs/images.md) — templates and categories, several images at once,
|
|
34
|
+
Pharo versions outside the MCP range.
|
|
35
|
+
- [Tools](docs/tools.md) — the advertised surface, finding the rest, and the
|
|
36
|
+
stdio bundle.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evref-bl/pharo-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Terminal-first Pharo integration for coding agents.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"test": "node --test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@evref-bl/pharo-mcp": "1.
|
|
35
|
-
"@evref-bl/plexus": "0.
|
|
36
|
-
"@evref-bl/plexus-gateway": "0.
|
|
34
|
+
"@evref-bl/pharo-mcp": "2.1.0",
|
|
35
|
+
"@evref-bl/plexus": "0.5.2",
|
|
36
|
+
"@evref-bl/plexus-gateway": "0.5.2",
|
|
37
37
|
"@modelcontextprotocol/sdk": "1.32.1"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/plugin.json
CHANGED
|
@@ -8,10 +8,11 @@ Run this only when the user asks to enable Pharo tooling in this project.
|
|
|
8
8
|
From the project root, run the released CLI that matches this plugin:
|
|
9
9
|
|
|
10
10
|
```sh
|
|
11
|
-
npx --yes @evref-bl/pharo-agent@0.
|
|
11
|
+
npx --yes @evref-bl/pharo-agent@0.5.0 init --host claude
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
Name the host this chat runs in: `--host claude` in Claude Code,
|
|
15
|
+
`--host codex` in Codex, `--host opencode` in OpenCode.
|
|
15
16
|
Do not configure an MCP server in the agent host. The command adds only a
|
|
16
17
|
conditional instruction block, so Pharo guidance stays out of unrelated work.
|
|
17
18
|
|
|
@@ -19,9 +20,11 @@ Setup initializes the current directory, creating `plexus.json` with
|
|
|
19
20
|
no image when it is absent. Register one before the first `pharo-agent open`:
|
|
20
21
|
|
|
21
22
|
```sh
|
|
22
|
-
pharo-agent image
|
|
23
|
+
pharo-agent image templates
|
|
23
24
|
pharo-agent image add dev --template '<launcher template name>'
|
|
24
25
|
```
|
|
25
26
|
|
|
27
|
+
`image templates` names what the launcher offers, with the category of each.
|
|
28
|
+
|
|
26
29
|
After setup, read `pharo.AGENTS.md` in this chat before doing Pharo work. A
|
|
27
30
|
new chat picks up the conditional project guidance automatically.
|
package/src/cli.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
import { hostChoices, hostList } from "./hosts.js";
|
|
1
2
|
import { initialize } from "./init.js";
|
|
2
3
|
import { addImage, listImages, removeImage, useImage } from "./images.js";
|
|
3
4
|
import { callImageTool, listImageTools, serveImagesMcp } from "./gateway.js";
|
|
4
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
agentImageStatus,
|
|
7
|
+
closeAgentImage,
|
|
8
|
+
listImageTemplates,
|
|
9
|
+
openAgentImage,
|
|
10
|
+
} from "./live.js";
|
|
5
11
|
import { findProjectRoot } from "./project.js";
|
|
6
12
|
import { readOpenImage } from "./runtime.js";
|
|
7
13
|
|
|
@@ -22,7 +28,7 @@ function parseInit(args) {
|
|
|
22
28
|
}
|
|
23
29
|
throw new Error(`Unknown init option: ${option}`);
|
|
24
30
|
}
|
|
25
|
-
if (!host) throw new Error(
|
|
31
|
+
if (!host) throw new Error(`init requires --host <${hostChoices()}>`);
|
|
26
32
|
return { host, mountRoot };
|
|
27
33
|
}
|
|
28
34
|
|
|
@@ -32,7 +38,11 @@ function parseImageAdd(args) {
|
|
|
32
38
|
throw new Error("image add requires an image id");
|
|
33
39
|
}
|
|
34
40
|
const parsed = { id };
|
|
35
|
-
const options = {
|
|
41
|
+
const options = {
|
|
42
|
+
"--template": "template",
|
|
43
|
+
"--category": "category",
|
|
44
|
+
"--profile": "profile",
|
|
45
|
+
};
|
|
36
46
|
for (let index = 0; index < rest.length; index += 1) {
|
|
37
47
|
const option = rest[index];
|
|
38
48
|
const key = options[option];
|
|
@@ -65,9 +75,10 @@ function takeImageOption(args) {
|
|
|
65
75
|
function usage() {
|
|
66
76
|
return [
|
|
67
77
|
"Usage:",
|
|
68
|
-
|
|
78
|
+
` pharo-agent init --host <${hostChoices()}> [--mount-root <path>]`,
|
|
69
79
|
" pharo-agent image list",
|
|
70
|
-
" pharo-agent image
|
|
80
|
+
" pharo-agent image templates",
|
|
81
|
+
" pharo-agent image add <id> --template <name> [--category <name>] [--profile <id>]",
|
|
71
82
|
" pharo-agent image use <id>",
|
|
72
83
|
" pharo-agent image remove <id>",
|
|
73
84
|
" pharo-agent open [--image <id>]",
|
|
@@ -106,7 +117,10 @@ export async function main(args) {
|
|
|
106
117
|
console.log(JSON.stringify({ ok: true, ...result }, null, 2));
|
|
107
118
|
return;
|
|
108
119
|
}
|
|
109
|
-
|
|
120
|
+
// A caller reaching for help is asking the same question wherever the word
|
|
121
|
+
// lands, and `image add --help` answering that it needs an image id is no
|
|
122
|
+
// answer at all.
|
|
123
|
+
if (!command || args.some((value) => value === "--help" || value === "help")) {
|
|
110
124
|
console.log(usage());
|
|
111
125
|
return;
|
|
112
126
|
}
|
|
@@ -118,6 +132,15 @@ export async function main(args) {
|
|
|
118
132
|
console.log(JSON.stringify({ ok: true, ...listImages(projectRoot) }, null, 2));
|
|
119
133
|
return;
|
|
120
134
|
}
|
|
135
|
+
if (subcommand === "templates") {
|
|
136
|
+
requireNoArguments("image templates", imageArgs);
|
|
137
|
+
console.log(JSON.stringify(
|
|
138
|
+
{ ok: true, ...(await listImageTemplates(projectRoot)) },
|
|
139
|
+
null,
|
|
140
|
+
2,
|
|
141
|
+
));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
121
144
|
if (subcommand === "add") {
|
|
122
145
|
console.log(JSON.stringify({ ok: true, ...addImage(projectRoot, parseImageAdd(imageArgs)) }, null, 2));
|
|
123
146
|
return;
|
|
@@ -146,6 +169,9 @@ export async function main(args) {
|
|
|
146
169
|
}
|
|
147
170
|
if (command === "status") {
|
|
148
171
|
requireNoArguments(command, commandArgs);
|
|
172
|
+
if (image) {
|
|
173
|
+
throw new Error("status reports every open image and does not accept --image");
|
|
174
|
+
}
|
|
149
175
|
console.log(JSON.stringify({ ok: true, ...agentImageStatus(projectRoot) }, null, 2));
|
|
150
176
|
return;
|
|
151
177
|
}
|
package/src/gateway.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
1
2
|
import { PlexusGateway } from "@evref-bl/plexus-gateway";
|
|
2
3
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -7,6 +8,10 @@ import {
|
|
|
7
8
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
8
9
|
import { readJson, readOpenImage, selectRunningImage } from "./runtime.js";
|
|
9
10
|
|
|
11
|
+
// Read rather than restate: a hard-coded version is one more place for a
|
|
12
|
+
// release to forget, and the host shows this one to the user.
|
|
13
|
+
export const { version } = createRequire(import.meta.url)("../package.json");
|
|
14
|
+
|
|
10
15
|
export async function imageGateway(projectRoot, open) {
|
|
11
16
|
const state = readJson(open.statePath);
|
|
12
17
|
const { image, mcpServerId } = selectRunningImage(state, open.imageId);
|
|
@@ -75,17 +80,17 @@ export async function serveImagesMcp(projectRoot, defaultImage) {
|
|
|
75
80
|
const defaultOpen = readOpenImage(projectRoot, defaultImage);
|
|
76
81
|
const tools = (await listImageTools(projectRoot, defaultOpen))
|
|
77
82
|
.map((tool) => withImageSelector(tool, defaultImage));
|
|
78
|
-
const toolNames = new Set(tools.map((tool) => tool.name));
|
|
79
83
|
|
|
80
84
|
const server = new Server(
|
|
81
|
-
{ name: "pharo-agent", version
|
|
85
|
+
{ name: "pharo-agent", version },
|
|
82
86
|
{ capabilities: { tools: {} } },
|
|
83
87
|
);
|
|
84
88
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
|
|
85
89
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
90
|
+
// Listed tools are the common ones, not the whole catalogue, and the image
|
|
91
|
+
// answers for any tool it holds. Refusing a name this list does not carry
|
|
92
|
+
// would hide tools that tool_search just found, and the image's own error
|
|
93
|
+
// is the better answer for a name nothing holds.
|
|
89
94
|
const { image, ...argumentsValue } = request.params.arguments ?? {};
|
|
90
95
|
const open = readOpenImage(projectRoot, image);
|
|
91
96
|
return toolResult(
|
package/src/hosts.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The agent hosts this CLI initializes, in the order they are offered. One
|
|
2
|
+
// list, so the usage line, the error message and the documentation cannot
|
|
3
|
+
// drift apart or disagree about which hosts exist.
|
|
4
|
+
export const supportedHosts = [
|
|
5
|
+
{ id: "claude", name: "Claude Code", guide: "CLAUDE.md", plugin: true },
|
|
6
|
+
{ id: "codex", name: "Codex", guide: "AGENTS.md", plugin: true },
|
|
7
|
+
{ id: "opencode", name: "OpenCode", guide: "AGENTS.md", plugin: false },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
export const hostIds = supportedHosts.map((host) => host.id);
|
|
11
|
+
|
|
12
|
+
export function hostNamed(id) {
|
|
13
|
+
return supportedHosts.find((host) => host.id === id);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function guideForHost(id) {
|
|
17
|
+
return hostNamed(id)?.guide;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// "claude|codex|opencode", for the usage line.
|
|
21
|
+
export function hostChoices() {
|
|
22
|
+
return hostIds.join("|");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// "claude, codex, or opencode", for a sentence.
|
|
26
|
+
export function hostList() {
|
|
27
|
+
if (hostIds.length < 2) return hostIds.join("");
|
|
28
|
+
return `${hostIds.slice(0, -1).join(", ")}, or ${hostIds.at(-1)}`;
|
|
29
|
+
}
|
package/src/images.js
CHANGED
|
@@ -37,7 +37,7 @@ export function listImages(projectRoot) {
|
|
|
37
37
|
};
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
export function addImage(projectRoot, { id, template, profile }) {
|
|
40
|
+
export function addImage(projectRoot, { id, template, category, profile }) {
|
|
41
41
|
const config = readProjectConfig(projectRoot);
|
|
42
42
|
const images = Array.isArray(config.images) ? config.images : [];
|
|
43
43
|
if (images.some((image) => image.id === id)) {
|
|
@@ -57,6 +57,7 @@ export function addImage(projectRoot, { id, template, profile }) {
|
|
|
57
57
|
kind: "template",
|
|
58
58
|
...(profile ? { profileId: profile } : {}),
|
|
59
59
|
templateName: template,
|
|
60
|
+
...(category ? { templateCategory: category } : {}),
|
|
60
61
|
cleanupPolicy: "workspace_cleanup_only",
|
|
61
62
|
},
|
|
62
63
|
};
|
|
@@ -66,7 +67,13 @@ export function addImage(projectRoot, { id, template, profile }) {
|
|
|
66
67
|
if (isDefault) {
|
|
67
68
|
writeConfig(projectRoot, { ...pluginConfig, defaultImage: id });
|
|
68
69
|
}
|
|
69
|
-
return {
|
|
70
|
+
return {
|
|
71
|
+
id,
|
|
72
|
+
imageName,
|
|
73
|
+
isDefault,
|
|
74
|
+
templateName: template,
|
|
75
|
+
...(category ? { templateCategory: category } : {}),
|
|
76
|
+
};
|
|
70
77
|
}
|
|
71
78
|
|
|
72
79
|
export function useImage(projectRoot, id) {
|
package/src/init.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { templateDirectory as mcpTemplateDirectory } from "@evref-bl/pharo-mcp";
|
|
4
|
+
import { guideForHost, hostList } from "./hosts.js";
|
|
4
5
|
import {
|
|
5
6
|
createProjectConfig,
|
|
6
7
|
enclosingProjectRoot,
|
|
@@ -9,14 +10,12 @@ import {
|
|
|
9
10
|
projectConfigPath,
|
|
10
11
|
} from "./project.js";
|
|
11
12
|
|
|
13
|
+
// The host guide is the user's file, so our few lines are fenced off inside
|
|
14
|
+
// it. `pharo.AGENTS.md` is ours whole, and needs no fence: one sentence says
|
|
15
|
+
// where it came from, which a reader can act on and init can recognize.
|
|
12
16
|
const managedStart = "<!-- pharo-agent:begin -->";
|
|
13
17
|
const managedEnd = "<!-- pharo-agent:end -->";
|
|
14
|
-
|
|
15
|
-
const hostGuide = {
|
|
16
|
-
claude: "CLAUDE.md",
|
|
17
|
-
codex: "AGENTS.md",
|
|
18
|
-
opencode: "AGENTS.md",
|
|
19
|
-
};
|
|
18
|
+
const generatedBy = "Generated by `pharo-agent init`";
|
|
20
19
|
|
|
21
20
|
function copyDirectory(source, target) {
|
|
22
21
|
for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
|
|
@@ -63,10 +62,7 @@ function writeIfMissing(filePath, content) {
|
|
|
63
62
|
function assertGeneratedGuideCanBeUpdated(filePath) {
|
|
64
63
|
if (!fs.existsSync(filePath)) return;
|
|
65
64
|
const content = fs.readFileSync(filePath, "utf8");
|
|
66
|
-
if (
|
|
67
|
-
content.trimEnd() !== pharoGuide().trimEnd()
|
|
68
|
-
&& (!content.includes(managedStart) || !content.includes(managedEnd))
|
|
69
|
-
) {
|
|
65
|
+
if (!content.includes(generatedBy) && !content.includes(managedStart)) {
|
|
70
66
|
throw new Error(`Refusing to replace user-owned ${filePath}`);
|
|
71
67
|
}
|
|
72
68
|
}
|
|
@@ -78,23 +74,40 @@ function guidance() {
|
|
|
78
74
|
function pharoGuide() {
|
|
79
75
|
return `# Pharo project guide
|
|
80
76
|
|
|
77
|
+
${generatedBy}. Edits here are replaced the next time it runs.
|
|
78
|
+
|
|
81
79
|
\`plexus.json\` declares this project's images. A freshly initialized
|
|
82
80
|
project declares none; register one before the first \`pharo-agent open\`:
|
|
83
81
|
|
|
84
82
|
\`\`\`sh
|
|
83
|
+
pharo-agent image templates
|
|
85
84
|
pharo-agent image list
|
|
86
85
|
pharo-agent image add dev --template '<launcher template name>'
|
|
87
86
|
\`\`\`
|
|
88
87
|
|
|
88
|
+
\`image templates\` names the templates the launcher offers, with the category
|
|
89
|
+
of each; a name two categories offer also needs \`--category <name>\`. Any
|
|
90
|
+
version the launcher offers can be registered, and one outside the MCP range
|
|
91
|
+
opens without MCP tools or a source projection, saying so.
|
|
92
|
+
|
|
89
93
|
\`image add\` makes the first registered image the default. \`pharo-agent image
|
|
90
94
|
use <id>\` changes the default, and \`pharo-agent image remove <id>\` drops a
|
|
91
95
|
declaration. Close an image before removing its declaration.
|
|
92
96
|
|
|
93
97
|
Use \`pharo-agent open\` before live-image work and edit projected source under
|
|
94
|
-
\`.pharo-agent/mounts\`.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
+
\`.pharo-agent/mounts\`. Call a tool with
|
|
99
|
+
\`pharo-agent pharo <tool> [arguments-json]\`, and \`pharo-agent mcp\` exposes the
|
|
100
|
+
image as a command-scoped stdio MCP server.
|
|
101
|
+
|
|
102
|
+
\`pharo-agent pharo list\` names the tools the image advertises, which are the
|
|
103
|
+
common ones rather than all of it. Find the rest through the catalogue rather
|
|
104
|
+
than guessing at a name, then call one the same way as any other:
|
|
105
|
+
|
|
106
|
+
\`\`\`sh
|
|
107
|
+
pharo-agent pharo tool_search '{"query":"repository commit"}'
|
|
108
|
+
pharo-agent pharo tool_get '{"name":"repository_commit"}'
|
|
109
|
+
pharo-agent pharo repository_commit '{"name":"MCP","message":"..."}'
|
|
110
|
+
\`\`\`
|
|
98
111
|
|
|
99
112
|
Several images can be open at once. Every image-facing command takes
|
|
100
113
|
\`--image <id>\`; without it the default image answers. \`pharo-agent open
|
|
@@ -151,9 +164,9 @@ function resolveInitProjectRoot(cwd) {
|
|
|
151
164
|
}
|
|
152
165
|
|
|
153
166
|
export function initialize({ host, mountRoot, cwd = process.cwd() }) {
|
|
154
|
-
const guideName =
|
|
167
|
+
const guideName = guideForHost(host);
|
|
155
168
|
if (!guideName) {
|
|
156
|
-
throw new Error(
|
|
169
|
+
throw new Error(`--host must be ${hostList()}`);
|
|
157
170
|
}
|
|
158
171
|
|
|
159
172
|
const { projectRoot, createdProjectConfig } = resolveInitProjectRoot(cwd);
|
|
@@ -173,7 +186,7 @@ export function initialize({ host, mountRoot, cwd = process.cwd() }) {
|
|
|
173
186
|
);
|
|
174
187
|
}
|
|
175
188
|
writeIfMissing(path.join(directory, ".gitignore"), "mounts/\nruntime/\n");
|
|
176
|
-
|
|
189
|
+
fs.writeFileSync(pharoGuidePath, pharoGuide());
|
|
177
190
|
const templates = mcpTemplateDirectory();
|
|
178
191
|
copyDirectory(path.join(templates, "pharo-references"), path.join(directory, "references"));
|
|
179
192
|
copyDirectory(path.join(templates, "pharo-skills"), path.join(directory, "skills"));
|
package/src/live.js
CHANGED
|
@@ -21,7 +21,13 @@ import {
|
|
|
21
21
|
writeOpenImages,
|
|
22
22
|
} from "./runtime.js";
|
|
23
23
|
|
|
24
|
-
export const pifsRelease = "v0.
|
|
24
|
+
export const pifsRelease = "v0.6.0";
|
|
25
|
+
|
|
26
|
+
// The class-side PIFSServer messages this plugin sends into the image. The
|
|
27
|
+
// release automation checks that a published PIFS still implements them: a pin
|
|
28
|
+
// bump that leaves a removed selector in place passes every test here, because
|
|
29
|
+
// the tests read generated source, and fails only in a live image.
|
|
30
|
+
export const pifsSelectors = ["startMountedAt:named:", "stopOn:"];
|
|
25
31
|
|
|
26
32
|
export function pifsStartCode(mountPath, imageName) {
|
|
27
33
|
return `Metacello new
|
|
@@ -29,37 +35,66 @@ export function pifsStartCode(mountPath, imageName) {
|
|
|
29
35
|
repository: 'github://Evref-BL/pharo-image-fs:${pifsRelease}/src';
|
|
30
36
|
load: 'Core'.
|
|
31
37
|
((Smalltalk globals at: #PIFSServer)
|
|
32
|
-
|
|
33
|
-
mountAt: ${smalltalkString(mountPath)} asFileReference
|
|
38
|
+
startMountedAt: ${smalltalkString(mountPath)} asFileReference
|
|
34
39
|
named: ${smalltalkString(imageName)}) port`;
|
|
35
40
|
}
|
|
36
41
|
|
|
37
|
-
export function
|
|
38
|
-
|
|
39
|
-
|
|
42
|
+
export function pifsStopCode(projectionPort) {
|
|
43
|
+
return `(Smalltalk globals at: #PIFSServer) stopOn: ${projectionPort}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function namedPrintString(value) {
|
|
47
|
+
// Structured results name the print string, somewhere under the envelope,
|
|
48
|
+
// and reach us either as an object or as JSON inside a text part.
|
|
49
|
+
if (typeof value === "string") {
|
|
40
50
|
try {
|
|
41
|
-
return
|
|
51
|
+
return namedPrintString(JSON.parse(value));
|
|
42
52
|
} catch {
|
|
43
53
|
return undefined;
|
|
44
54
|
}
|
|
45
55
|
}
|
|
46
|
-
if (!
|
|
47
|
-
if (typeof
|
|
48
|
-
for (const
|
|
49
|
-
const found =
|
|
56
|
+
if (!value || typeof value !== "object") return undefined;
|
|
57
|
+
if (typeof value.resultPrintString === "string") return value.resultPrintString;
|
|
58
|
+
for (const each of Object.values(value)) {
|
|
59
|
+
const found = namedPrintString(each);
|
|
50
60
|
if (found) return found;
|
|
51
61
|
}
|
|
52
62
|
return undefined;
|
|
53
63
|
}
|
|
54
64
|
|
|
65
|
+
function textPartText(result) {
|
|
66
|
+
if (!result || typeof result !== "object" || !Array.isArray(result.content)) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
const part = result.content.find(
|
|
70
|
+
(candidate) =>
|
|
71
|
+
candidate
|
|
72
|
+
&& candidate.type === "text"
|
|
73
|
+
&& typeof candidate.text === "string",
|
|
74
|
+
);
|
|
75
|
+
const text = part?.text.trim();
|
|
76
|
+
return text === "" ? undefined : text;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function resultPrintString(result) {
|
|
80
|
+
// A server answering with structured content names the print string. In the
|
|
81
|
+
// content-only mode every MCP server defaults to, the text part is the print
|
|
82
|
+
// string, so a bare `60341` arrives as the whole answer.
|
|
83
|
+
return namedPrintString(result) ?? textPartText(result);
|
|
84
|
+
}
|
|
55
85
|
|
|
56
|
-
export function
|
|
86
|
+
export function imageCreationPlan(listed, imageId) {
|
|
57
87
|
const summary = listed.images.find((image) => image.imageId === imageId);
|
|
58
88
|
if (!summary) {
|
|
59
89
|
throw new Error(`PLexus does not know image ${imageId}.`);
|
|
60
90
|
}
|
|
61
91
|
// PLexus reports an image it has no runtime state for as declared.
|
|
62
|
-
|
|
92
|
+
if (summary.status === "declared") return "create";
|
|
93
|
+
// A creation that failed leaves runtime state that blocks both a retry and a
|
|
94
|
+
// fresh declaration of the same id, so the id would be spent for good. Clear
|
|
95
|
+
// it and create again.
|
|
96
|
+
if (summary.status === "failed") return "reset";
|
|
97
|
+
return "none";
|
|
63
98
|
}
|
|
64
99
|
|
|
65
100
|
// The launcher resolves a template name against its default group only, so an
|
|
@@ -156,8 +191,10 @@ export async function openAgentImage(projectRoot, imageId) {
|
|
|
156
191
|
}
|
|
157
192
|
fetchPifsRelease(pifsCloneDirectory(projectRoot));
|
|
158
193
|
const launcher = new ScopedPharoLauncher({ projectRoot });
|
|
159
|
-
const
|
|
194
|
+
const plan = imageCreationPlan(launcher.listImages(), resolved);
|
|
195
|
+
const created = plan !== "none";
|
|
160
196
|
if (created) {
|
|
197
|
+
if (plan === "reset") await launcher.resetImage(resolved);
|
|
161
198
|
await resolveTemplateCategory(projectRoot, launcher, resolved);
|
|
162
199
|
await launcher.createImage(resolved);
|
|
163
200
|
}
|
|
@@ -170,9 +207,18 @@ export async function openAgentImage(projectRoot, imageId) {
|
|
|
170
207
|
try {
|
|
171
208
|
({ image, mcpServerId } = selectRunningImage(opened.state, resolved));
|
|
172
209
|
} catch (error) {
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
210
|
+
// An image whose Pharo version is outside the MCP contract still runs, and
|
|
211
|
+
// is still worth having: the caller asked for that version, and the tools
|
|
212
|
+
// it cannot offer are the only thing missing. Keep it, and say what is gone.
|
|
213
|
+
const withoutMcp = unsupportedRunningImage(opened.state, resolved);
|
|
214
|
+
if (!withoutMcp) {
|
|
215
|
+
// The image is running but unusable to us, so do not leave it behind.
|
|
216
|
+
await closeProject({ projectRoot, imageIds: [resolved] }).catch(() => {});
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
const open = { statePath: opened.statePath, imageId: resolved, mcp: false };
|
|
220
|
+
writeOpenImages(projectRoot, { ...openImages, [resolved]: open });
|
|
221
|
+
return { ...open, created, warning: pharoMcpWarning(withoutMcp) };
|
|
176
222
|
}
|
|
177
223
|
const mountPath = resolveMountPath(projectRoot, readConfig(projectRoot), image.id);
|
|
178
224
|
fs.mkdirSync(path.dirname(mountPath), { recursive: true });
|
|
@@ -182,8 +228,36 @@ export async function openAgentImage(projectRoot, imageId) {
|
|
|
182
228
|
mcpServerId,
|
|
183
229
|
mountPath,
|
|
184
230
|
};
|
|
231
|
+
let projectionPort;
|
|
232
|
+
try {
|
|
233
|
+
projectionPort = await startProjection(projectRoot, open, image.imageName);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
// The projection is half-started at best and nothing records it yet, so
|
|
236
|
+
// close the image rather than leave an image and a mount nothing owns.
|
|
237
|
+
await closeProject({ projectRoot, imageIds: [resolved] }).catch(() => {});
|
|
238
|
+
throw error;
|
|
239
|
+
}
|
|
240
|
+
writeOpenImages(projectRoot, { ...openImages, [resolved]: { ...open, projectionPort } });
|
|
241
|
+
return { imageId: image.id, created, mountPath, projectionPort };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function unsupportedRunningImage(state, imageId) {
|
|
245
|
+
const image = (state.images ?? []).find(
|
|
246
|
+
(candidate) => candidate.id === imageId && candidate.status === "running",
|
|
247
|
+
);
|
|
248
|
+
return image?.pharoMcpContract?.status === "unsupported" ? image : undefined;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function pharoMcpWarning(image) {
|
|
252
|
+
const reason = image.pharoMcpContract?.reason
|
|
253
|
+
?? "This image Pharo version is outside the supported Pharo MCP range.";
|
|
254
|
+
return `${reason} Image ${image.id} is open without MCP tools or a source projection;`
|
|
255
|
+
+ " drive it with the Pharo command line instead.";
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function startProjection(projectRoot, open, imageName) {
|
|
185
259
|
const result = await callImageTool(projectRoot, open, "image_evaluate", {
|
|
186
|
-
code: pifsStartCode(mountPath,
|
|
260
|
+
code: pifsStartCode(open.mountPath, imageName),
|
|
187
261
|
});
|
|
188
262
|
const projectionPort = Number(resultPrintString(result));
|
|
189
263
|
if (!Number.isInteger(projectionPort) || projectionPort < 1) {
|
|
@@ -191,19 +265,22 @@ export async function openAgentImage(projectRoot, imageId) {
|
|
|
191
265
|
`PIFS did not report a projection port: ${JSON.stringify(result).slice(0, 500)}`,
|
|
192
266
|
);
|
|
193
267
|
}
|
|
194
|
-
|
|
195
|
-
return { imageId: image.id, created, mountPath, projectionPort };
|
|
268
|
+
return projectionPort;
|
|
196
269
|
}
|
|
197
270
|
|
|
198
271
|
async function closeOneImage(projectRoot, openImages, imageId) {
|
|
199
272
|
const open = openImages[imageId];
|
|
200
273
|
let projectionError;
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
274
|
+
// An image opened without MCP has no projection to stop, and no route to
|
|
275
|
+
// ask over either.
|
|
276
|
+
if (open.projectionPort !== undefined) {
|
|
277
|
+
try {
|
|
278
|
+
await callImageTool(projectRoot, open, "image_evaluate", {
|
|
279
|
+
code: pifsStopCode(open.projectionPort),
|
|
280
|
+
});
|
|
281
|
+
} catch (error) {
|
|
282
|
+
projectionError = error;
|
|
283
|
+
}
|
|
207
284
|
}
|
|
208
285
|
const closed = await runLifecycle(
|
|
209
286
|
() => closeProject({ projectRoot, imageIds: [imageId] }),
|
|
@@ -260,3 +337,21 @@ export function agentImageStatus(projectRoot) {
|
|
|
260
337
|
})),
|
|
261
338
|
};
|
|
262
339
|
}
|
|
340
|
+
|
|
341
|
+
export async function listImageTemplates(projectRoot) {
|
|
342
|
+
// `image add --template` takes a name the launcher must already know, and a
|
|
343
|
+
// name that lands in two categories needs the category as well, so answer
|
|
344
|
+
// both rather than leave the caller guessing a magic string.
|
|
345
|
+
const listed = await new ScopedPharoLauncher({ projectRoot }).listTemplates();
|
|
346
|
+
const counts = new Map();
|
|
347
|
+
for (const template of listed.templates) {
|
|
348
|
+
counts.set(template.name, (counts.get(template.name) ?? 0) + 1);
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
templates: listed.templates.map((template) => ({
|
|
352
|
+
name: template.name,
|
|
353
|
+
category: template.templateCategory,
|
|
354
|
+
...(counts.get(template.name) > 1 ? { categoryRequired: true } : {}),
|
|
355
|
+
})),
|
|
356
|
+
};
|
|
357
|
+
}
|
package/src/runtime.js
CHANGED
|
@@ -90,6 +90,12 @@ export function readOpenImage(projectRoot, imageId) {
|
|
|
90
90
|
`Image ${resolved} is not open. Run pharo-agent open${imageId ? ` --image ${resolved}` : ""} first.`,
|
|
91
91
|
);
|
|
92
92
|
}
|
|
93
|
+
if (open.mcp === false) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`Image ${resolved} is open without MCP, so it offers no Pharo tools. `
|
|
96
|
+
+ "Register an image on a Pharo version the MCP contract supports to use them.",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
93
99
|
return open;
|
|
94
100
|
}
|
|
95
101
|
|