@evref-bl/pharo-agent 0.16.0 → 0.17.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pharo-agent",
3
3
  "displayName": "Pharo Agent",
4
- "version": "0.16.0",
4
+ "version": "0.17.0",
5
5
  "description": "Terminal-first Pharo image setup for coding agents.",
6
6
  "author": {
7
7
  "name": "Evref-BL",
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 it
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 with [fuse-t](https://www.fuse-t.org/),
36
- installed with `brew install --cask fuse-t`. Its daemon is downloaded for
37
- you. Everywhere else images start with their Pharo tools and no projection,
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.16.0",
3
+ "version": "0.17.0",
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.5.0",
36
- "@evref-bl/plexus": "0.15.0",
35
+ "@evref-bl/pharo-mcp": "3.6.0",
36
+ "@evref-bl/plexus": "0.15.1",
37
37
  "@modelcontextprotocol/sdk": "1.32.2"
38
38
  },
39
39
  "devDependencies": {
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "pharo-agent",
4
- "version": "0.16.0",
4
+ "version": "0.17.0",
5
5
  "description": "Terminal-first Pharo image setup for coding agents.",
6
6
  "author": {
7
7
  "name": "Evref-BL",
@@ -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.16.0`; the image's Pharo version, which
66
+ - **Tool:** its name; `pharo-agent 0.17.0`; 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.16.0 init --host claude
13
+ npx --yes @evref-bl/pharo-agent@0.17.0 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, listImageTools } from "./gateway.js";
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 with fuse-t its source mounts as files.",
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", "pharo"].includes(args[0]) && args[1]
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
- { id: "opencode", name: "OpenCode", guide: "AGENTS.md", plugin: false },
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 new Error(`Image ${id} is not configured.`);
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 new Error(`Image ${id} is not configured.`);
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 { guideForHost, hostList } from "./hosts.js";
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 plugin, use 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 plugin, an image's tools are MCP tools. Otherwise call one with
137
- \`pharo-agent pharo <tool> [arguments-json] [--image <id>]\`, or serve them
138
- with \`pharo-agent mcp\`. An image lists its common tools; \`tool_search\` and
139
- \`tool_get\` find the rest. On macOS with fuse-t, \`image start\` also mounts
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 guideName = guideForHost(host);
166
- if (!guideName) {
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.10.3";
25
+ export const pifsRelease = "v0.11.0";
26
26
 
27
- // fuse-t ships as a macOS installer package, so it lands in /usr/local
28
- // whatever Homebrew's prefix is. The unversioned symlink is what the daemon
29
- // links against.
30
- export const fuseLibraryPath = "/usr/local/lib/libfuse-t.dylib";
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 a source projection can be mounted here.
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
- * PIFS needs macOS and fuse-t, which is a kernel-adjacent install the user
36
- * has to approve. PLexus and the Pharo MCP run anywhere, so until this was
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 {{ platform?: string, exists?: (path: string) => boolean }} [host]
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
- const exists = host.exists ?? fs.existsSync;
47
- if (platform !== "darwin") {
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: "A source projection needs macOS and fuse-t, which this platform"
51
- + ` (${platform}) cannot provide.`,
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 (!exists(fuseLibraryPath)) {
68
+ if (!projectedPlatforms.includes(platform)) {
55
69
  return {
56
70
  available: false,
57
- reason: "A source projection needs fuse-t, which is not installed."
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
- return `Metacello new
77
- baseline: 'PharoImageFS';
78
- repository: 'github://Evref-BL/pharo-image-fs:${pifsRelease}/src';
79
- load: 'Core'.
80
- ((Smalltalk globals at: #PIFSServer)
81
- startMountedAt: ${smalltalkString(mountPath)} asFileReference
82
- named: ${smalltalkString(imageName)}) port`;
83
- }
84
-
85
- export function pifsStopCode(projectionPort) {
86
- return `(Smalltalk globals at: #PIFSServer) stopOn: ${projectionPort}`;
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
- // `mount` prints one "<source> on <path> (<options>)" line per mount, with the
90
- // path resolved, so a project under a symlink is looked for by its real path.
91
- export function isMountedAt(mountPath, mountTable) {
92
- let realPath = mountPath;
93
- try {
94
- realPath = path.join(fs.realpathSync(path.dirname(mountPath)), path.basename(mountPath));
95
- } catch {
96
- // No parent directory, so nothing can be mounted there either.
97
- }
98
- return mountTable
99
- .split("\n")
100
- .some((line) => line.includes(` on ${mountPath} (`) || line.includes(` on ${realPath} (`));
101
- }
102
-
103
- function currentMountTable() {
104
- return spawnSync("mount", [], { encoding: "utf8" }).stdout ?? "";
105
- }
106
-
107
- function unmount(mountPath) {
108
- // What PIFSServer>>stopDaemon runs when it stops a projection itself.
109
- if (spawnSync("umount", [mountPath], { stdio: "ignore" }).status !== 0) {
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 a projection whose image is gone.
132
+ * Unmount the projection of an image that is gone, when nothing else will.
116
133
  *
117
- * The mount is served by a PIFS daemon, a process of its own that reads the
118
- * source over HTTP from the image. PIFS unmounts it from inside the image when
119
- * it stops, which an image that crashed or was closed never does: the mount
120
- * stays, every read in it fails with an I/O error, and it holds the path
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} mountPath
125
- * @param {{ mountTable?: () => string, unmount?: (path: string) => void }} [host]
126
- * @returns {boolean} whether something was unmounted
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 releaseProjectionMount(mountPath, host = {}) {
129
- const mountTable = host.mountTable ?? currentMountTable;
130
- if (mountPath === undefined || !isMountedAt(mountPath, mountTable())) return false;
131
- (host.unmount ?? unmount)(mountPath);
132
- if (isMountedAt(mountPath, mountTable())) {
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 projection = projectionAvailability();
301
- if (!projection.available) {
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 platform allows it.
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
- const mountPath = resolveMountPath(projectRoot, readConfig(projectRoot), image.id);
318
- fs.mkdirSync(path.dirname(mountPath), { recursive: true });
319
- const record = {
320
- statePath: started.statePath,
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(record.mountPath, imageName),
370
+ code: pifsStartCode(mountPath, imageName),
362
371
  });
363
- const projectionPort = Number(resultPrintString(result));
364
- if (!Number.isInteger(projectionPort) || projectionPort < 1) {
365
- throw new Error(
366
- `PIFS did not report a projection port: ${JSON.stringify(result).slice(0, 500)}`,
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 projectionPort;
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
- if (recordedImageIsRunning(record)) {
380
- await callImageTool(projectRoot, record, "image_evaluate", {
381
- code: pifsStopCode(record.projectionPort),
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 with fuse-t its source also mounts as files.",
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 new Error(`Image ${imageId} is not configured.`);
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
+ }