@evref-bl/pharo-agent 0.15.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.15.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.15.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.4.1",
36
- "@evref-bl/plexus": "0.14.1",
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.15.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.15.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.
@@ -5,21 +5,20 @@ description: Enable or refresh Pharo image tooling in the current Pharo project.
5
5
 
6
6
  Run this only when the user asks to enable or refresh Pharo tooling in this project.
7
7
 
8
- From the project root, run the released CLI that matches this plugin:
8
+ From the project root, run the released CLI that matches this plugin, naming
9
+ the host this chat runs in: `--host claude` in Claude Code, `--host codex` in
10
+ Codex, `--host opencode` in OpenCode.
9
11
 
10
12
  ```sh
11
- npx --yes @evref-bl/pharo-agent@0.15.0 init --host claude
13
+ npx --yes @evref-bl/pharo-agent@0.17.0 init --host claude
12
14
  ```
13
15
 
14
- Name the host this chat runs in: `--host claude` in Claude Code,
15
- `--host codex` in Codex, `--host opencode` in OpenCode.
16
- The command writes no host configuration. It adds a conditional instruction
17
- block and this project's own files, so Pharo guidance stays out of unrelated
18
- work.
16
+ It writes the project's Pharo guide, skills and references, and a block in the
17
+ host's guide that points to them; no host configuration. Re-running it after
18
+ an upgrade replaces those files and keeps image declarations and image code.
19
19
 
20
- To get the image's tools as MCP tools in Claude Code, enable this plugin for
21
- this project rather than configuring a server by hand. Commit
22
- `.claude/settings.json`:
20
+ In Claude Code, enable the plugin for this project in `.claude/settings.json`;
21
+ the next session gets the tools:
23
22
 
24
23
  ```json
25
24
  {
@@ -35,26 +34,11 @@ this project rather than configuring a server by hand. Commit
35
34
  }
36
35
  ```
37
36
 
38
- A host reads that file when a session starts, so the tools appear in the next
39
- one. Enabling it per project keeps every other project untouched.
40
-
41
- After upgrading the plugin/CLI, re-run the setup command from the project root
42
- with the same host to refresh the generated guide and copied skills/references.
43
- Those files are overwritten; keep project-specific guidance outside the host's
44
- managed block and outside generated/copied paths. Omit `--mount-root` to retain
45
- existing settings. Image declarations, runtime state, and live image code are
46
- preserved. This refresh does not install a newer package or upgrade image tools.
47
-
48
- Setup initializes the current directory, creating `plexus.json` with
49
- no image when it is absent. Register one before the first
50
- `pharo-agent image start`:
37
+ Then declare the project's first image, its default:
51
38
 
52
39
  ```sh
53
40
  pharo-agent image templates
54
41
  pharo-agent image add dev --template '<launcher template name>'
55
42
  ```
56
43
 
57
- `image templates` names what the launcher offers, with the category of each.
58
-
59
- After setup, read `pharo.AGENTS.md` in this chat before doing Pharo work. A
60
- new chat picks up the conditional project guidance automatically.
44
+ Read `pharo.AGENTS.md` before doing Pharo work.
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
@@ -3,81 +3,67 @@ import { hostChoices, hostIds } from "./hosts.js";
3
3
  const commands = {
4
4
  init: {
5
5
  usage: `init --host <${hostChoices()}> [--mount-root <path>]`,
6
- description: "Initialize the current project for the required agent host. Write its managed instruction block,\n"
7
- + "pharo.AGENTS.md, and workflow skills/references. Re-run to refresh those generated files.\n"
8
- + "The mount root defaults to .pharo-agent/mounts; omit --mount-root to keep an existing setting.",
6
+ description: "Write this project's Pharo guide, skills and references for the host. Re-run to refresh\n"
7
+ + "them. --mount-root sets where sources mount, .pharo-agent/mounts by default.",
9
8
  example: `init --host ${hostIds[0]}`,
10
9
  },
11
10
  "image list": {
12
11
  usage: "image list",
13
- description: "List the project's declared images and the default one, with the mount and\n"
14
- + "projection port of each image this project has running. An image this project\n"
15
- + "started that has since exited is listed as exited.",
12
+ description: "List the declared images, the default, and the mount and port of each running one.",
16
13
  example: "image list",
17
14
  },
18
15
  "image templates": {
19
16
  usage: "image templates",
20
- description: "List available launcher templates and their categories. A name in several categories\n"
21
- + "needs --category when passed to image add.",
17
+ description: "List the templates images can be made from, with their categories.",
22
18
  example: "image templates",
23
19
  },
24
20
  "image add": {
25
21
  usage: "image add <id> --template <name> [--category <name>] [--profile <id>]",
26
- description: "Declare an image with a required id and template name; image start creates it later.\n"
27
- + "Use --category for an ambiguous template name and --profile to choose a launcher profile.\n"
28
- + "The first declared image becomes the default.",
29
- example: "image add dev --template '<template name>'",
22
+ description: "Declare an image; image start creates it. Name it after what it is for, like a branch.\n"
23
+ + "The first declared image is the default. --category is for a name two categories offer.",
24
+ example: "image add pretty-print-formatter --template '<template name>'",
30
25
  },
31
26
  "image use": {
32
27
  usage: "image use <id>",
33
- description: "Make the required declared image the project's default. It may already be running.",
28
+ description: "Make an image the default.",
34
29
  example: "image use dev",
35
30
  },
36
31
  "image remove": {
37
32
  usage: "image remove <id>",
38
- description: "Remove the required image declaration. Stop the image first. Its image files are kept.\n"
39
- + "Removing the default image clears the default selection.",
33
+ description: "Drop an image's declaration. Stop it first; its files are kept.",
40
34
  example: "image remove dev",
41
35
  },
42
36
  "image start": {
43
37
  usage: "image start [<id>]",
44
- description: "Create if needed and start an image with its Pharo tools. Omit the id to use the\n"
45
- + "project's default image. Images already running stay running. A source projection is\n"
46
- + "mounted on macOS with fuse-t; elsewhere the image starts without one and says so.\n"
47
- + "Pharo versions outside the MCP support range start without tools or a projection.",
38
+ description: "Create if needed and start an image, the default one without an id. Others keep running.\n"
39
+ + "On macOS and Linux its source mounts as files, unless PHARO_AGENT_PROJECTION=off.",
48
40
  example: "image start dev",
49
41
  },
50
42
  "image stop": {
51
43
  usage: "image stop [<id>] [--all]",
52
- description: "Stop the named image and any source projection it has, or the project's default\n"
53
- + "image when no id is given. Use --all to stop every image this project started.",
44
+ description: "Stop an image and its source mount, the default one without an id, or --all of them.",
54
45
  example: "image stop dev",
55
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
+ },
56
58
  mcp: {
57
59
  usage: "mcp [--image <id>]",
58
- description: "Serve this project's images as a command-scoped stdio MCP server. Omit\n"
59
- + "--image to use the project's default image. It starts whether or not an image\n"
60
- + "is running: image_start starts one and the image's own tools join the list. The\n"
61
- + "server exits with this command, and the plugin's .mcp.json starts it for a host.",
60
+ description: "Serve this project's images as a stdio MCP server, as the plugin does for a host.",
62
61
  example: "mcp --image dev",
63
62
  },
64
- "pharo list": {
65
- usage: "pharo list [--image <id>]",
66
- description: "List a running image's advertised tools. Omit --image to use the project's default image.\n"
67
- + "Use tool_search and tool_get to discover and inspect the rest of its catalogue.",
68
- example: "pharo list",
69
- },
70
- pharo: {
71
- usage: "pharo <tool> [arguments-json] [--image <id>]",
72
- description: "Call a tool in a running image. Arguments must be a JSON object and default to {}.\n"
73
- + "Omit --image to use the project's default image. Inspect the tool's schema with tool_get\n"
74
- + "before calling it; a failed tool result makes the command exit unsuccessfully.",
75
- example: 'pharo tool_get \'{"name":"class_search"}\'',
76
- },
77
63
  };
78
64
 
79
65
  export function usage(args = []) {
80
- const key = ["image", "pharo"].includes(args[0]) && args[1]
66
+ const key = args[0] === "image" && args[1]
81
67
  ? `${args[0]} ${args[1]}`
82
68
  : args[0];
83
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);