@evref-bl/pharo-agent 0.16.0 → 0.17.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 +9 -8
- package/package.json +3 -3
- package/plugin.json +1 -1
- package/skills/pharo-feedback/SKILL.md +1 -1
- package/skills/pharo-setup/SKILL.md +1 -1
- package/src/cli.js +65 -39
- package/src/gateway.js +33 -0
- package/src/help.js +13 -13
- package/src/hosts.js +3 -1
- package/src/images.js +3 -2
- package/src/init.js +56 -11
- package/src/live.js +127 -120
- package/src/mcpServer.js +1 -1
- package/src/mcpYaml.js +102 -0
- package/src/runtime.js +17 -1
- package/src/toolCommand.js +216 -0
package/README.md
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
`pharo-agent` gives a coding agent a Pharo Launcher of its own. It can see the
|
|
4
4
|
templates the launcher offers, create images from them, start and stop them,
|
|
5
5
|
and work inside the ones it started: classes, methods, senders, tests,
|
|
6
|
-
refactorings, critiques, and the rest of what a Pharo IDE does. On macOS
|
|
7
|
-
can also read and edit an image's source as files.
|
|
6
|
+
refactorings, critiques, and the rest of what a Pharo IDE does. On macOS and
|
|
7
|
+
Linux it can also read and edit an image's source as files.
|
|
8
8
|
|
|
9
9
|
It works with Claude Code, Codex and OpenCode.
|
|
10
10
|
|
|
@@ -32,10 +32,10 @@ tested together, and gives the agent one command surface over the lot.
|
|
|
32
32
|
- Node 22.12 or later.
|
|
33
33
|
- [Pharo Launcher](https://pharo-project.github.io/pharo-launcher/), which is
|
|
34
34
|
where images and templates come from.
|
|
35
|
-
- For editing source as files: macOS
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
rather than failing.
|
|
35
|
+
- For editing source as files: macOS, which needs nothing more, or Linux with
|
|
36
|
+
FUSE 3 (the `fuse3` package, installed by default on most distributions).
|
|
37
|
+
Its daemon is downloaded for you. Where a projection cannot mount, the image
|
|
38
|
+
starts with its Pharo tools and a warning saying why, rather than failing.
|
|
39
39
|
|
|
40
40
|
The image tools cover Pharo 12 to 15. An image outside that range still
|
|
41
41
|
starts, and says what it will not have.
|
|
@@ -76,10 +76,11 @@ its schemas stay out of the agent's context. The tools belong to the image, so
|
|
|
76
76
|
what exists depends on what that image has loaded. [Tools](docs/tools.md)
|
|
77
77
|
covers both, and serving a different MCP from an image.
|
|
78
78
|
|
|
79
|
-
**Source as files, on macOS.** Starting an image mounts its source below
|
|
79
|
+
**Source as files, on macOS and Linux.** Starting an image mounts its source below
|
|
80
80
|
`.pharo-agent/mounts`. A file edited there is compiled into the live image:
|
|
81
81
|
the files and the tools are two views of one image, not of an export and an
|
|
82
|
-
image.
|
|
82
|
+
image. Setting `PHARO_AGENT_PROJECTION=off` in the environment starts images
|
|
83
|
+
without mounting anything.
|
|
83
84
|
|
|
84
85
|
**Guidance for the agent.** Setting a project up writes a Pharo guide into it,
|
|
85
86
|
and a conditional line in the host's own guide pointing at that guide for
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evref-bl/pharo-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Terminal-first Pharo integration for coding agents.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"test": "node --test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@evref-bl/pharo-mcp": "3.
|
|
36
|
-
"@evref-bl/plexus": "0.15.
|
|
35
|
+
"@evref-bl/pharo-mcp": "3.6.0",
|
|
36
|
+
"@evref-bl/plexus": "0.15.2",
|
|
37
37
|
"@modelcontextprotocol/sdk": "1.32.2"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
package/plugin.json
CHANGED
|
@@ -63,7 +63,7 @@ instead of a new issue.
|
|
|
63
63
|
One issue per problem; findings that share a cause make one issue. Title it
|
|
64
64
|
with what goes wrong, in the tool's own terms. Keep the body short:
|
|
65
65
|
|
|
66
|
-
- **Tool:** its name; `pharo-agent 0.
|
|
66
|
+
- **Tool:** its name; `pharo-agent 0.17.1`; the image's Pharo version, which
|
|
67
67
|
`image_list` gives; the host you run in.
|
|
68
68
|
- **What happened:** what you asked for and what came back, the error quoted
|
|
69
69
|
and trimmed.
|
|
@@ -10,7 +10,7 @@ the host this chat runs in: `--host claude` in Claude Code, `--host codex` in
|
|
|
10
10
|
Codex, `--host opencode` in OpenCode.
|
|
11
11
|
|
|
12
12
|
```sh
|
|
13
|
-
npx --yes @evref-bl/pharo-agent@0.
|
|
13
|
+
npx --yes @evref-bl/pharo-agent@0.17.1 init --host claude
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
It writes the project's Pharo guide, skills and references, and a block in the
|
package/src/cli.js
CHANGED
|
@@ -2,11 +2,18 @@ import { hostChoices, hostList } from "./hosts.js";
|
|
|
2
2
|
import { usage } from "./help.js";
|
|
3
3
|
import { initialize } from "./init.js";
|
|
4
4
|
import { addImage, listImages, removeImage, useImage } from "./images.js";
|
|
5
|
-
import { callImageTool,
|
|
5
|
+
import { callImageTool, listAdvertisedTools, toolContract } from "./gateway.js";
|
|
6
6
|
import { serveImagesMcp } from "./mcpServer.js";
|
|
7
7
|
import { listImageTemplates, startAgentImage, stopAgentImage } from "./live.js";
|
|
8
8
|
import { findProjectRoot } from "./project.js";
|
|
9
9
|
import { readRunningImage } from "./runtime.js";
|
|
10
|
+
import {
|
|
11
|
+
isToolName,
|
|
12
|
+
resultText,
|
|
13
|
+
toolArguments,
|
|
14
|
+
toolListing,
|
|
15
|
+
toolUsage,
|
|
16
|
+
} from "./toolCommand.js";
|
|
10
17
|
|
|
11
18
|
function parseInit(args) {
|
|
12
19
|
let host;
|
|
@@ -97,20 +104,6 @@ function requireNoArguments(command, args) {
|
|
|
97
104
|
}
|
|
98
105
|
}
|
|
99
106
|
|
|
100
|
-
function parseToolArguments(value) {
|
|
101
|
-
if (value === undefined) return {};
|
|
102
|
-
let parsed;
|
|
103
|
-
try {
|
|
104
|
-
parsed = JSON.parse(value);
|
|
105
|
-
} catch {
|
|
106
|
-
throw new Error("Tool arguments must be a JSON object.");
|
|
107
|
-
}
|
|
108
|
-
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
|
|
109
|
-
throw new Error("Tool arguments must be a JSON object.");
|
|
110
|
-
}
|
|
111
|
-
return parsed;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
107
|
// A tool the image answered with an error is still an answer: the MCP result
|
|
115
108
|
// carries the Pharo exception, and a caller wants to read it. Print it as
|
|
116
109
|
// always, and report the failure in the exit status too, so `set -e` and
|
|
@@ -119,8 +112,61 @@ export function exitCodeForToolResult(result) {
|
|
|
119
112
|
return result?.isError === true ? 1 : 0;
|
|
120
113
|
}
|
|
121
114
|
|
|
115
|
+
function takeFlag(args, flag) {
|
|
116
|
+
return { present: args.includes(flag), rest: args.filter((value) => value !== flag) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// `pharo-agent <tool> ...`: the answer's text for a reader, or the MCP result
|
|
120
|
+
// with --json. Its schema is fetched only when the words need reading.
|
|
121
|
+
// The MCP server's own tools manage images; from the shell they are the
|
|
122
|
+
// image commands, which an agent that knows the server may not have read.
|
|
123
|
+
const lifecycleTools = new Set(["image_templates", "image_add", "image_start", "image_list", "image_stop"]);
|
|
124
|
+
|
|
125
|
+
async function runTool([toolName, ...words]) {
|
|
126
|
+
if (lifecycleTools.has(toolName)) {
|
|
127
|
+
throw new Error(`${toolName} is pharo-agent image ${toolName.slice("image_".length)} here.`);
|
|
128
|
+
}
|
|
129
|
+
const { image, rest: withoutImage } = takeImageOption(words);
|
|
130
|
+
const json = takeFlag(withoutImage, "--json");
|
|
131
|
+
const help = takeFlag(json.rest, "--help");
|
|
132
|
+
if (help.present) {
|
|
133
|
+
// Help answers before a project or image exists; only the tool's own
|
|
134
|
+
// arguments need its image.
|
|
135
|
+
try {
|
|
136
|
+
const projectRoot = findProjectRoot();
|
|
137
|
+
console.log(toolUsage(await toolContract(projectRoot, readRunningImage(projectRoot, image), toolName)));
|
|
138
|
+
} catch (error) {
|
|
139
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
140
|
+
console.log(`${usage(["<tool>"])}\n\n${toolName}'s own arguments come from a running image: ${reason}`);
|
|
141
|
+
}
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
const projectRoot = findProjectRoot();
|
|
145
|
+
const running = readRunningImage(projectRoot, image);
|
|
146
|
+
const argumentsValue = help.rest.length === 0 ? {}
|
|
147
|
+
: toolArguments(toolName, help.rest, (await toolContract(projectRoot, running, toolName)).inputSchema);
|
|
148
|
+
const result = await callImageTool(projectRoot, running, toolName, argumentsValue);
|
|
149
|
+
console.log(json.present ? JSON.stringify(result, null, 2) : resultText(result));
|
|
150
|
+
return exitCodeForToolResult(result);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function listTools(words) {
|
|
154
|
+
const { image, rest: withoutImage } = takeImageOption(words);
|
|
155
|
+
const json = takeFlag(withoutImage, "--json");
|
|
156
|
+
requireNoArguments("list", json.rest);
|
|
157
|
+
const projectRoot = findProjectRoot();
|
|
158
|
+
const tools = await listAdvertisedTools(projectRoot, readRunningImage(projectRoot, image));
|
|
159
|
+
console.log(json.present ? JSON.stringify(tools, null, 2) : toolListing(tools));
|
|
160
|
+
}
|
|
161
|
+
|
|
122
162
|
export async function main(args) {
|
|
123
163
|
const [command, ...rest] = args;
|
|
164
|
+
// A snake_case word is an image tool; its own --help reads the tool's
|
|
165
|
+
// schema, and the word help is a value it may take, not a request for
|
|
166
|
+
// the CLI's usage.
|
|
167
|
+
if (command && isToolName(command)) {
|
|
168
|
+
return runTool(args);
|
|
169
|
+
}
|
|
124
170
|
// A caller reaching for help is asking the same question wherever the word
|
|
125
171
|
// lands, and `image add --help` answering that it needs an image id is no
|
|
126
172
|
// answer at all.
|
|
@@ -143,6 +189,10 @@ export async function main(args) {
|
|
|
143
189
|
await serveImagesMcp(findProjectRoot, mcpImage);
|
|
144
190
|
return;
|
|
145
191
|
}
|
|
192
|
+
if (command === "list") {
|
|
193
|
+
await listTools(rest);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
146
196
|
const projectRoot = findProjectRoot();
|
|
147
197
|
if (command === "image") {
|
|
148
198
|
const [subcommand, ...imageArgs] = rest;
|
|
@@ -193,29 +243,5 @@ export async function main(args) {
|
|
|
193
243
|
}
|
|
194
244
|
throw new Error(`Unsupported image command: ${subcommand ?? ""}\n${usage()}`);
|
|
195
245
|
}
|
|
196
|
-
const { image, rest: commandArgs } = takeImageOption(rest);
|
|
197
|
-
if (command === "pharo") {
|
|
198
|
-
const [toolName, argumentsJson, ...extra] = commandArgs;
|
|
199
|
-
if (toolName === "list") {
|
|
200
|
-
if (argumentsJson || extra.length > 0) throw new Error("pharo list does not accept arguments");
|
|
201
|
-
console.log(JSON.stringify(
|
|
202
|
-
await listImageTools(projectRoot, readRunningImage(projectRoot, image)),
|
|
203
|
-
null,
|
|
204
|
-
2,
|
|
205
|
-
));
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
if (!toolName || extra.length > 0) {
|
|
209
|
-
throw new Error("pharo requires one tool name and an optional JSON object");
|
|
210
|
-
}
|
|
211
|
-
const result = await callImageTool(
|
|
212
|
-
projectRoot,
|
|
213
|
-
readRunningImage(projectRoot, image),
|
|
214
|
-
toolName,
|
|
215
|
-
parseToolArguments(argumentsJson),
|
|
216
|
-
);
|
|
217
|
-
console.log(JSON.stringify(result, null, 2));
|
|
218
|
-
return exitCodeForToolResult(result);
|
|
219
|
-
}
|
|
220
246
|
throw new Error(`Unsupported command: ${command}\n${usage()}`);
|
|
221
247
|
}
|
package/src/gateway.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { PlexusGateway } from "@evref-bl/plexus/gateway";
|
|
3
3
|
import { loadProjectState } from "@evref-bl/plexus";
|
|
4
|
+
import { toolContractFrom } from "./mcpYaml.js";
|
|
4
5
|
import {
|
|
5
6
|
selectRunningImage,
|
|
6
7
|
unappliedPharoMcpPin,
|
|
@@ -65,6 +66,38 @@ export async function listImageTools(projectRoot, running) {
|
|
|
65
66
|
return gateway.refreshPharoTools();
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
// PLexus adds its routing handle to every schema it lists; a caller never
|
|
70
|
+
// passes it, so the CLI neither offers nor describes it.
|
|
71
|
+
function withoutRouting(tool) {
|
|
72
|
+
const { required: listed = [], ...schema } = tool.inputSchema ?? {};
|
|
73
|
+
const properties = { ...(schema.properties ?? {}) };
|
|
74
|
+
delete properties.mcpServerId;
|
|
75
|
+
const required = listed.filter((name) => name !== "mcpServerId");
|
|
76
|
+
return { ...tool, inputSchema: { ...schema, properties, ...(required.length ? { required } : {}) } };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function listAdvertisedTools(projectRoot, running) {
|
|
80
|
+
return (await listImageTools(projectRoot, running)).map(withoutRouting);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A tool's contract: from the advertised list when it is there, otherwise
|
|
85
|
+
* from tool_get, which reaches the rest of the catalogue.
|
|
86
|
+
*/
|
|
87
|
+
export async function toolContract(projectRoot, running, toolName) {
|
|
88
|
+
const advertised = (await listAdvertisedTools(projectRoot, running)).find((tool) => tool.name === toolName);
|
|
89
|
+
if (advertised) return advertised;
|
|
90
|
+
const answer = /** @type {{ isError?: boolean, content?: { text?: string }[] }} */ (
|
|
91
|
+
await callImageTool(projectRoot, running, "tool_get", { name: toolName })
|
|
92
|
+
);
|
|
93
|
+
const contract = answer?.isError ? undefined
|
|
94
|
+
: toolContractFrom((answer?.content ?? []).map((part) => part.text ?? "").join("\n"));
|
|
95
|
+
if (!contract) {
|
|
96
|
+
throw new Error(`Image ${running.imageId} has no tool ${toolName}. pharo-agent tool_search <words> finds one.`);
|
|
97
|
+
}
|
|
98
|
+
return withoutRouting(contract);
|
|
99
|
+
}
|
|
100
|
+
|
|
68
101
|
export function withImageSelector(tool, defaultImage) {
|
|
69
102
|
const inputSchema = tool.inputSchema ?? {};
|
|
70
103
|
const properties = { ...(inputSchema.properties ?? {}) };
|
package/src/help.js
CHANGED
|
@@ -36,7 +36,7 @@ const commands = {
|
|
|
36
36
|
"image start": {
|
|
37
37
|
usage: "image start [<id>]",
|
|
38
38
|
description: "Create if needed and start an image, the default one without an id. Others keep running.\n"
|
|
39
|
-
+ "On macOS
|
|
39
|
+
+ "On macOS and Linux its source mounts as files, unless PHARO_AGENT_PROJECTION=off.",
|
|
40
40
|
example: "image start dev",
|
|
41
41
|
},
|
|
42
42
|
"image stop": {
|
|
@@ -44,26 +44,26 @@ const commands = {
|
|
|
44
44
|
description: "Stop an image and its source mount, the default one without an id, or --all of them.",
|
|
45
45
|
example: "image stop dev",
|
|
46
46
|
},
|
|
47
|
+
list: {
|
|
48
|
+
usage: "list [--image <id>] [--json]",
|
|
49
|
+
description: "List a running image's advertised tools, one line each. tool_search finds the rest.",
|
|
50
|
+
example: "list",
|
|
51
|
+
},
|
|
52
|
+
"<tool>": {
|
|
53
|
+
usage: "<tool> [<value>] [--<argument> <value>]... [--image <id>] [--json]",
|
|
54
|
+
description: "Call an image tool. Its arguments are flags, - reads a value from stdin, and\n"
|
|
55
|
+
+ "<tool> --help lists them. Prints the answer's text, or the MCP result with --json.",
|
|
56
|
+
example: "class_get Point",
|
|
57
|
+
},
|
|
47
58
|
mcp: {
|
|
48
59
|
usage: "mcp [--image <id>]",
|
|
49
60
|
description: "Serve this project's images as a stdio MCP server, as the plugin does for a host.",
|
|
50
61
|
example: "mcp --image dev",
|
|
51
62
|
},
|
|
52
|
-
"pharo list": {
|
|
53
|
-
usage: "pharo list [--image <id>]",
|
|
54
|
-
description: "List a running image's advertised tools. tool_search and tool_get find the rest.",
|
|
55
|
-
example: "pharo list",
|
|
56
|
-
},
|
|
57
|
-
pharo: {
|
|
58
|
-
usage: "pharo <tool> [arguments-json] [--image <id>]",
|
|
59
|
-
description: "Call a tool in a running image with a JSON object of arguments, {} by default.\n"
|
|
60
|
-
+ "Read its schema with tool_get first.",
|
|
61
|
-
example: 'pharo tool_get \'{"name":"class_search"}\'',
|
|
62
|
-
},
|
|
63
63
|
};
|
|
64
64
|
|
|
65
65
|
export function usage(args = []) {
|
|
66
|
-
const key = ["image"
|
|
66
|
+
const key = args[0] === "image" && args[1]
|
|
67
67
|
? `${args[0]} ${args[1]}`
|
|
68
68
|
: args[0];
|
|
69
69
|
const command = Object.hasOwn(commands, key) ? commands[key]
|
package/src/hosts.js
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
export const supportedHosts = [
|
|
5
5
|
{ id: "claude", name: "Claude Code", guide: "CLAUDE.md", plugin: true },
|
|
6
6
|
{ id: "codex", name: "Codex", guide: "AGENTS.md", plugin: true },
|
|
7
|
-
|
|
7
|
+
// No plugin manifest to declare the MCP server in: init writes it into the
|
|
8
|
+
// project's own configuration, which OpenCode reads for that project only.
|
|
9
|
+
{ id: "opencode", name: "OpenCode", guide: "AGENTS.md", plugin: false, serverConfig: "opencode.json" },
|
|
8
10
|
];
|
|
9
11
|
|
|
10
12
|
export const hostIds = supportedHosts.map((host) => host.id);
|
package/src/images.js
CHANGED
|
@@ -2,6 +2,7 @@ import { declareProjectImage, removeProjectImage } from "@evref-bl/plexus";
|
|
|
2
2
|
import { configuredImages } from "./project.js";
|
|
3
3
|
import {
|
|
4
4
|
defaultImageId,
|
|
5
|
+
notConfigured,
|
|
5
6
|
readConfig,
|
|
6
7
|
readRunningImages,
|
|
7
8
|
recordedImageIsRunning,
|
|
@@ -93,7 +94,7 @@ export function addImage(projectRoot, request) {
|
|
|
93
94
|
|
|
94
95
|
export function useImage(projectRoot, id) {
|
|
95
96
|
if (!configuredImages(projectRoot).some((image) => image.id === id)) {
|
|
96
|
-
throw
|
|
97
|
+
throw notConfigured(projectRoot, id);
|
|
97
98
|
}
|
|
98
99
|
writeConfig(projectRoot, { ...readConfig(projectRoot), defaultImage: id });
|
|
99
100
|
return { defaultImage: id };
|
|
@@ -101,7 +102,7 @@ export function useImage(projectRoot, id) {
|
|
|
101
102
|
|
|
102
103
|
export function removeImage(projectRoot, id) {
|
|
103
104
|
if (!configuredImages(projectRoot).some((image) => image.id === id)) {
|
|
104
|
-
throw
|
|
105
|
+
throw notConfigured(projectRoot, id);
|
|
105
106
|
}
|
|
106
107
|
assertNotRunning(projectRoot, id, "removing it");
|
|
107
108
|
removeProjectImage(projectRoot, id);
|
package/src/init.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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 {
|
|
4
|
+
import { version } from "./gateway.js";
|
|
5
|
+
import { hostList, hostNamed } from "./hosts.js";
|
|
5
6
|
import {
|
|
6
7
|
createProjectConfig,
|
|
7
8
|
enclosingProjectRoot,
|
|
@@ -67,6 +68,47 @@ function assertGeneratedGuideCanBeUpdated(filePath) {
|
|
|
67
68
|
}
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
// Pinned to the release that wrote the guide, as the setup skill pins its
|
|
72
|
+
// npx command: a host that has no plugin has no pharo-agent on its PATH
|
|
73
|
+
// either. Code mode off, because OpenCode would otherwise hide the tools
|
|
74
|
+
// inside its own execute tool.
|
|
75
|
+
function serverEntry() {
|
|
76
|
+
return {
|
|
77
|
+
type: "local",
|
|
78
|
+
command: ["npx", "--yes", `@evref-bl/pharo-agent@${version}`, "mcp"],
|
|
79
|
+
enabled: true,
|
|
80
|
+
codemode: false,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Only mcp.pharo is ours. A configuration that may hold comments is not
|
|
85
|
+
// rewritten, since parsing and writing it back would drop them.
|
|
86
|
+
function readServerConfig(projectRoot, fileName) {
|
|
87
|
+
const filePath = path.join(projectRoot, fileName);
|
|
88
|
+
const byHand = `add this under "mcp" by hand:\n"pharo": ${JSON.stringify(serverEntry(), null, 2)}`;
|
|
89
|
+
if (!fs.existsSync(filePath)) {
|
|
90
|
+
if (fs.existsSync(`${filePath}c`)) {
|
|
91
|
+
throw new Error(`${filePath}c may hold comments that rewriting it would drop; ${byHand}`);
|
|
92
|
+
}
|
|
93
|
+
return { filePath, config: { $schema: "https://opencode.ai/config.json" } };
|
|
94
|
+
}
|
|
95
|
+
let config;
|
|
96
|
+
try {
|
|
97
|
+
config = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
98
|
+
} catch {
|
|
99
|
+
throw new Error(`${filePath} is not plain JSON, so rewriting it would lose what it holds; ${byHand}`);
|
|
100
|
+
}
|
|
101
|
+
if (config === null || typeof config !== "object" || Array.isArray(config)) {
|
|
102
|
+
throw new Error(`${filePath} is not a JSON object; ${byHand}`);
|
|
103
|
+
}
|
|
104
|
+
return { filePath, config };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function writeServerConfig({ filePath, config }) {
|
|
108
|
+
const mcp = config.mcp !== null && typeof config.mcp === "object" ? config.mcp : {};
|
|
109
|
+
fs.writeFileSync(filePath, `${JSON.stringify({ ...config, mcp: { ...mcp, pharo: serverEntry() } }, null, 2)}\n`);
|
|
110
|
+
}
|
|
111
|
+
|
|
70
112
|
function guidance() {
|
|
71
113
|
return `# Pharo work\n\nWhen the task uses Pharo, read \`pharo.AGENTS.md\` before acting.\n`;
|
|
72
114
|
}
|
|
@@ -116,8 +158,8 @@ elsewhere.
|
|
|
116
158
|
|
|
117
159
|
## Images
|
|
118
160
|
|
|
119
|
-
\`plexus.json\` declares this workspace's images. With the
|
|
120
|
-
\`image_*\` tools; otherwise:
|
|
161
|
+
\`plexus.json\` declares this workspace's images. With the \`pharo\` MCP server,
|
|
162
|
+
use the \`image_*\` tools; otherwise:
|
|
121
163
|
|
|
122
164
|
\`\`\`sh
|
|
123
165
|
pharo-agent image templates
|
|
@@ -133,11 +175,11 @@ session's. Stop an image and remove its declaration when its subject is done.
|
|
|
133
175
|
|
|
134
176
|
## Tools
|
|
135
177
|
|
|
136
|
-
With the
|
|
137
|
-
\`pharo-agent
|
|
138
|
-
|
|
139
|
-
\`
|
|
140
|
-
the source under \`.pharo-agent/mounts\`.
|
|
178
|
+
With the \`pharo\` MCP server, an image's tools are MCP tools. Otherwise run
|
|
179
|
+
one as \`pharo-agent <tool> --<argument> <value>\`: \`<tool> --help\` lists its
|
|
180
|
+
arguments, and a value of \`-\` is read from stdin. \`pharo-agent list\` names
|
|
181
|
+
the common tools; \`tool_search\` finds the rest. On macOS and Linux,
|
|
182
|
+
\`image start\` also mounts the source under \`.pharo-agent/mounts\`.
|
|
141
183
|
|
|
142
184
|
${pharoRouting()}
|
|
143
185
|
`;
|
|
@@ -162,10 +204,11 @@ function resolveInitProjectRoot(cwd) {
|
|
|
162
204
|
|
|
163
205
|
/** @param {{ host: string, mountRoot?: string, cwd?: string }} options */
|
|
164
206
|
export function initialize({ host, mountRoot, cwd = process.cwd() }) {
|
|
165
|
-
const
|
|
166
|
-
if (!
|
|
207
|
+
const hostEntry = hostNamed(host);
|
|
208
|
+
if (!hostEntry) {
|
|
167
209
|
throw new Error(`--host must be ${hostList()}`);
|
|
168
210
|
}
|
|
211
|
+
const guideName = hostEntry.guide;
|
|
169
212
|
|
|
170
213
|
const { projectRoot, createdProjectConfig } = resolveInitProjectRoot(cwd);
|
|
171
214
|
const directory = pluginDirectory(projectRoot);
|
|
@@ -173,6 +216,7 @@ export function initialize({ host, mountRoot, cwd = process.cwd() }) {
|
|
|
173
216
|
const pharoGuidePath = path.join(projectRoot, "pharo.AGENTS.md");
|
|
174
217
|
assertGeneratedGuideCanBeUpdated(pharoGuidePath);
|
|
175
218
|
assertManagedBlockCanBeUpdated(path.join(projectRoot, guideName));
|
|
219
|
+
const serverConfig = hostEntry.serverConfig && readServerConfig(projectRoot, hostEntry.serverConfig);
|
|
176
220
|
if (createdProjectConfig) {
|
|
177
221
|
createProjectConfig(projectRoot);
|
|
178
222
|
}
|
|
@@ -189,6 +233,7 @@ export function initialize({ host, mountRoot, cwd = process.cwd() }) {
|
|
|
189
233
|
copyDirectory(path.join(templates, "pharo-references"), path.join(directory, "references"));
|
|
190
234
|
copyDirectory(path.join(templates, "pharo-skills"), path.join(directory, "skills"));
|
|
191
235
|
updateManagedBlock(path.join(projectRoot, guideName), guidance());
|
|
236
|
+
if (serverConfig) writeServerConfig(serverConfig);
|
|
192
237
|
|
|
193
|
-
return { projectRoot, guideName, createdProjectConfig };
|
|
238
|
+
return { projectRoot, guideName, createdProjectConfig, ...(serverConfig && { serverConfig: hostEntry.serverConfig }) };
|
|
194
239
|
}
|
package/src/live.js
CHANGED
|
@@ -22,47 +22,65 @@ import {
|
|
|
22
22
|
writeRunningImages,
|
|
23
23
|
} from "./runtime.js";
|
|
24
24
|
|
|
25
|
-
export const pifsRelease = "v0.
|
|
25
|
+
export const pifsRelease = "v0.11.0";
|
|
26
26
|
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
// Set to `off`, no image mounts a source projection, wherever it runs.
|
|
28
|
+
const projectionVariable = "PHARO_AGENT_PROJECTION";
|
|
29
|
+
|
|
30
|
+
// The systems PIFS releases a daemon for.
|
|
31
|
+
const projectedPlatforms = ["darwin", "linux"];
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
|
-
* Whether
|
|
34
|
+
* Whether to ask the image for a source projection.
|
|
35
|
+
*
|
|
36
|
+
* Whether this machine can mount is the PIFS daemon's to say: macOS needs
|
|
37
|
+
* nothing, Linux needs fuse3, and the daemon names what is missing. A
|
|
38
|
+
* projection that fails leaves the image running with that reason as its
|
|
39
|
+
* warning, so this answers no only where asking is wasted, on a system PIFS
|
|
40
|
+
* has no daemon for, or forbidden.
|
|
34
41
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* checked, `image start` mounted unconditionally and then stopped the image
|
|
38
|
-
* when the mount failed -- every image on Linux and Windows, and on any Mac
|
|
39
|
-
* without fuse-t. The image and its tools were never the part that could not
|
|
40
|
-
* work.
|
|
42
|
+
* A machine that could mount may still be one that must not, so the
|
|
43
|
+
* environment can turn projections off before anything is asked.
|
|
41
44
|
*
|
|
42
|
-
* @param {{
|
|
45
|
+
* @param {{
|
|
46
|
+
* env?: Record<string, string | undefined>,
|
|
47
|
+
* platform?: string,
|
|
48
|
+
* }} [host]
|
|
43
49
|
*/
|
|
44
50
|
export function projectionAvailability(host = {}) {
|
|
51
|
+
const setting = (host.env ?? process.env)[projectionVariable];
|
|
45
52
|
const platform = host.platform ?? process.platform;
|
|
46
|
-
|
|
47
|
-
|
|
53
|
+
if (setting === "off") {
|
|
54
|
+
return {
|
|
55
|
+
available: false,
|
|
56
|
+
reason: `Source projections are turned off by ${projectionVariable}=off.`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
if (setting) {
|
|
60
|
+
// Any other value fails safe: an opt-out that ignored a typo, or a 0,
|
|
61
|
+
// would mount on the very machine that asked not to be mounted on.
|
|
48
62
|
return {
|
|
49
63
|
available: false,
|
|
50
|
-
reason:
|
|
51
|
-
+ `
|
|
64
|
+
reason: `Source projections are off: ${projectionVariable} takes only off,`
|
|
65
|
+
+ ` and is ${JSON.stringify(setting)}. Unset it to mount them where the platform allows.`,
|
|
52
66
|
};
|
|
53
67
|
}
|
|
54
|
-
if (!
|
|
68
|
+
if (!projectedPlatforms.includes(platform)) {
|
|
55
69
|
return {
|
|
56
70
|
available: false,
|
|
57
|
-
reason:
|
|
58
|
-
+ " Install it with `brew install --cask fuse-t`.",
|
|
71
|
+
reason: `PIFS mounts source projections on macOS and Linux, not on this platform (${platform}).`,
|
|
59
72
|
};
|
|
60
73
|
}
|
|
61
74
|
return { available: true };
|
|
62
75
|
}
|
|
63
76
|
|
|
77
|
+
// The daemon's own reasons end without a period.
|
|
78
|
+
function sentence(text) {
|
|
79
|
+
return /[.!?]$/.test(text) ? text : `${text}.`;
|
|
80
|
+
}
|
|
81
|
+
|
|
64
82
|
export function projectionUnavailableWarning(imageId, reason) {
|
|
65
|
-
return `${reason} Image ${imageId} is running with its Pharo tools and no`
|
|
83
|
+
return `${sentence(reason)} Image ${imageId} is running with its Pharo tools and no`
|
|
66
84
|
+ " source projection; read and edit its code through the tools.";
|
|
67
85
|
}
|
|
68
86
|
|
|
@@ -73,71 +91,67 @@ export function projectionUnavailableWarning(imageId, reason) {
|
|
|
73
91
|
export const pifsSelectors = ["startMountedAt:named:", "stopOn:"];
|
|
74
92
|
|
|
75
93
|
export function pifsStartCode(mountPath, imageName) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
94
|
+
// A projection that cannot mount answers why, as a string, rather than
|
|
95
|
+
// failing the evaluation: the reason is the daemon's, and becomes the
|
|
96
|
+
// warning of an image that keeps running.
|
|
97
|
+
return `[ Metacello new
|
|
98
|
+
baseline: 'PharoImageFS';
|
|
99
|
+
repository: 'github://Evref-BL/pharo-image-fs:${pifsRelease}/src';
|
|
100
|
+
load: 'Core'.
|
|
101
|
+
((Smalltalk globals at: #PIFSServer)
|
|
102
|
+
startMountedAt: ${smalltalkString(mountPath)} asFileReference
|
|
103
|
+
named: ${smalltalkString(imageName)}) port ]
|
|
104
|
+
on: Error
|
|
105
|
+
do: [ :error | error messageText asString ]`;
|
|
87
106
|
}
|
|
88
107
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
spawnSync("diskutil", ["unmount", mountPath], { stdio: "ignore" });
|
|
111
|
-
}
|
|
108
|
+
/**
|
|
109
|
+
* What a projection start answered: the port serving it, why PIFS could not
|
|
110
|
+
* mount, or nothing readable.
|
|
111
|
+
*
|
|
112
|
+
* @param {string | undefined} printString
|
|
113
|
+
* @returns {{ port: number } | { reason: string } | undefined}
|
|
114
|
+
*/
|
|
115
|
+
export function projectionStartOutcome(printString) {
|
|
116
|
+
// The value comes first; MCP appends warnings, the Transcript and a
|
|
117
|
+
// truncation mark on the lines after it.
|
|
118
|
+
const text = printString ?? "";
|
|
119
|
+
const digits = /^(\d+)(?:\n|$)/.exec(text);
|
|
120
|
+
if (digits && Number(digits[1]) > 0) return { port: Number(digits[1]) };
|
|
121
|
+
// A string prints quoted, its quotes doubled, and a long one is cut short
|
|
122
|
+
// of its closing quote, before the truncation mark.
|
|
123
|
+
const quoted = /^'((?:[^']|'')*)(?:'|$)/.exec(text);
|
|
124
|
+
if (!quoted) return undefined;
|
|
125
|
+
const literal = quoted[1].split("\n[truncated")[0].replaceAll("''", "'").trim();
|
|
126
|
+
return {
|
|
127
|
+
reason: `${sentence(literal)} Set ${projectionVariable}=off to start images without trying to mount.`,
|
|
128
|
+
};
|
|
112
129
|
}
|
|
113
130
|
|
|
114
131
|
/**
|
|
115
|
-
* Unmount
|
|
132
|
+
* Unmount the projection of an image that is gone, when nothing else will.
|
|
116
133
|
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
* against the next projection. Unmounting is enough; the daemon exits with its
|
|
122
|
-
* mount.
|
|
134
|
+
* Records written before this one name no PIFS release: fuse-t's daemon
|
|
135
|
+
* served their projection, and it outlives its image. A later daemon
|
|
136
|
+
* unmounts on its own once its image is gone. A path with nothing mounted
|
|
137
|
+
* just fails to unmount.
|
|
123
138
|
*
|
|
124
|
-
* @param {string | undefined}
|
|
125
|
-
* @param {
|
|
126
|
-
* @returns {boolean} whether
|
|
139
|
+
* @param {{ mountPath?: string, pifsRelease?: string, [key: string]: unknown } | undefined} record
|
|
140
|
+
* @param {(mountPath: string) => void} [unmount]
|
|
141
|
+
* @returns {boolean} whether it tried
|
|
127
142
|
*/
|
|
128
|
-
export function
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
(
|
|
132
|
-
|
|
133
|
-
throw new Error(
|
|
134
|
-
`The source projection of an image that is gone is still mounted at ${mountPath},`
|
|
135
|
-
+ ` and could not be unmounted. Run umount ${JSON.stringify(mountPath)}, then try again.`,
|
|
136
|
-
);
|
|
137
|
-
}
|
|
143
|
+
export function releaseFuseTMount(record, unmount = (mountPath) => {
|
|
144
|
+
spawnSync("umount", [mountPath], { stdio: "ignore" });
|
|
145
|
+
}) {
|
|
146
|
+
if (record?.mountPath === undefined || record.pifsRelease !== undefined) return false;
|
|
147
|
+
unmount(record.mountPath);
|
|
138
148
|
return true;
|
|
139
149
|
}
|
|
140
150
|
|
|
151
|
+
export function pifsStopCode(projectionPort) {
|
|
152
|
+
return `(Smalltalk globals at: #PIFSServer) stopOn: ${projectionPort}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
141
155
|
function namedPrintString(value) {
|
|
142
156
|
// Structured results name the print string, somewhere under the envelope,
|
|
143
157
|
// and reach us either as an object or as JSON inside a text part.
|
|
@@ -255,16 +269,12 @@ export async function startAgentImage(projectRoot, imageId) {
|
|
|
255
269
|
// The image this record names has exited. Forget it and start again,
|
|
256
270
|
// rather than hand back an endpoint nothing answers and call it running.
|
|
257
271
|
const { [resolved]: exited, ...remaining } = runningImages;
|
|
272
|
+
releaseFuseTMount(exited);
|
|
258
273
|
runningImages = remaining;
|
|
259
274
|
writeRunningImages(projectRoot, runningImages);
|
|
260
275
|
restarted = true;
|
|
261
276
|
}
|
|
262
277
|
if (projectionAvailability().available) {
|
|
263
|
-
// This project has no live image at this mount path now: none was
|
|
264
|
-
// recorded, or the one recorded has exited. Whatever is still mounted
|
|
265
|
-
// there was left by an image that is gone, and would hold the path
|
|
266
|
-
// against the projection about to be mounted.
|
|
267
|
-
releaseProjectionMount(resolveMountPath(projectRoot, readConfig(projectRoot), resolved));
|
|
268
278
|
fetchPifsRelease(pifsCloneDirectory(projectRoot));
|
|
269
279
|
}
|
|
270
280
|
const launcher = new ScopedPharoLauncher({ projectRoot });
|
|
@@ -297,11 +307,24 @@ export async function startAgentImage(projectRoot, imageId) {
|
|
|
297
307
|
return { ...record, created, warning: pharoMcpWarning(withoutMcp) };
|
|
298
308
|
}
|
|
299
309
|
const unappliedPin = unappliedPharoMcpPin(image);
|
|
300
|
-
const
|
|
301
|
-
|
|
310
|
+
const record = { statePath: started.statePath, imageId: image.id, mcpServerId };
|
|
311
|
+
const mountPath = resolveMountPath(projectRoot, readConfig(projectRoot), image.id);
|
|
312
|
+
const availability = projectionAvailability();
|
|
313
|
+
/** @type {{ port?: number, reason?: string }} */
|
|
314
|
+
let projection = availability;
|
|
315
|
+
if (availability.available) {
|
|
316
|
+
try {
|
|
317
|
+
projection = await startProjection(projectRoot, record, mountPath, image.imageName);
|
|
318
|
+
} catch (error) {
|
|
319
|
+
// Nothing says whether PIFS mounted, so stop the image rather than keep
|
|
320
|
+
// a mount nothing records: its daemon unmounts once the image is gone.
|
|
321
|
+
await stopProjectImage({ projectRoot, imageId: resolved }).catch(() => {});
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (projection.reason !== undefined) {
|
|
302
326
|
// The image and its tools are the point; the projection is the extra this
|
|
303
|
-
// package bundles where the
|
|
304
|
-
const record = { statePath: started.statePath, imageId: image.id, mcpServerId };
|
|
327
|
+
// package bundles where the machine allows it.
|
|
305
328
|
writeRunningImages(projectRoot, { ...runningImages, [resolved]: record });
|
|
306
329
|
return {
|
|
307
330
|
imageId: image.id,
|
|
@@ -313,25 +336,11 @@ export async function startAgentImage(projectRoot, imageId) {
|
|
|
313
336
|
].filter(Boolean).join("\n"),
|
|
314
337
|
};
|
|
315
338
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
imageId: image.id,
|
|
322
|
-
mcpServerId,
|
|
323
|
-
mountPath,
|
|
324
|
-
};
|
|
325
|
-
let projectionPort;
|
|
326
|
-
try {
|
|
327
|
-
projectionPort = await startProjection(projectRoot, record, image.imageName);
|
|
328
|
-
} catch (error) {
|
|
329
|
-
// The projection is half-started at best and nothing records it yet, so
|
|
330
|
-
// stop the image rather than leave an image and a mount nothing owns.
|
|
331
|
-
await stopProjectImage({ projectRoot, imageId: resolved }).catch(() => {});
|
|
332
|
-
throw error;
|
|
333
|
-
}
|
|
334
|
-
writeRunningImages(projectRoot, { ...runningImages, [resolved]: { ...record, projectionPort } });
|
|
339
|
+
const projectionPort = projection.port;
|
|
340
|
+
writeRunningImages(projectRoot, {
|
|
341
|
+
...runningImages,
|
|
342
|
+
[resolved]: { ...record, mountPath, projectionPort, pifsRelease },
|
|
343
|
+
});
|
|
335
344
|
return {
|
|
336
345
|
imageId: image.id,
|
|
337
346
|
created,
|
|
@@ -356,17 +365,16 @@ export function pharoMcpWarning(image) {
|
|
|
356
365
|
+ " drive it with the Pharo command line instead.";
|
|
357
366
|
}
|
|
358
367
|
|
|
359
|
-
async function startProjection(projectRoot, record, imageName) {
|
|
368
|
+
async function startProjection(projectRoot, record, mountPath, imageName) {
|
|
360
369
|
const result = await callImageTool(projectRoot, record, "image_evaluate", {
|
|
361
|
-
code: pifsStartCode(
|
|
370
|
+
code: pifsStartCode(mountPath, imageName),
|
|
362
371
|
});
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
);
|
|
372
|
+
// A refusal is PIFS's own: it stopped what it had started.
|
|
373
|
+
const outcome = projectionStartOutcome(resultPrintString(result));
|
|
374
|
+
if (!outcome) {
|
|
375
|
+
throw new Error(`PIFS did not report a projection port: ${JSON.stringify(result).slice(0, 500)}`);
|
|
368
376
|
}
|
|
369
|
-
return
|
|
377
|
+
return outcome;
|
|
370
378
|
}
|
|
371
379
|
|
|
372
380
|
async function stopOneImage(projectRoot, runningImages, imageId) {
|
|
@@ -374,19 +382,18 @@ async function stopOneImage(projectRoot, runningImages, imageId) {
|
|
|
374
382
|
let projectionError;
|
|
375
383
|
// An image started without MCP has no projection to stop, and no route to
|
|
376
384
|
// ask over either.
|
|
377
|
-
if (record.projectionPort !== undefined) {
|
|
385
|
+
if (record.projectionPort !== undefined && recordedImageIsRunning(record)) {
|
|
378
386
|
try {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
});
|
|
383
|
-
} else {
|
|
384
|
-
// PIFS stops a projection from inside its image, and this one is gone.
|
|
385
|
-
releaseProjectionMount(record.mountPath);
|
|
386
|
-
}
|
|
387
|
+
await callImageTool(projectRoot, record, "image_evaluate", {
|
|
388
|
+
code: pifsStopCode(record.projectionPort),
|
|
389
|
+
});
|
|
387
390
|
} catch (error) {
|
|
388
391
|
projectionError = error;
|
|
389
392
|
}
|
|
393
|
+
} else {
|
|
394
|
+
// A gone image has no one to ask, and its projection's daemon unmounts
|
|
395
|
+
// on its own, unless fuse-t's served it.
|
|
396
|
+
releaseFuseTMount(record);
|
|
390
397
|
}
|
|
391
398
|
const stopped = await runLifecycle(
|
|
392
399
|
() => stopProjectImage({ projectRoot, imageId }),
|
package/src/mcpServer.js
CHANGED
|
@@ -75,7 +75,7 @@ const projectTools = [
|
|
|
75
75
|
name: "image_start",
|
|
76
76
|
description:
|
|
77
77
|
"Start an image, creating it the first time. Its tools are listed once "
|
|
78
|
-
+ "it answers. On macOS
|
|
78
|
+
+ "it answers. On macOS and Linux its source also mounts as files.",
|
|
79
79
|
inputSchema: {
|
|
80
80
|
type: "object",
|
|
81
81
|
properties: {
|
package/src/mcpYaml.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// tool_get answers a tool's contract as YAML, written by MCP's MCPYamlWriter,
|
|
2
|
+
// and through PLexus that text is all there is: no structured content. The
|
|
3
|
+
// writer's subset is small and fixed, so it is read here rather than with a
|
|
4
|
+
// YAML library: block mappings indented by two spaces, `{}` and `[]`, flow
|
|
5
|
+
// lists of scalars, block lists whose entries sit at their key's indent, and
|
|
6
|
+
// scalars that are either plain words or JSON (quoted strings, numbers, true,
|
|
7
|
+
// false, null).
|
|
8
|
+
|
|
9
|
+
function indentOf(line) {
|
|
10
|
+
return line.length - line.trimStart().length;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function scalar(text) {
|
|
14
|
+
const value = text.trim();
|
|
15
|
+
if (value.startsWith("\"")) return JSON.parse(value);
|
|
16
|
+
if (value === "true") return true;
|
|
17
|
+
if (value === "false") return false;
|
|
18
|
+
if (value === "null") return null;
|
|
19
|
+
if (/^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(value)) return Number(value);
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function flowList(text) {
|
|
24
|
+
const inner = text.trim().slice(1, -1);
|
|
25
|
+
const items = [];
|
|
26
|
+
let current = "";
|
|
27
|
+
let quoted = false;
|
|
28
|
+
for (let index = 0; index < inner.length; index += 1) {
|
|
29
|
+
const character = inner[index];
|
|
30
|
+
if (quoted && character === "\\") {
|
|
31
|
+
current += character + inner[index + 1];
|
|
32
|
+
index += 1;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (character === "\"") quoted = !quoted;
|
|
36
|
+
if (!quoted && character === ",") {
|
|
37
|
+
items.push(scalar(current));
|
|
38
|
+
current = "";
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
current += character;
|
|
42
|
+
}
|
|
43
|
+
if (current.trim()) items.push(scalar(current));
|
|
44
|
+
return items;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A key is a plain word or a JSON string, followed by a colon and then a
|
|
48
|
+
// space or the end of the line.
|
|
49
|
+
const keyPattern = /^("(?:[^"\\]|\\.)*"|[^\s"][^:]*?):(?: (.*))?$/;
|
|
50
|
+
|
|
51
|
+
function inlineValue(text) {
|
|
52
|
+
const value = text.trim();
|
|
53
|
+
if (value === "{}") return {};
|
|
54
|
+
if (value.startsWith("[")) return flowList(value);
|
|
55
|
+
return scalar(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseMapping(lines, start, indent) {
|
|
59
|
+
const mapping = {};
|
|
60
|
+
let index = start;
|
|
61
|
+
while (index < lines.length && indentOf(lines[index]) === indent && !lines[index].trimStart().startsWith("- ")) {
|
|
62
|
+
const match = keyPattern.exec(lines[index].slice(indent));
|
|
63
|
+
if (!match) throw new Error(`Unreadable tool contract line: ${lines[index]}`);
|
|
64
|
+
const key = match[1].startsWith("\"") ? JSON.parse(match[1]) : match[1];
|
|
65
|
+
index += 1;
|
|
66
|
+
if (match[2] !== undefined) {
|
|
67
|
+
mapping[key] = inlineValue(match[2]);
|
|
68
|
+
} else if (index < lines.length && indentOf(lines[index]) === indent && lines[index].trimStart().startsWith("- ")) {
|
|
69
|
+
[mapping[key], index] = parseSequence(lines, index, indent);
|
|
70
|
+
} else {
|
|
71
|
+
[mapping[key], index] = parseMapping(lines, index, indent + 2);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return [mapping, index];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseSequence(lines, start, indent) {
|
|
78
|
+
const sequence = [];
|
|
79
|
+
let index = start;
|
|
80
|
+
while (index < lines.length && indentOf(lines[index]) === indent && lines[index].trimStart().startsWith("- ")) {
|
|
81
|
+
const entry = lines[index].slice(indent + 2);
|
|
82
|
+
if (keyPattern.test(entry)) {
|
|
83
|
+
const nested = [`${" ".repeat(indent + 2)}${entry}`, ...lines.slice(index + 1)];
|
|
84
|
+
const [mapping, consumed] = parseMapping(nested, 0, indent + 2);
|
|
85
|
+
sequence.push(mapping);
|
|
86
|
+
index += consumed;
|
|
87
|
+
} else {
|
|
88
|
+
sequence.push(inlineValue(entry));
|
|
89
|
+
index += 1;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return [sequence, index];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The contract tool_get answered, or undefined when the text holds none. */
|
|
96
|
+
export function toolContractFrom(text) {
|
|
97
|
+
const lines = String(text).split("\n");
|
|
98
|
+
const start = lines.findIndex((line) => line === "tool:");
|
|
99
|
+
if (start === -1) return undefined;
|
|
100
|
+
const [contract] = parseMapping(lines, start + 1, 2);
|
|
101
|
+
return contract;
|
|
102
|
+
}
|
package/src/runtime.js
CHANGED
|
@@ -81,10 +81,26 @@ export function defaultImageId(projectRoot) {
|
|
|
81
81
|
);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/** The error for an image id this project does not declare, naming the ones it does. */
|
|
85
|
+
export function notConfigured(projectRoot, imageId) {
|
|
86
|
+
const ids = configuredImages(projectRoot).map((image) => image.id);
|
|
87
|
+
if (ids.length === 0) {
|
|
88
|
+
return new Error(`Image ${imageId} is not configured, and no image is yet. Declare one with pharo-agent image add.`);
|
|
89
|
+
}
|
|
90
|
+
let defaultId;
|
|
91
|
+
try {
|
|
92
|
+
defaultId = defaultImageId(projectRoot);
|
|
93
|
+
} catch {
|
|
94
|
+
defaultId = undefined;
|
|
95
|
+
}
|
|
96
|
+
const named = ids.map((id) => (id === defaultId ? `${id} (the default)` : id)).join(", ");
|
|
97
|
+
return new Error(`Image ${imageId} is not configured. This project declares ${named}.`);
|
|
98
|
+
}
|
|
99
|
+
|
|
84
100
|
export function resolveImageId(projectRoot, imageId) {
|
|
85
101
|
if (imageId === undefined) return defaultImageId(projectRoot);
|
|
86
102
|
if (!configuredImages(projectRoot).some((image) => image.id === imageId)) {
|
|
87
|
-
throw
|
|
103
|
+
throw notConfigured(projectRoot, imageId);
|
|
88
104
|
}
|
|
89
105
|
return imageId;
|
|
90
106
|
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// An image tool called from the shell, the way an agent reaches for it:
|
|
2
|
+
// `pharo-agent class_get Point`, `pharo-agent method_compile --class Point
|
|
3
|
+
// --source - <<'EOF'`. The flags are the tool's own arguments, read from its
|
|
4
|
+
// schema, so a number reaches the image as a number and a boolean flag needs
|
|
5
|
+
// no value. An argument that is itself an object takes JSON as its value.
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
|
|
8
|
+
// The CLI's own options, which no tool argument can shadow.
|
|
9
|
+
export const cliOptions = new Set(["--image", "--json", "--help"]);
|
|
10
|
+
|
|
11
|
+
/** A word the CLI reads as a tool: every image tool is snake_case. */
|
|
12
|
+
export function isToolName(word) {
|
|
13
|
+
return /^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$/.test(word);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function kebab(name) {
|
|
17
|
+
return name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function typesOf(property) {
|
|
21
|
+
return [property?.type].flat().filter(Boolean);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function flagsText(schema) {
|
|
25
|
+
return Object.keys(schema?.properties ?? {}).map((name) => `--${name}`).join(", ") || "none";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The property a bare value fills: the one required, or the only one. */
|
|
29
|
+
export function positionalProperty(schema) {
|
|
30
|
+
const required = schema?.required ?? [];
|
|
31
|
+
if (required.length === 1) return required[0];
|
|
32
|
+
const names = Object.keys(schema?.properties ?? {});
|
|
33
|
+
return required.length === 0 && names.length === 1 ? names[0] : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function propertyNamed(flag, schema, toolName) {
|
|
37
|
+
const properties = schema?.properties ?? {};
|
|
38
|
+
const name = Object.keys(properties).find((each) => each === flag || kebab(each) === flag);
|
|
39
|
+
if (!name) {
|
|
40
|
+
throw new Error(`${toolName} has no argument --${flag}. Its arguments: ${flagsText(schema)}.`);
|
|
41
|
+
}
|
|
42
|
+
return name;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function scalar(value, property, name, toolName) {
|
|
46
|
+
const types = typesOf(property);
|
|
47
|
+
if (types.includes("integer") || types.includes("number")) {
|
|
48
|
+
const number = Number(value);
|
|
49
|
+
if (value.trim() === "" || !Number.isFinite(number)) {
|
|
50
|
+
throw new Error(`${toolName} --${name} takes a number, not ${JSON.stringify(value)}.`);
|
|
51
|
+
}
|
|
52
|
+
return number;
|
|
53
|
+
}
|
|
54
|
+
if (types.includes("boolean") && !types.includes("string")) {
|
|
55
|
+
if (value === "true" || value === "false") return value === "true";
|
|
56
|
+
throw new Error(`${toolName} --${name} is true or false, not ${JSON.stringify(value)}.`);
|
|
57
|
+
}
|
|
58
|
+
if (types.includes("object") && !types.includes("string")) {
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(value);
|
|
61
|
+
} catch {
|
|
62
|
+
throw new Error(`${toolName} --${name} takes a JSON object.`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// An array argument gathers each use of its flag; an argument that takes a
|
|
69
|
+
// string or an array of them, such as method_compile's source, is a string
|
|
70
|
+
// until the flag comes a second time.
|
|
71
|
+
function assign(parsed, name, value, property, toolName) {
|
|
72
|
+
const types = typesOf(property);
|
|
73
|
+
if (types.includes("array")) {
|
|
74
|
+
const item = scalar(value, property.items ?? {}, name, toolName);
|
|
75
|
+
if (!Object.hasOwn(parsed, name)) {
|
|
76
|
+
parsed[name] = types.includes("string") ? value : [item];
|
|
77
|
+
} else {
|
|
78
|
+
parsed[name] = [parsed[name]].flat().concat(item);
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (Object.hasOwn(parsed, name)) {
|
|
83
|
+
throw new Error(`${toolName} takes --${name} once.`);
|
|
84
|
+
}
|
|
85
|
+
parsed[name] = scalar(value, property, name, toolName);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The tool arguments the words give. `schema` is the tool's input schema;
|
|
90
|
+
* `readStdin` answers standard input, for a value written as `-`.
|
|
91
|
+
*/
|
|
92
|
+
export function toolArguments(toolName, words, schema, readStdin = () => fs.readFileSync(0, "utf8")) {
|
|
93
|
+
const properties = schema?.properties ?? {};
|
|
94
|
+
const flags = {};
|
|
95
|
+
let stdinUsed = false;
|
|
96
|
+
const valueOf = (word) => {
|
|
97
|
+
if (word !== "-") return word;
|
|
98
|
+
if (stdinUsed) throw new Error(`${toolName} reads one value from stdin, not two.`);
|
|
99
|
+
stdinUsed = true;
|
|
100
|
+
return readStdin().replace(/\r?\n$/, "");
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
for (let index = 0; index < words.length; index += 1) {
|
|
104
|
+
const word = words[index];
|
|
105
|
+
if (word.startsWith("--") && word.length > 2) {
|
|
106
|
+
const [flag, inline] = word.slice(2).split(/=(.*)/s, 2);
|
|
107
|
+
const negated = flag.startsWith("no-") && !Object.hasOwn(properties, flag)
|
|
108
|
+
&& !Object.keys(properties).some((each) => kebab(each) === flag);
|
|
109
|
+
const name = propertyNamed(negated ? flag.slice(3) : flag, schema, toolName);
|
|
110
|
+
const property = properties[name];
|
|
111
|
+
const types = typesOf(property);
|
|
112
|
+
if (types.includes("boolean") && !types.includes("string")) {
|
|
113
|
+
if (inline !== undefined) assign(flags, name, inline, property, toolName);
|
|
114
|
+
else if (words[index + 1] === "true" || words[index + 1] === "false") {
|
|
115
|
+
assign(flags, name, words[index + 1], property, toolName);
|
|
116
|
+
index += 1;
|
|
117
|
+
} else {
|
|
118
|
+
assign(flags, name, negated ? "false" : "true", property, toolName);
|
|
119
|
+
}
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (negated) throw new Error(`${toolName} --${name} is not a boolean, so --no-${flag.slice(3)} means nothing.`);
|
|
123
|
+
let value = inline;
|
|
124
|
+
if (value === undefined) {
|
|
125
|
+
value = words[index + 1];
|
|
126
|
+
if (value === undefined) throw new Error(`${toolName} --${name} needs a value; - reads it from stdin.`);
|
|
127
|
+
index += 1;
|
|
128
|
+
}
|
|
129
|
+
assign(flags, name, valueOf(value), property, toolName);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const positional = positionalProperty(schema);
|
|
133
|
+
if (!positional) {
|
|
134
|
+
throw new Error(`${toolName} takes its arguments as flags: ${flagsText(schema)}.`);
|
|
135
|
+
}
|
|
136
|
+
// Bare words for a text argument are one text, as a shell splits
|
|
137
|
+
// `tool_search repository commit` into two.
|
|
138
|
+
const types = typesOf(properties[positional]);
|
|
139
|
+
if (Object.hasOwn(flags, positional) && types.includes("string") && !types.includes("array")) {
|
|
140
|
+
flags[positional] = `${flags[positional]} ${valueOf(word)}`;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
assign(flags, positional, valueOf(word), properties[positional], toolName);
|
|
144
|
+
}
|
|
145
|
+
return flags;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** What a tool answered, as the text it wrote for a reader. */
|
|
149
|
+
export function resultText(result) {
|
|
150
|
+
const texts = (result?.content ?? [])
|
|
151
|
+
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
|
152
|
+
.map((part) => part.text);
|
|
153
|
+
if (texts.length > 0) return texts.join("\n");
|
|
154
|
+
return JSON.stringify(result?.structuredContent ?? result ?? {}, null, 2);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function firstLine(text) {
|
|
158
|
+
return String(text ?? "").split("\n", 1)[0].trim();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// What a list has room for: the first sentence, which every tool's
|
|
162
|
+
// description opens with what it does.
|
|
163
|
+
function firstSentence(text) {
|
|
164
|
+
const line = firstLine(text);
|
|
165
|
+
const end = line.search(/\.(\s|$)/);
|
|
166
|
+
return end === -1 ? line : line.slice(0, end + 1);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** One line per tool, then where the rest of the catalogue is. */
|
|
170
|
+
export function toolListing(tools) {
|
|
171
|
+
const width = Math.max(0, ...tools.map((tool) => tool.name.length));
|
|
172
|
+
return [
|
|
173
|
+
...tools.map((tool) => `${tool.name.padEnd(width)} ${firstSentence(tool.description)}`),
|
|
174
|
+
"",
|
|
175
|
+
"These are the advertised tools. pharo-agent tool_search <words> finds the rest;",
|
|
176
|
+
"pharo-agent <tool> --help shows a tool's arguments.",
|
|
177
|
+
].join("\n");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function typeText(property) {
|
|
181
|
+
const types = typesOf(property);
|
|
182
|
+
if (types.includes("array")) {
|
|
183
|
+
return `<${typesOf(property.items).join("|") || "value"}>, repeatable`;
|
|
184
|
+
}
|
|
185
|
+
if (types.includes("boolean")) return "";
|
|
186
|
+
return `<${types.join("|") || "value"}>`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** A tool's own usage, from its schema. */
|
|
190
|
+
export function toolUsage(tool) {
|
|
191
|
+
const schema = tool.inputSchema ?? {};
|
|
192
|
+
const required = new Set(schema.required ?? []);
|
|
193
|
+
const positional = positionalProperty(schema);
|
|
194
|
+
const entries = Object.entries(schema.properties ?? {}).map(([name, property]) => {
|
|
195
|
+
const head = [`--${name}`, typeText(property), required.has(name) ? "(required)" : ""]
|
|
196
|
+
.filter(Boolean).join(" ");
|
|
197
|
+
return { head, description: firstLine(property.description) };
|
|
198
|
+
});
|
|
199
|
+
const width = Math.max(0, ...entries.map((entry) => entry.head.length));
|
|
200
|
+
const usageLine = `pharo-agent ${tool.name}${positional ? ` [<${positional}>]` : ""}`
|
|
201
|
+
+ `${entries.length ? " [--<argument> <value>]..." : ""} [--image <id>] [--json]`;
|
|
202
|
+
return [
|
|
203
|
+
"Usage:",
|
|
204
|
+
` ${usageLine}`,
|
|
205
|
+
"",
|
|
206
|
+
firstLine(tool.description) ? String(tool.description).trim() : "",
|
|
207
|
+
...(entries.length ? ["", "Arguments:", ...entries.map((entry) => ` ${entry.head.padEnd(width)} ${entry.description}`)] : []),
|
|
208
|
+
"",
|
|
209
|
+
[
|
|
210
|
+
positional ? `A bare value is --${positional}.` : "",
|
|
211
|
+
Object.values(schema.properties ?? {}).some((property) => typesOf(property).includes("boolean"))
|
|
212
|
+
? "A flag without a type is true alone, false as --no-<flag>." : "",
|
|
213
|
+
"A value of - is read from stdin. --json prints the MCP result.",
|
|
214
|
+
].filter(Boolean).join(" "),
|
|
215
|
+
].join("\n");
|
|
216
|
+
}
|