@evref-bl/pharo-agent 0.4.0 → 0.5.1
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 +20 -57
- package/package.json +4 -4
- package/plugin.json +1 -1
- package/skills/pharo-setup/SKILL.md +6 -3
- package/src/cli.js +29 -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 +109 -23
- package/src/runtime.js +6 -0
package/README.md
CHANGED
|
@@ -1,73 +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
|
-
closed before its declaration is removed.
|
|
33
|
-
|
|
34
|
-
`open` starts an image through PLexus, loads PIFS, and mounts its projection
|
|
35
|
-
below `.pharo-agent/mounts`. `close` unmounts the projection and closes the
|
|
36
|
-
PLexus image. `pharo <tool> [arguments-json]` calls any exposed MCP tool
|
|
37
|
-
through the same project route and `pharo list` names the tools an image
|
|
38
|
-
offers, while `mcp` exposes that tool surface as a command-scoped stdio MCP
|
|
39
|
-
server.
|
|
40
|
-
|
|
41
|
-
Several images can be open at once. `open`, `close`, `pharo` and `mcp` accept
|
|
42
|
-
`--image <id>`; without it the default image answers, so a project with one
|
|
43
|
-
image never mentions an image at all. `open --image <id>` adds an image
|
|
44
|
-
without closing the others, and `close` without `--image` closes every image
|
|
45
|
-
this project opened. `status` takes no `--image`, because it reports every
|
|
46
|
-
open image and which one is the default. Over `mcp`, each Pharo tool takes the
|
|
47
|
-
same optional `image` argument.
|
|
48
|
-
|
|
49
|
-
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.
|
|
50
19
|
|
|
51
20
|
## Requirements
|
|
52
21
|
|
|
53
22
|
Node 22.12 or later.
|
|
54
23
|
|
|
55
|
-
|
|
56
|
-
[fuse-t](https://www.fuse-t.org/). Its
|
|
57
|
-
Go is not required at runtime.
|
|
58
|
-
|
|
59
|
-
The CLI pins released MCP and PLexus dependencies. PLexus, in turn, pins the
|
|
60
|
-
MCP release used to prepare the image; PIFS is loaded at its own exact release
|
|
61
|
-
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.
|
|
62
26
|
|
|
63
|
-
##
|
|
27
|
+
## What the agent gets
|
|
64
28
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
Pharo workflow catalogue to every chat. OpenCode uses the same CLI command
|
|
68
|
-
with `--host opencode`.
|
|
29
|
+
Reference, for reading rather than typing: these are the commands the guidance
|
|
30
|
+
hands to the agent.
|
|
69
31
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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.1",
|
|
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": "2.
|
|
35
|
-
"@evref-bl/plexus": "0.5.
|
|
36
|
-
"@evref-bl/plexus-gateway": "0.5.
|
|
34
|
+
"@evref-bl/pharo-mcp": "2.1.0",
|
|
35
|
+
"@evref-bl/plexus": "0.5.3",
|
|
36
|
+
"@evref-bl/plexus-gateway": "0.5.3",
|
|
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.1 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;
|
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
|
@@ -43,32 +43,58 @@ export function pifsStopCode(projectionPort) {
|
|
|
43
43
|
return `(Smalltalk globals at: #PIFSServer) stopOn: ${projectionPort}`;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
//
|
|
48
|
-
|
|
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") {
|
|
49
50
|
try {
|
|
50
|
-
return
|
|
51
|
+
return namedPrintString(JSON.parse(value));
|
|
51
52
|
} catch {
|
|
52
53
|
return undefined;
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
|
-
if (!
|
|
56
|
-
if (typeof
|
|
57
|
-
for (const
|
|
58
|
-
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);
|
|
59
60
|
if (found) return found;
|
|
60
61
|
}
|
|
61
62
|
return undefined;
|
|
62
63
|
}
|
|
63
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
|
+
}
|
|
64
85
|
|
|
65
|
-
export function
|
|
86
|
+
export function imageCreationPlan(listed, imageId) {
|
|
66
87
|
const summary = listed.images.find((image) => image.imageId === imageId);
|
|
67
88
|
if (!summary) {
|
|
68
89
|
throw new Error(`PLexus does not know image ${imageId}.`);
|
|
69
90
|
}
|
|
70
91
|
// PLexus reports an image it has no runtime state for as declared.
|
|
71
|
-
|
|
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";
|
|
72
98
|
}
|
|
73
99
|
|
|
74
100
|
// The launcher resolves a template name against its default group only, so an
|
|
@@ -165,8 +191,10 @@ export async function openAgentImage(projectRoot, imageId) {
|
|
|
165
191
|
}
|
|
166
192
|
fetchPifsRelease(pifsCloneDirectory(projectRoot));
|
|
167
193
|
const launcher = new ScopedPharoLauncher({ projectRoot });
|
|
168
|
-
const
|
|
194
|
+
const plan = imageCreationPlan(launcher.listImages(), resolved);
|
|
195
|
+
const created = plan !== "none";
|
|
169
196
|
if (created) {
|
|
197
|
+
if (plan === "reset") await launcher.resetImage(resolved);
|
|
170
198
|
await resolveTemplateCategory(projectRoot, launcher, resolved);
|
|
171
199
|
await launcher.createImage(resolved);
|
|
172
200
|
}
|
|
@@ -179,9 +207,18 @@ export async function openAgentImage(projectRoot, imageId) {
|
|
|
179
207
|
try {
|
|
180
208
|
({ image, mcpServerId } = selectRunningImage(opened.state, resolved));
|
|
181
209
|
} catch (error) {
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
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) };
|
|
185
222
|
}
|
|
186
223
|
const mountPath = resolveMountPath(projectRoot, readConfig(projectRoot), image.id);
|
|
187
224
|
fs.mkdirSync(path.dirname(mountPath), { recursive: true });
|
|
@@ -191,8 +228,36 @@ export async function openAgentImage(projectRoot, imageId) {
|
|
|
191
228
|
mcpServerId,
|
|
192
229
|
mountPath,
|
|
193
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) {
|
|
194
259
|
const result = await callImageTool(projectRoot, open, "image_evaluate", {
|
|
195
|
-
code: pifsStartCode(mountPath,
|
|
260
|
+
code: pifsStartCode(open.mountPath, imageName),
|
|
196
261
|
});
|
|
197
262
|
const projectionPort = Number(resultPrintString(result));
|
|
198
263
|
if (!Number.isInteger(projectionPort) || projectionPort < 1) {
|
|
@@ -200,19 +265,22 @@ export async function openAgentImage(projectRoot, imageId) {
|
|
|
200
265
|
`PIFS did not report a projection port: ${JSON.stringify(result).slice(0, 500)}`,
|
|
201
266
|
);
|
|
202
267
|
}
|
|
203
|
-
|
|
204
|
-
return { imageId: image.id, created, mountPath, projectionPort };
|
|
268
|
+
return projectionPort;
|
|
205
269
|
}
|
|
206
270
|
|
|
207
271
|
async function closeOneImage(projectRoot, openImages, imageId) {
|
|
208
272
|
const open = openImages[imageId];
|
|
209
273
|
let projectionError;
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
+
}
|
|
216
284
|
}
|
|
217
285
|
const closed = await runLifecycle(
|
|
218
286
|
() => closeProject({ projectRoot, imageIds: [imageId] }),
|
|
@@ -269,3 +337,21 @@ export function agentImageStatus(projectRoot) {
|
|
|
269
337
|
})),
|
|
270
338
|
};
|
|
271
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
|
|