@evref-bl/pharo-agent 0.10.2 → 0.12.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.10.2",
4
+ "version": "0.12.0",
5
5
  "description": "Terminal-first Pharo image setup for coding agents.",
6
6
  "author": {
7
7
  "name": "Evref-BL",
package/.mcp.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "image": {
4
+ "command": "node",
5
+ "args": ["${CLAUDE_PLUGIN_ROOT}/bin/pharo-agent.js", "mcp"]
6
+ }
7
+ }
8
+ }
package/README.md CHANGED
@@ -14,7 +14,7 @@ npx --yes @evref-bl/pharo-agent init --host <claude|codex|opencode>
14
14
 
15
15
  That is all of it. `init` writes the project's Pharo guidance and copies the
16
16
  workflow skills that guidance refers to. From there the agent registers the
17
- image the project needs, opens it and uses its tools by following what was
17
+ image the project needs, starts it and uses its tools by following what was
18
18
  written; ask it for Pharo work as you would for anything else.
19
19
 
20
20
  ## Refreshing an existing project
@@ -35,8 +35,10 @@ the file ownership rules and [Images](docs/images.md) for image-side MCP updates
35
35
 
36
36
  Node 22.12 or later.
37
37
 
38
- The source projection currently supports macOS and needs
39
- [fuse-t](https://www.fuse-t.org/). Its daemon is downloaded for you.
38
+ Images and their Pharo tools run anywhere Node does. The source projection is
39
+ the exception: it needs macOS with [fuse-t](https://www.fuse-t.org/), whose
40
+ daemon is downloaded for you. Everywhere else an image starts with its tools
41
+ and says that the projection is not available, rather than failing.
40
42
 
41
43
  ## What the agent gets
42
44
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evref-bl/pharo-agent",
3
- "version": "0.10.2",
3
+ "version": "0.12.0",
4
4
  "private": false,
5
5
  "description": "Terminal-first Pharo integration for coding agents.",
6
6
  "license": "Apache-2.0",
@@ -16,6 +16,7 @@
16
16
  "bin",
17
17
  "src",
18
18
  "skills",
19
+ ".mcp.json",
19
20
  "plugin.json",
20
21
  ".agents",
21
22
  ".claude-plugin",
@@ -32,7 +33,7 @@
32
33
  },
33
34
  "dependencies": {
34
35
  "@evref-bl/pharo-mcp": "3.3.1",
35
- "@evref-bl/plexus": "0.10.1",
36
+ "@evref-bl/plexus": "0.12.1",
36
37
  "@modelcontextprotocol/sdk": "1.32.1"
37
38
  },
38
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.10.2",
4
+ "version": "0.12.0",
5
5
  "description": "Terminal-first Pharo image setup for coding agents.",
6
6
  "author": {
7
7
  "name": "Evref-BL",
@@ -8,13 +8,35 @@ Run this only when the user asks to enable or refresh Pharo tooling in this proj
8
8
  From the project root, run the released CLI that matches this plugin:
9
9
 
10
10
  ```sh
11
- npx --yes @evref-bl/pharo-agent@0.10.2 init --host claude
11
+ npx --yes @evref-bl/pharo-agent@0.12.0 init --host claude
12
12
  ```
13
13
 
14
14
  Name the host this chat runs in: `--host claude` in Claude Code,
15
15
  `--host codex` in Codex, `--host opencode` in OpenCode.
16
- Do not configure an MCP server in the agent host. The command adds only a
17
- conditional instruction block, so Pharo guidance stays out of unrelated work.
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.
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`:
23
+
24
+ ```json
25
+ {
26
+ "extraKnownMarketplaces": {
27
+ "evref-bl": {
28
+ "source": {
29
+ "source": "git",
30
+ "url": "https://github.com/Evref-BL/pharo-agent-plugin.git"
31
+ }
32
+ }
33
+ },
34
+ "enabledPlugins": { "pharo-agent@evref-bl": true }
35
+ }
36
+ ```
37
+
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.
18
40
 
19
41
  After upgrading the plugin/CLI, re-run the setup command from the project root
20
42
  with the same host to refresh the generated guide and copied skills/references.
@@ -24,7 +46,8 @@ existing settings. Image declarations, runtime state, and live image code are
24
46
  preserved. This refresh does not install a newer package or upgrade image tools.
25
47
 
26
48
  Setup initializes the current directory, creating `plexus.json` with
27
- no image when it is absent. Register one before the first `pharo-agent open`:
49
+ no image when it is absent. Register one before the first
50
+ `pharo-agent image start`:
28
51
 
29
52
  ```sh
30
53
  pharo-agent image templates
package/src/cli.js CHANGED
@@ -2,15 +2,11 @@ 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, serveImagesMcp } from "./gateway.js";
6
- import {
7
- agentImageStatus,
8
- closeAgentImage,
9
- listImageTemplates,
10
- openAgentImage,
11
- } from "./live.js";
5
+ import { callImageTool, listImageTools } from "./gateway.js";
6
+ import { serveImagesMcp } from "./mcpServer.js";
7
+ import { listImageTemplates, startAgentImage, stopAgentImage } from "./live.js";
12
8
  import { findProjectRoot } from "./project.js";
13
- import { readOpenImage } from "./runtime.js";
9
+ import { readRunningImage } from "./runtime.js";
14
10
 
15
11
  function parseInit(args) {
16
12
  let host;
@@ -33,11 +29,18 @@ function parseInit(args) {
33
29
  return { host, mountRoot };
34
30
  }
35
31
 
32
+ /**
33
+ * The template is required, and the loop below refuses the call without one,
34
+ * which the dynamic key assignment hides from the typechecker.
35
+ *
36
+ * @returns {{ id: string, template: string, category?: string, profile?: string }}
37
+ */
36
38
  function parseImageAdd(args) {
37
39
  const [id, ...rest] = args;
38
40
  if (!id || id.startsWith("--")) {
39
41
  throw new Error("image add requires an image id");
40
42
  }
43
+ /** @type {{ id: string, template?: string, category?: string, profile?: string }} */
41
44
  const parsed = { id };
42
45
  const options = {
43
46
  "--template": "template",
@@ -60,7 +63,22 @@ function parseImageAdd(args) {
60
63
  if (!parsed.template) {
61
64
  throw new Error("image add requires --template <name>");
62
65
  }
63
- return parsed;
66
+ return { ...parsed, template: parsed.template };
67
+ }
68
+
69
+ // `image start` and `image stop` name their subject, like `image use` and
70
+ // `image remove`; `--image` stays on the commands that scope a call.
71
+ function parseImageTarget(subcommand, args) {
72
+ const all = args.includes("--all");
73
+ const rest = args.filter((value) => value !== "--all");
74
+ const [id, ...extra] = rest;
75
+ if (extra.length > 0 || (id && id.startsWith("--"))) {
76
+ throw new Error(`image ${subcommand} takes one image id`);
77
+ }
78
+ if (all && subcommand !== "stop") {
79
+ throw new Error(`image ${subcommand} does not accept --all`);
80
+ }
81
+ return { id, all };
64
82
  }
65
83
 
66
84
  function takeImageOption(args) {
@@ -115,6 +133,16 @@ export async function main(args) {
115
133
  console.log(JSON.stringify({ ok: true, ...result }, null, 2));
116
134
  return;
117
135
  }
136
+ // Above the project lookup on purpose: a host starts this in whatever
137
+ // directory it likes, which may hold no project at all, and a server that
138
+ // exits is a broken server in the host's list. It resolves a project per
139
+ // request instead, so the reason reaches whoever calls a tool.
140
+ if (command === "mcp") {
141
+ const { image: mcpImage, rest: mcpArgs } = takeImageOption(rest);
142
+ requireNoArguments(command, mcpArgs);
143
+ await serveImagesMcp(findProjectRoot, mcpImage);
144
+ return;
145
+ }
118
146
  const projectRoot = findProjectRoot();
119
147
  if (command === "image") {
120
148
  const [subcommand, ...imageArgs] = rest;
@@ -136,6 +164,24 @@ export async function main(args) {
136
164
  console.log(JSON.stringify({ ok: true, ...addImage(projectRoot, parseImageAdd(imageArgs)) }, null, 2));
137
165
  return;
138
166
  }
167
+ if (subcommand === "start") {
168
+ const { id } = parseImageTarget(subcommand, imageArgs);
169
+ console.log(JSON.stringify(
170
+ { ok: true, ...(await startAgentImage(projectRoot, id)) },
171
+ null,
172
+ 2,
173
+ ));
174
+ return;
175
+ }
176
+ if (subcommand === "stop") {
177
+ const { id, all } = parseImageTarget(subcommand, imageArgs);
178
+ console.log(JSON.stringify(
179
+ { ok: true, ...(await stopAgentImage(projectRoot, id, { all })) },
180
+ null,
181
+ 2,
182
+ ));
183
+ return;
184
+ }
139
185
  if (subcommand === "use" || subcommand === "remove") {
140
186
  const [id, ...extra] = imageArgs;
141
187
  if (!id || extra.length > 0) {
@@ -148,35 +194,12 @@ export async function main(args) {
148
194
  throw new Error(`Unsupported image command: ${subcommand ?? ""}\n${usage()}`);
149
195
  }
150
196
  const { image, rest: commandArgs } = takeImageOption(rest);
151
- if (command === "open") {
152
- requireNoArguments(command, commandArgs);
153
- console.log(JSON.stringify({ ok: true, ...(await openAgentImage(projectRoot, image)) }, null, 2));
154
- return;
155
- }
156
- if (command === "close") {
157
- requireNoArguments(command, commandArgs);
158
- console.log(JSON.stringify({ ok: true, ...(await closeAgentImage(projectRoot, image)) }, null, 2));
159
- return;
160
- }
161
- if (command === "status") {
162
- requireNoArguments(command, commandArgs);
163
- if (image) {
164
- throw new Error("status reports every open image and does not accept --image");
165
- }
166
- console.log(JSON.stringify({ ok: true, ...agentImageStatus(projectRoot) }, null, 2));
167
- return;
168
- }
169
- if (command === "mcp") {
170
- requireNoArguments(command, commandArgs);
171
- await serveImagesMcp(projectRoot, image);
172
- return;
173
- }
174
197
  if (command === "pharo") {
175
198
  const [toolName, argumentsJson, ...extra] = commandArgs;
176
199
  if (toolName === "list") {
177
200
  if (argumentsJson || extra.length > 0) throw new Error("pharo list does not accept arguments");
178
201
  console.log(JSON.stringify(
179
- await listImageTools(projectRoot, readOpenImage(projectRoot, image)),
202
+ await listImageTools(projectRoot, readRunningImage(projectRoot, image)),
180
203
  null,
181
204
  2,
182
205
  ));
@@ -187,7 +210,7 @@ export async function main(args) {
187
210
  }
188
211
  const result = await callImageTool(
189
212
  projectRoot,
190
- readOpenImage(projectRoot, image),
213
+ readRunningImage(projectRoot, image),
191
214
  toolName,
192
215
  parseToolArguments(argumentsJson),
193
216
  );
package/src/gateway.js CHANGED
@@ -1,14 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { PlexusGateway } from "@evref-bl/plexus/gateway";
3
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { loadProjectState } from "@evref-bl/plexus";
5
4
  import {
6
- CallToolRequestSchema,
7
- ListToolsRequestSchema,
8
- } from "@modelcontextprotocol/sdk/types.js";
9
- import {
10
- readJson,
11
- readOpenImage,
12
5
  selectRunningImage,
13
6
  unappliedPharoMcpPin,
14
7
  } from "./runtime.js";
@@ -20,15 +13,18 @@ export const { version } = createRequire(import.meta.url)("../package.json");
20
13
  // A route source is the directory, not the route. PLexus re-reads the state
21
14
  // file and the image's endpoint handoff every time it resolves one, so there
22
15
  // is nothing to register here and nothing that can go stale between the call
23
- // that opened the image and this one.
24
- export function imageGateway(projectRoot, open) {
25
- const state = readJson(open.statePath);
26
- const { image, mcpServerId } = selectRunningImage(state, open.imageId);
27
- if (image.id !== open.imageId || mcpServerId !== open.mcpServerId) {
28
- throw new Error(`The PLexus image ${open.imageId} changed; run pharo-agent open again.`);
16
+ // that started the image and this one.
17
+ export function imageGateway(projectRoot, running) {
18
+ const state = loadProjectState(running.statePath);
19
+ if (!state) {
20
+ throw new Error(`PLexus has no state at ${running.statePath}.`);
21
+ }
22
+ const { image, mcpServerId } = selectRunningImage(state, running.imageId);
23
+ if (image.id !== running.imageId || mcpServerId !== running.mcpServerId) {
24
+ throw new Error(`The PLexus image ${running.imageId} changed; run pharo-agent image start again.`);
29
25
  }
30
26
  const gateway = new PlexusGateway({
31
- routeSources: [{ projectRoot, statePath: open.statePath }],
27
+ routeSources: [{ projectRoot, statePath: running.statePath }],
32
28
  pharoScope: {
33
29
  projectId: state.projectId,
34
30
  workspaceId: state.workspaceId,
@@ -49,8 +45,8 @@ export function toolFailureMessage(error, image) {
49
45
  return unappliedPin ? `${error}\n${unappliedPin}` : error;
50
46
  }
51
47
 
52
- export async function callImageTool(projectRoot, open, toolName, argumentsValue) {
53
- const { gateway, mcpServerId, image } = imageGateway(projectRoot, open);
48
+ export async function callImageTool(projectRoot, running, toolName, argumentsValue) {
49
+ const { gateway, mcpServerId, image } = imageGateway(projectRoot, running);
54
50
  const result = await gateway.routeToImage({
55
51
  mcpServerId,
56
52
  toolName,
@@ -64,8 +60,8 @@ export async function callImageTool(projectRoot, open, toolName, argumentsValue)
64
60
  );
65
61
  }
66
62
 
67
- export async function listImageTools(projectRoot, open) {
68
- const { gateway } = imageGateway(projectRoot, open);
63
+ export async function listImageTools(projectRoot, running) {
64
+ const { gateway } = imageGateway(projectRoot, running);
69
65
  return gateway.refreshPharoTools();
70
66
  }
71
67
 
@@ -89,28 +85,3 @@ export function toolResult(data) {
89
85
  }
90
86
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
91
87
  }
92
-
93
- export async function serveImagesMcp(projectRoot, defaultImage) {
94
- const defaultOpen = readOpenImage(projectRoot, defaultImage);
95
- const tools = (await listImageTools(projectRoot, defaultOpen))
96
- .map((tool) => withImageSelector(tool, defaultImage));
97
-
98
- const server = new Server(
99
- { name: "pharo-agent", version },
100
- { capabilities: { tools: {} } },
101
- );
102
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
103
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
104
- // Listed tools are the common ones, not the whole catalogue, and the image
105
- // answers for any tool it holds. Refusing a name this list does not carry
106
- // would hide tools that tool_search just found, and the image's own error
107
- // is the better answer for a name nothing holds.
108
- const { image, ...argumentsValue } = request.params.arguments ?? {};
109
- const open = readOpenImage(projectRoot, image);
110
- return toolResult(
111
- await callImageTool(projectRoot, open, request.params.name, argumentsValue),
112
- );
113
- });
114
-
115
- await server.connect(new StdioServerTransport());
116
- }
package/src/help.js CHANGED
@@ -10,7 +10,8 @@ const commands = {
10
10
  },
11
11
  "image list": {
12
12
  usage: "image list",
13
- description: "List the project's declared images, default image, and recorded open state.",
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.",
14
15
  example: "image list",
15
16
  },
16
17
  "image templates": {
@@ -21,55 +22,53 @@ const commands = {
21
22
  },
22
23
  "image add": {
23
24
  usage: "image add <id> --template <name> [--category <name>] [--profile <id>]",
24
- description: "Declare an image with a required id and template name; open creates it later.\n"
25
+ description: "Declare an image with a required id and template name; image start creates it later.\n"
25
26
  + "Use --category for an ambiguous template name and --profile to choose a launcher profile.\n"
26
27
  + "The first declared image becomes the default.",
27
28
  example: "image add dev --template '<template name>'",
28
29
  },
29
30
  "image use": {
30
31
  usage: "image use <id>",
31
- description: "Make the required declared image the project's default. It may already be open.",
32
+ description: "Make the required declared image the project's default. It may already be running.",
32
33
  example: "image use dev",
33
34
  },
34
35
  "image remove": {
35
36
  usage: "image remove <id>",
36
- description: "Remove the required image declaration. Close the image first. Its image files are kept.\n"
37
+ description: "Remove the required image declaration. Stop the image first. Its image files are kept.\n"
37
38
  + "Removing the default image clears the default selection.",
38
39
  example: "image remove dev",
39
40
  },
40
- open: {
41
- usage: "open [--image <id>]",
42
- description: "Create if needed and open an image with its tools and source projection. Omit --image\n"
43
- + "to use the project's default image. Other open images stay open. Pharo versions outside\n"
44
- + "the MCP support range open without tools or a source projection.",
45
- example: "open --image dev",
41
+ "image start": {
42
+ usage: "image start [<id>]",
43
+ description: "Create if needed and start an image with its Pharo tools. Omit the id to use the\n"
44
+ + "project's default image. Images already running stay running. A source projection is\n"
45
+ + "mounted on macOS with fuse-t; elsewhere the image starts without one and says so.\n"
46
+ + "Pharo versions outside the MCP support range start without tools or a projection.",
47
+ example: "image start dev",
46
48
  },
47
- status: {
48
- usage: "status",
49
- description: "Report all images this project has open. This command does not accept --image.",
50
- example: "status",
51
- },
52
- close: {
53
- usage: "close [--image <id>]",
54
- description: "Close the selected image and its source projection. Omitting --image closes all images\n"
55
- + "this project opened, rather than only the default image.",
56
- example: "close --image dev",
49
+ "image stop": {
50
+ usage: "image stop [<id>] [--all]",
51
+ description: "Stop the named image and any source projection it has, or the project's default\n"
52
+ + "image when no id is given. Use --all to stop every image this project started.",
53
+ example: "image stop dev",
57
54
  },
58
55
  mcp: {
59
56
  usage: "mcp [--image <id>]",
60
- description: "Expose an open image's tools as a command-scoped stdio MCP server. Omit --image\n"
61
- + "to use the project's default image. The server exits with this command.",
57
+ description: "Serve this project's images as a command-scoped stdio MCP server. Omit\n"
58
+ + "--image to use the project's default image. It starts whether or not an image\n"
59
+ + "is running: image_start starts one and the image's own tools join the list. The\n"
60
+ + "server exits with this command, and the plugin's .mcp.json starts it for a host.",
62
61
  example: "mcp --image dev",
63
62
  },
64
63
  "pharo list": {
65
64
  usage: "pharo list [--image <id>]",
66
- description: "List an open image's advertised tools. Omit --image to use the project's default image.\n"
65
+ description: "List a running image's advertised tools. Omit --image to use the project's default image.\n"
67
66
  + "Use tool_search and tool_get to discover and inspect the rest of its catalogue.",
68
67
  example: "pharo list",
69
68
  },
70
69
  pharo: {
71
70
  usage: "pharo <tool> [arguments-json] [--image <id>]",
72
- description: "Call a tool in an open image. Arguments must be a JSON object and default to {}.\n"
71
+ description: "Call a tool in a running image. Arguments must be a JSON object and default to {}.\n"
73
72
  + "Omit --image to use the project's default image. Inspect the tool's schema with tool_get\n"
74
73
  + "before calling it; a failed tool result makes the command exit unsuccessfully.",
75
74
  example: 'pharo tool_get \'{"name":"class_search"}\'',
package/src/images.js CHANGED
@@ -1,77 +1,78 @@
1
- import {
2
- configuredImages,
3
- projectIdentity,
4
- readProjectConfig,
5
- writeProjectConfig,
6
- } from "./project.js";
7
- import { readConfig, readOpenImages, writeConfig } from "./runtime.js";
1
+ import { declareProjectImage, removeProjectImage } from "@evref-bl/plexus";
2
+ import { configuredImages } from "./project.js";
3
+ import { defaultImageId, readConfig, readRunningImages, writeConfig } from "./runtime.js";
8
4
 
9
5
  export const defaultLoadScript = "pharo/load-mcp.st";
10
6
 
11
- function assertNoOpenImage(projectRoot, imageId, action) {
12
- if (readOpenImages(projectRoot)[imageId]) {
13
- throw new Error(`Image ${imageId} is open. Run pharo-agent close before ${action}.`);
7
+ function assertNotRunning(projectRoot, imageId, action) {
8
+ if (readRunningImages(projectRoot)[imageId]) {
9
+ throw new Error(
10
+ `Image ${imageId} is running. Run pharo-agent image stop ${imageId} before ${action}.`,
11
+ );
14
12
  }
15
13
  }
16
14
 
17
- function derivedImageName(projectRoot, imageId) {
18
- const { name } = projectIdentity(projectRoot);
19
- return `${name}-{workspaceId}-${imageId}`;
20
- }
21
-
22
15
  export function listImages(projectRoot) {
23
16
  const configured = configuredImages(projectRoot);
24
- const open = readOpenImages(projectRoot);
25
- const defaultImage = readConfig(projectRoot).defaultImage
26
- ?? (configured.length === 1 ? configured[0].id : undefined);
17
+ const running = readRunningImages(projectRoot);
18
+ let defaultImage;
19
+ try {
20
+ defaultImage = defaultImageId(projectRoot);
21
+ } catch {
22
+ // Several declared and none chosen. The list is how a caller finds that
23
+ // out, so it answers rather than raising the question back at them.
24
+ defaultImage = undefined;
25
+ }
27
26
  return {
28
27
  ...(defaultImage ? { defaultImage } : {}),
29
- images: configured.map((image) => ({
30
- id: image.id,
31
- imageName: image.imageName,
32
- isDefault: image.id === defaultImage,
33
- open: Boolean(open[image.id]),
34
- templateName: image.create?.templateName,
35
- templateCategory: image.create?.templateCategory,
36
- })),
28
+ images: configured.map((image) => {
29
+ // The record repeats the id it is keyed by; everything else in it is
30
+ // runtime detail this list carries when the image is running.
31
+ const { imageId: _keyed, ...runtime } = running[image.id] ?? {};
32
+ return {
33
+ id: image.id,
34
+ imageName: image.imageName,
35
+ isDefault: image.id === defaultImage,
36
+ running: Boolean(running[image.id]),
37
+ ...runtime,
38
+ templateName: image.create?.templateName,
39
+ templateCategory: image.create?.templateCategory,
40
+ };
41
+ }),
37
42
  };
38
43
  }
39
44
 
40
- /** @param {{ id: string, template?: string, category?: string, profile?: string }} request */
45
+ /**
46
+ * PLexus owns `plexus.json`, so PLexus writes it. This package chooses what
47
+ * to declare — the load script that puts the pinned MCP in the image — and
48
+ * leaves the schema, the name template, the defaults and the validation to
49
+ * the package that reads them back.
50
+ *
51
+ * @param {{ id: string, template: string, category?: string, profile?: string }} request
52
+ */
41
53
  export function addImage(projectRoot, request) {
42
54
  const { id, template, category, profile } = request;
43
- const config = readProjectConfig(projectRoot);
44
- const images = Array.isArray(config.images) ? config.images : [];
45
- if (images.some((image) => image.id === id)) {
46
- throw new Error(`Image ${id} is already configured.`);
47
- }
48
- const imageName = derivedImageName(projectRoot, id);
49
- if (images.some((image) => image.imageName === imageName)) {
50
- throw new Error(`Image name ${imageName} is already configured.`);
55
+ const declaredBefore = configuredImages(projectRoot).length;
56
+ const config = declareProjectImage(projectRoot, {
57
+ id,
58
+ templateName: template,
59
+ ...(category ? { templateCategory: category } : {}),
60
+ ...(profile ? { profileId: profile } : {}),
61
+ loadScript: defaultLoadScript,
62
+ });
63
+ const declared = config.images.find((image) => image.id === id);
64
+ if (!declared) {
65
+ throw new Error(`PLexus declared image ${id} but did not record it.`);
51
66
  }
52
67
 
53
- const image = {
54
- id,
55
- imageName,
56
- active: true,
57
- mcp: { loadScript: defaultLoadScript, loadPolicy: "ifMissing" },
58
- create: {
59
- kind: "template",
60
- ...(profile ? { profileId: profile } : {}),
61
- templateName: template,
62
- ...(category ? { templateCategory: category } : {}),
63
- cleanupPolicy: "workspace_cleanup_only",
64
- },
65
- };
66
- writeProjectConfig(projectRoot, { ...config, images: [...images, image] });
67
68
  const pluginConfig = readConfig(projectRoot);
68
- const isDefault = pluginConfig.defaultImage === undefined && images.length === 0;
69
+ const isDefault = pluginConfig.defaultImage === undefined && declaredBefore === 0;
69
70
  if (isDefault) {
70
71
  writeConfig(projectRoot, { ...pluginConfig, defaultImage: id });
71
72
  }
72
73
  return {
73
74
  id,
74
- imageName,
75
+ imageName: declared.imageName,
75
76
  isDefault,
76
77
  templateName: template,
77
78
  ...(category ? { templateCategory: category } : {}),
@@ -87,16 +88,11 @@ export function useImage(projectRoot, id) {
87
88
  }
88
89
 
89
90
  export function removeImage(projectRoot, id) {
90
- const config = readProjectConfig(projectRoot);
91
- const images = Array.isArray(config.images) ? config.images : [];
92
- if (!images.some((image) => image.id === id)) {
91
+ if (!configuredImages(projectRoot).some((image) => image.id === id)) {
93
92
  throw new Error(`Image ${id} is not configured.`);
94
93
  }
95
- assertNoOpenImage(projectRoot, id, "removing it");
96
- writeProjectConfig(projectRoot, {
97
- ...config,
98
- images: images.filter((image) => image.id !== id),
99
- });
94
+ assertNotRunning(projectRoot, id, "removing it");
95
+ removeProjectImage(projectRoot, id);
100
96
  const pluginConfig = readConfig(projectRoot);
101
97
  if (pluginConfig.defaultImage === id) {
102
98
  const { defaultImage: _removed, ...rest } = pluginConfig;