@evref-bl/pharo-agent 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pharo-agent",
3
3
  "displayName": "Pharo Agent",
4
- "version": "0.1.0",
4
+ "version": "0.2.1",
5
5
  "description": "Terminal-first Pharo image setup for coding agents.",
6
6
  "author": {
7
7
  "name": "Evref-BL",
@@ -10,5 +10,9 @@
10
10
  "homepage": "https://github.com/Evref-BL/pharo-agent-plugin",
11
11
  "repository": "https://github.com/Evref-BL/pharo-agent-plugin",
12
12
  "license": "Apache-2.0",
13
- "keywords": ["pharo", "smalltalk", "mcp"]
13
+ "keywords": [
14
+ "pharo",
15
+ "smalltalk",
16
+ "mcp"
17
+ ]
14
18
  }
package/README.md CHANGED
@@ -4,8 +4,7 @@
4
4
  OpenCode. It has one small setup skill; a project receives the full Pharo
5
5
  guidance only after setup.
6
6
 
7
- The project must already contain a `plexus.project.json` that declares one
8
- agent-facing image. Initialize it for one selected agent host:
7
+ Initialize the current directory for one selected agent host:
9
8
 
10
9
  ```sh
11
10
  npx --yes @evref-bl/pharo-agent init --host codex
@@ -15,6 +14,22 @@ It writes a small conditional instruction block, `pharo.AGENTS.md`, and copies
15
14
  on-demand Pharo workflow skills and references from the pinned MCP template
16
15
  package. It does not install an agent-host MCP server.
17
16
 
17
+ `init` acts on the current directory and never searches above it. If that
18
+ directory has no `plexus.project.json`, it creates one that declares the
19
+ project and no image; an existing one is left untouched. It refuses to run
20
+ inside an enclosing PLexus project rather than initializing that project root.
21
+
22
+ Register the image the project needs before the first `open`:
23
+
24
+ ```sh
25
+ pharo-agent image add dev --template 'Pharo 13.0 - 64bit (stable)'
26
+ ```
27
+
28
+ `image add` registers a template-created image with the pinned MCP load policy
29
+ and makes it active when no other image is. `image list`, `image use <id>`,
30
+ and `image remove <id>` cover the rest; an open image must be closed before
31
+ its declaration changes.
32
+
18
33
  `open` starts the project image through PLexus, loads PIFS, and mounts its
19
34
  projection below `.pharo-agent/mounts`. `close` unmounts the projection and
20
35
  closes the PLexus image. `pharo` calls any exposed MCP tool through the same
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evref-bl/pharo-agent",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "private": false,
5
5
  "description": "Terminal-first Pharo integration for coding agents.",
6
6
  "license": "Apache-2.0",
@@ -32,8 +32,8 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@evref-bl/pharo-mcp": "1.4.1",
35
- "@evref-bl/plexus": "0.2.1",
36
- "@evref-bl/plexus-gateway": "0.2.1",
35
+ "@evref-bl/plexus": "0.3.0",
36
+ "@evref-bl/plexus-gateway": "0.3.0",
37
37
  "@modelcontextprotocol/sdk": "1.32.1"
38
38
  }
39
39
  }
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.1.0",
4
+ "version": "0.2.1",
5
5
  "description": "Terminal-first Pharo image setup for coding agents.",
6
6
  "author": {
7
7
  "name": "Evref-BL",
@@ -10,7 +10,11 @@
10
10
  "homepage": "https://github.com/Evref-BL/pharo-agent-plugin",
11
11
  "repository": "https://github.com/Evref-BL/pharo-agent-plugin",
12
12
  "license": "Apache-2.0",
13
- "keywords": ["pharo", "smalltalk", "mcp"],
13
+ "keywords": [
14
+ "pharo",
15
+ "smalltalk",
16
+ "mcp"
17
+ ],
14
18
  "extensions": {
15
19
  "com.openai": {
16
20
  "requires_local_executor": true,
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pharo-setup
3
- description: Enable Pharo image tooling in the current PLexus project.
3
+ description: Enable Pharo image tooling in the current Pharo project.
4
4
  ---
5
5
 
6
6
  Run this only when the user asks to enable Pharo tooling in this project.
@@ -8,12 +8,20 @@ Run this only when the user asks to enable Pharo tooling in this project.
8
8
  From the project root, run the released CLI that matches this plugin:
9
9
 
10
10
  ```sh
11
- npx --yes @evref-bl/pharo-agent@0.1.0 init --host codex
11
+ npx --yes @evref-bl/pharo-agent@0.2.1 init --host codex
12
12
  ```
13
13
 
14
14
  Use `--host claude` in Claude Code and `--host opencode` in OpenCode.
15
15
  Do not configure an MCP server in the agent host. The command adds only a
16
16
  conditional instruction block, so Pharo guidance stays out of unrelated work.
17
17
 
18
+ Setup initializes the current directory, creating `plexus.project.json` with
19
+ no image when it is absent. Register one before the first `pharo-agent open`:
20
+
21
+ ```sh
22
+ pharo-agent image list
23
+ pharo-agent image add dev --template '<launcher template name>'
24
+ ```
25
+
18
26
  After setup, read `pharo.AGENTS.md` in this chat before doing Pharo work. A
19
27
  new chat picks up the conditional project guidance automatically.
package/src/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { initialize } from "./init.js";
2
+ import { addImage, listImages, removeImage, useImage } from "./images.js";
2
3
  import { callActiveTool, listActiveTools, serveActiveMcp } from "./gateway.js";
3
4
  import { closeAgentImage, openAgentImage } from "./live.js";
4
5
  import { findProjectRoot } from "./project.js";
@@ -25,10 +26,40 @@ function parseInit(args) {
25
26
  return { host, mountRoot };
26
27
  }
27
28
 
29
+ function parseImageAdd(args) {
30
+ const [id, ...rest] = args;
31
+ if (!id || id.startsWith("--")) {
32
+ throw new Error("image add requires an image id");
33
+ }
34
+ const parsed = { id };
35
+ const options = { "--template": "template", "--category": "category", "--profile": "profile" };
36
+ for (let index = 0; index < rest.length; index += 1) {
37
+ const option = rest[index];
38
+ const key = options[option];
39
+ if (!key) {
40
+ throw new Error(`Unknown image add option: ${option}`);
41
+ }
42
+ const value = rest[index + 1];
43
+ if (!value) {
44
+ throw new Error(`${option} requires a value`);
45
+ }
46
+ parsed[key] = value;
47
+ index += 1;
48
+ }
49
+ if (!parsed.template) {
50
+ throw new Error("image add requires --template <name>");
51
+ }
52
+ return parsed;
53
+ }
54
+
28
55
  function usage() {
29
56
  return [
30
57
  "Usage:",
31
58
  " pharo-agent init --host <codex|claude|opencode> [--mount-root <path>]",
59
+ " pharo-agent image list",
60
+ " pharo-agent image add <id> --template <name> [--category <name>] [--profile <id>]",
61
+ " pharo-agent image use <id>",
62
+ " pharo-agent image remove <id>",
32
63
  " pharo-agent open",
33
64
  " pharo-agent status",
34
65
  " pharo-agent close",
@@ -70,6 +101,28 @@ export async function main(args) {
70
101
  return;
71
102
  }
72
103
  const projectRoot = findProjectRoot();
104
+ if (command === "image") {
105
+ const [subcommand, ...imageArgs] = rest;
106
+ if (subcommand === "list") {
107
+ requireNoArguments("image list", imageArgs);
108
+ console.log(JSON.stringify({ ok: true, ...listImages(projectRoot) }, null, 2));
109
+ return;
110
+ }
111
+ if (subcommand === "add") {
112
+ console.log(JSON.stringify({ ok: true, ...addImage(projectRoot, parseImageAdd(imageArgs)) }, null, 2));
113
+ return;
114
+ }
115
+ if (subcommand === "use" || subcommand === "remove") {
116
+ const [id, ...extra] = imageArgs;
117
+ if (!id || extra.length > 0) {
118
+ throw new Error(`image ${subcommand} requires one image id`);
119
+ }
120
+ const result = subcommand === "use" ? useImage(projectRoot, id) : removeImage(projectRoot, id);
121
+ console.log(JSON.stringify({ ok: true, ...result }, null, 2));
122
+ return;
123
+ }
124
+ throw new Error(`Unsupported image command: ${subcommand ?? ""}\n${usage()}`);
125
+ }
73
126
  if (command === "open") {
74
127
  requireNoArguments(command, rest);
75
128
  console.log(JSON.stringify({ ok: true, ...(await openAgentImage(projectRoot)) }, null, 2));
package/src/images.js ADDED
@@ -0,0 +1,95 @@
1
+ import fs from "node:fs";
2
+ import {
3
+ configuredImages,
4
+ projectIdentity,
5
+ readProjectConfig,
6
+ writeProjectConfig,
7
+ } from "./project.js";
8
+ import { activePath, readActive } from "./runtime.js";
9
+
10
+ export const defaultLoadScript = "pharo/load-mcp.st";
11
+
12
+ function assertNoOpenImage(projectRoot, imageId, action) {
13
+ if (!fs.existsSync(activePath(projectRoot))) return;
14
+ const active = readActive(projectRoot);
15
+ if (active.imageId === imageId) {
16
+ throw new Error(`Image ${imageId} is open. Run pharo-agent close before ${action}.`);
17
+ }
18
+ }
19
+
20
+ function derivedImageName(projectRoot, imageId) {
21
+ const { name } = projectIdentity(projectRoot);
22
+ return `${name}-{workspaceId}-${imageId}`;
23
+ }
24
+
25
+ export function listImages(projectRoot) {
26
+ return {
27
+ images: configuredImages(projectRoot).map((image) => ({
28
+ id: image.id,
29
+ imageName: image.imageName,
30
+ active: image.active === true,
31
+ templateName: image.create?.templateName,
32
+ templateCategory: image.create?.templateCategory,
33
+ })),
34
+ };
35
+ }
36
+
37
+ export function addImage(projectRoot, { id, template, category, profile }) {
38
+ const config = readProjectConfig(projectRoot);
39
+ const images = Array.isArray(config.images) ? config.images : [];
40
+ if (images.some((image) => image.id === id)) {
41
+ throw new Error(`Image ${id} is already configured.`);
42
+ }
43
+ const imageName = derivedImageName(projectRoot, id);
44
+ if (images.some((image) => image.imageName === imageName)) {
45
+ throw new Error(`Image name ${imageName} is already configured.`);
46
+ }
47
+
48
+ const image = {
49
+ id,
50
+ imageName,
51
+ active: !images.some((other) => other.active === true),
52
+ mcp: { loadScript: defaultLoadScript, loadPolicy: "ifMissing" },
53
+ create: {
54
+ kind: "template",
55
+ ...(profile ? { profileId: profile } : {}),
56
+ templateName: template,
57
+ ...(category ? { templateCategory: category } : {}),
58
+ cleanupPolicy: "workspace_cleanup_only",
59
+ },
60
+ };
61
+ writeProjectConfig(projectRoot, { ...config, images: [...images, image] });
62
+ return { id, imageName, active: image.active, templateName: template };
63
+ }
64
+
65
+ export function useImage(projectRoot, id) {
66
+ const config = readProjectConfig(projectRoot);
67
+ const images = Array.isArray(config.images) ? config.images : [];
68
+ if (!images.some((image) => image.id === id)) {
69
+ throw new Error(`Image ${id} is not configured.`);
70
+ }
71
+ for (const image of images) {
72
+ if (image.active === true && image.id !== id) {
73
+ assertNoOpenImage(projectRoot, image.id, "switching the active image");
74
+ }
75
+ }
76
+ writeProjectConfig(projectRoot, {
77
+ ...config,
78
+ images: images.map((image) => ({ ...image, active: image.id === id })),
79
+ });
80
+ return { activeImageId: id };
81
+ }
82
+
83
+ export function removeImage(projectRoot, id) {
84
+ const config = readProjectConfig(projectRoot);
85
+ const images = Array.isArray(config.images) ? config.images : [];
86
+ if (!images.some((image) => image.id === id)) {
87
+ throw new Error(`Image ${id} is not configured.`);
88
+ }
89
+ assertNoOpenImage(projectRoot, id, "removing it");
90
+ writeProjectConfig(projectRoot, {
91
+ ...config,
92
+ images: images.filter((image) => image.id !== id),
93
+ });
94
+ return { removedImageId: id };
95
+ }
package/src/init.js CHANGED
@@ -1,7 +1,13 @@
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 { findProjectRoot, pluginDirectory } from "./project.js";
4
+ import {
5
+ createProjectConfig,
6
+ enclosingProjectRoot,
7
+ pluginDirectory,
8
+ projectConfigFileName,
9
+ projectConfigPath,
10
+ } from "./project.js";
5
11
 
6
12
  const managedStart = "<!-- pharo-agent:begin -->";
7
13
  const managedEnd = "<!-- pharo-agent:end -->";
@@ -71,6 +77,18 @@ function guidance() {
71
77
  function pharoGuide() {
72
78
  return `# Pharo project guide
73
79
 
80
+ \`plexus.project.json\` declares this project's images. A freshly initialized
81
+ project declares none; register one before the first \`pharo-agent open\`:
82
+
83
+ \`\`\`sh
84
+ pharo-agent image list
85
+ pharo-agent image add dev --template '<launcher template name>'
86
+ \`\`\`
87
+
88
+ \`image add\` makes the first registered image active. \`pharo-agent image use
89
+ <id>\` switches the active image, and \`pharo-agent image remove <id>\` drops a
90
+ declaration. Close an image before changing its declaration.
91
+
74
92
  Use \`pharo-agent open\` before live-image work and edit projected source under
75
93
  \`.pharo-agent/mounts\`. Use \`pharo-agent pharo list\` to inspect the complete
76
94
  live MCP tool surface and \`pharo-agent pharo <tool> [arguments-json]\` to call
@@ -108,18 +126,38 @@ Image, Tonel, Iceberg, disk Git, and host processes are separate states.
108
126
  `;
109
127
  }
110
128
 
129
+ function resolveInitProjectRoot(cwd) {
130
+ const projectRoot = path.resolve(cwd);
131
+ if (fs.existsSync(projectConfigPath(projectRoot))) {
132
+ return { projectRoot, createdProjectConfig: false };
133
+ }
134
+
135
+ const enclosing = enclosingProjectRoot(projectRoot);
136
+ if (enclosing) {
137
+ throw new Error(
138
+ `${projectRoot} is inside the PLexus project at ${enclosing}. `
139
+ + `Initialize that project root, or remove its ${projectConfigFileName}.`,
140
+ );
141
+ }
142
+
143
+ return { projectRoot, createdProjectConfig: true };
144
+ }
145
+
111
146
  export function initialize({ host, mountRoot, cwd = process.cwd() }) {
112
147
  const guideName = hostGuide[host];
113
148
  if (!guideName) {
114
149
  throw new Error("--host must be codex, claude, or opencode");
115
150
  }
116
151
 
117
- const projectRoot = findProjectRoot(cwd);
152
+ const { projectRoot, createdProjectConfig } = resolveInitProjectRoot(cwd);
118
153
  const directory = pluginDirectory(projectRoot);
119
154
  const configPath = path.join(directory, "config.json");
120
155
  const pharoGuidePath = path.join(projectRoot, "pharo.AGENTS.md");
121
156
  assertGeneratedGuideCanBeUpdated(pharoGuidePath);
122
157
  assertManagedBlockCanBeUpdated(path.join(projectRoot, guideName));
158
+ if (createdProjectConfig) {
159
+ createProjectConfig(projectRoot);
160
+ }
123
161
  fs.mkdirSync(directory, { recursive: true });
124
162
  if (!fs.existsSync(configPath) || mountRoot !== undefined) {
125
163
  fs.writeFileSync(
@@ -134,5 +172,5 @@ export function initialize({ host, mountRoot, cwd = process.cwd() }) {
134
172
  copyDirectory(path.join(templates, "pharo-skills"), path.join(directory, "skills"));
135
173
  updateManagedBlock(path.join(projectRoot, guideName), guidance());
136
174
 
137
- return { projectRoot, guideName };
175
+ return { projectRoot, guideName, createdProjectConfig };
138
176
  }
package/src/project.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
 
4
+ export const projectConfigFileName = "plexus.project.json";
5
+
4
6
  export function findProjectRoot(start = process.cwd()) {
5
7
  let directory = path.resolve(start);
6
8
 
7
9
  while (true) {
8
- if (fs.existsSync(path.join(directory, "plexus.project.json"))) {
10
+ if (fs.existsSync(path.join(directory, projectConfigFileName))) {
9
11
  return directory;
10
12
  }
11
13
 
@@ -17,19 +19,66 @@ export function findProjectRoot(start = process.cwd()) {
17
19
  }
18
20
  }
19
21
 
22
+ export function enclosingProjectRoot(start) {
23
+ const directory = path.resolve(start);
24
+ const parent = path.dirname(directory);
25
+ if (parent === directory) return undefined;
26
+ try {
27
+ return findProjectRoot(parent);
28
+ } catch {
29
+ return undefined;
30
+ }
31
+ }
32
+
33
+ export function projectConfigPath(projectRoot) {
34
+ return path.join(projectRoot, projectConfigFileName);
35
+ }
36
+
20
37
  export function pluginDirectory(projectRoot) {
21
38
  return path.join(projectRoot, ".pharo-agent");
22
39
  }
23
40
 
24
- export function configuredActiveImageId(projectRoot) {
25
- const configPath = path.join(projectRoot, "plexus.project.json");
26
- let config;
41
+ export function projectIdentity(projectRoot) {
42
+ const name = path.basename(path.resolve(projectRoot));
43
+ const id = name
44
+ .replaceAll(/[^A-Za-z0-9._-]+/g, "-")
45
+ .replaceAll(/^-+|-+$/g, "")
46
+ .toLowerCase();
47
+ return { id: id || "pharo-project", name };
48
+ }
49
+
50
+ export function readProjectConfig(projectRoot) {
51
+ const configPath = projectConfigPath(projectRoot);
27
52
  try {
28
- config = JSON.parse(fs.readFileSync(configPath, "utf8"));
53
+ return JSON.parse(fs.readFileSync(configPath, "utf8"));
29
54
  } catch (error) {
30
55
  throw new Error(`Cannot read ${configPath}: ${error.message}`);
31
56
  }
32
- const images = Array.isArray(config.images) ? config.images.filter((image) => image?.active) : [];
57
+ }
58
+
59
+ export function writeProjectConfig(projectRoot, config) {
60
+ fs.writeFileSync(projectConfigPath(projectRoot), `${JSON.stringify(config, null, 2)}\n`);
61
+ }
62
+
63
+ export function createProjectConfig(projectRoot) {
64
+ const { id, name } = projectIdentity(projectRoot);
65
+ const config = { id, name, images: [] };
66
+ writeProjectConfig(projectRoot, config);
67
+ return config;
68
+ }
69
+
70
+ export function configuredImages(projectRoot) {
71
+ const config = readProjectConfig(projectRoot);
72
+ return Array.isArray(config.images) ? config.images : [];
73
+ }
74
+
75
+ export function configuredActiveImageId(projectRoot) {
76
+ const images = configuredImages(projectRoot).filter((image) => image?.active);
77
+ if (images.length === 0) {
78
+ throw new Error(
79
+ "No active image in plexus.project.json. Register one with pharo-agent image add <id> --template <name>.",
80
+ );
81
+ }
33
82
  if (images.length !== 1 || typeof images[0].id !== "string") {
34
83
  throw new Error(
35
84
  `pharo-agent needs exactly one active image in plexus.project.json; found ${images.length}.`,