@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +9 -8
- package/package.json +3 -3
- package/plugin.json +1 -1
- package/skills/pharo-feedback/SKILL.md +1 -1
- package/skills/pharo-setup/SKILL.md +11 -27
- package/src/cli.js +65 -39
- package/src/gateway.js +33 -0
- package/src/help.js +25 -39
- package/src/hosts.js +3 -1
- package/src/images.js +3 -2
- package/src/init.js +68 -53
- package/src/live.js +127 -120
- package/src/mcpServer.js +23 -35
- package/src/mcpYaml.js +102 -0
- package/src/runtime.js +17 -1
- package/src/toolCommand.js +216 -0
package/src/mcpYaml.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// tool_get answers a tool's contract as YAML, written by MCP's MCPYamlWriter,
|
|
2
|
+
// and through PLexus that text is all there is: no structured content. The
|
|
3
|
+
// writer's subset is small and fixed, so it is read here rather than with a
|
|
4
|
+
// YAML library: block mappings indented by two spaces, `{}` and `[]`, flow
|
|
5
|
+
// lists of scalars, block lists whose entries sit at their key's indent, and
|
|
6
|
+
// scalars that are either plain words or JSON (quoted strings, numbers, true,
|
|
7
|
+
// false, null).
|
|
8
|
+
|
|
9
|
+
function indentOf(line) {
|
|
10
|
+
return line.length - line.trimStart().length;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function scalar(text) {
|
|
14
|
+
const value = text.trim();
|
|
15
|
+
if (value.startsWith("\"")) return JSON.parse(value);
|
|
16
|
+
if (value === "true") return true;
|
|
17
|
+
if (value === "false") return false;
|
|
18
|
+
if (value === "null") return null;
|
|
19
|
+
if (/^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(value)) return Number(value);
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function flowList(text) {
|
|
24
|
+
const inner = text.trim().slice(1, -1);
|
|
25
|
+
const items = [];
|
|
26
|
+
let current = "";
|
|
27
|
+
let quoted = false;
|
|
28
|
+
for (let index = 0; index < inner.length; index += 1) {
|
|
29
|
+
const character = inner[index];
|
|
30
|
+
if (quoted && character === "\\") {
|
|
31
|
+
current += character + inner[index + 1];
|
|
32
|
+
index += 1;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (character === "\"") quoted = !quoted;
|
|
36
|
+
if (!quoted && character === ",") {
|
|
37
|
+
items.push(scalar(current));
|
|
38
|
+
current = "";
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
current += character;
|
|
42
|
+
}
|
|
43
|
+
if (current.trim()) items.push(scalar(current));
|
|
44
|
+
return items;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A key is a plain word or a JSON string, followed by a colon and then a
|
|
48
|
+
// space or the end of the line.
|
|
49
|
+
const keyPattern = /^("(?:[^"\\]|\\.)*"|[^\s"][^:]*?):(?: (.*))?$/;
|
|
50
|
+
|
|
51
|
+
function inlineValue(text) {
|
|
52
|
+
const value = text.trim();
|
|
53
|
+
if (value === "{}") return {};
|
|
54
|
+
if (value.startsWith("[")) return flowList(value);
|
|
55
|
+
return scalar(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseMapping(lines, start, indent) {
|
|
59
|
+
const mapping = {};
|
|
60
|
+
let index = start;
|
|
61
|
+
while (index < lines.length && indentOf(lines[index]) === indent && !lines[index].trimStart().startsWith("- ")) {
|
|
62
|
+
const match = keyPattern.exec(lines[index].slice(indent));
|
|
63
|
+
if (!match) throw new Error(`Unreadable tool contract line: ${lines[index]}`);
|
|
64
|
+
const key = match[1].startsWith("\"") ? JSON.parse(match[1]) : match[1];
|
|
65
|
+
index += 1;
|
|
66
|
+
if (match[2] !== undefined) {
|
|
67
|
+
mapping[key] = inlineValue(match[2]);
|
|
68
|
+
} else if (index < lines.length && indentOf(lines[index]) === indent && lines[index].trimStart().startsWith("- ")) {
|
|
69
|
+
[mapping[key], index] = parseSequence(lines, index, indent);
|
|
70
|
+
} else {
|
|
71
|
+
[mapping[key], index] = parseMapping(lines, index, indent + 2);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return [mapping, index];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseSequence(lines, start, indent) {
|
|
78
|
+
const sequence = [];
|
|
79
|
+
let index = start;
|
|
80
|
+
while (index < lines.length && indentOf(lines[index]) === indent && lines[index].trimStart().startsWith("- ")) {
|
|
81
|
+
const entry = lines[index].slice(indent + 2);
|
|
82
|
+
if (keyPattern.test(entry)) {
|
|
83
|
+
const nested = [`${" ".repeat(indent + 2)}${entry}`, ...lines.slice(index + 1)];
|
|
84
|
+
const [mapping, consumed] = parseMapping(nested, 0, indent + 2);
|
|
85
|
+
sequence.push(mapping);
|
|
86
|
+
index += consumed;
|
|
87
|
+
} else {
|
|
88
|
+
sequence.push(inlineValue(entry));
|
|
89
|
+
index += 1;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return [sequence, index];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The contract tool_get answered, or undefined when the text holds none. */
|
|
96
|
+
export function toolContractFrom(text) {
|
|
97
|
+
const lines = String(text).split("\n");
|
|
98
|
+
const start = lines.findIndex((line) => line === "tool:");
|
|
99
|
+
if (start === -1) return undefined;
|
|
100
|
+
const [contract] = parseMapping(lines, start + 1, 2);
|
|
101
|
+
return contract;
|
|
102
|
+
}
|
package/src/runtime.js
CHANGED
|
@@ -81,10 +81,26 @@ export function defaultImageId(projectRoot) {
|
|
|
81
81
|
);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/** The error for an image id this project does not declare, naming the ones it does. */
|
|
85
|
+
export function notConfigured(projectRoot, imageId) {
|
|
86
|
+
const ids = configuredImages(projectRoot).map((image) => image.id);
|
|
87
|
+
if (ids.length === 0) {
|
|
88
|
+
return new Error(`Image ${imageId} is not configured, and no image is yet. Declare one with pharo-agent image add.`);
|
|
89
|
+
}
|
|
90
|
+
let defaultId;
|
|
91
|
+
try {
|
|
92
|
+
defaultId = defaultImageId(projectRoot);
|
|
93
|
+
} catch {
|
|
94
|
+
defaultId = undefined;
|
|
95
|
+
}
|
|
96
|
+
const named = ids.map((id) => (id === defaultId ? `${id} (the default)` : id)).join(", ");
|
|
97
|
+
return new Error(`Image ${imageId} is not configured. This project declares ${named}.`);
|
|
98
|
+
}
|
|
99
|
+
|
|
84
100
|
export function resolveImageId(projectRoot, imageId) {
|
|
85
101
|
if (imageId === undefined) return defaultImageId(projectRoot);
|
|
86
102
|
if (!configuredImages(projectRoot).some((image) => image.id === imageId)) {
|
|
87
|
-
throw
|
|
103
|
+
throw notConfigured(projectRoot, imageId);
|
|
88
104
|
}
|
|
89
105
|
return imageId;
|
|
90
106
|
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// An image tool called from the shell, the way an agent reaches for it:
|
|
2
|
+
// `pharo-agent class_get Point`, `pharo-agent method_compile --class Point
|
|
3
|
+
// --source - <<'EOF'`. The flags are the tool's own arguments, read from its
|
|
4
|
+
// schema, so a number reaches the image as a number and a boolean flag needs
|
|
5
|
+
// no value. An argument that is itself an object takes JSON as its value.
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
|
|
8
|
+
// The CLI's own options, which no tool argument can shadow.
|
|
9
|
+
export const cliOptions = new Set(["--image", "--json", "--help"]);
|
|
10
|
+
|
|
11
|
+
/** A word the CLI reads as a tool: every image tool is snake_case. */
|
|
12
|
+
export function isToolName(word) {
|
|
13
|
+
return /^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$/.test(word);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function kebab(name) {
|
|
17
|
+
return name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function typesOf(property) {
|
|
21
|
+
return [property?.type].flat().filter(Boolean);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function flagsText(schema) {
|
|
25
|
+
return Object.keys(schema?.properties ?? {}).map((name) => `--${name}`).join(", ") || "none";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The property a bare value fills: the one required, or the only one. */
|
|
29
|
+
export function positionalProperty(schema) {
|
|
30
|
+
const required = schema?.required ?? [];
|
|
31
|
+
if (required.length === 1) return required[0];
|
|
32
|
+
const names = Object.keys(schema?.properties ?? {});
|
|
33
|
+
return required.length === 0 && names.length === 1 ? names[0] : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function propertyNamed(flag, schema, toolName) {
|
|
37
|
+
const properties = schema?.properties ?? {};
|
|
38
|
+
const name = Object.keys(properties).find((each) => each === flag || kebab(each) === flag);
|
|
39
|
+
if (!name) {
|
|
40
|
+
throw new Error(`${toolName} has no argument --${flag}. Its arguments: ${flagsText(schema)}.`);
|
|
41
|
+
}
|
|
42
|
+
return name;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function scalar(value, property, name, toolName) {
|
|
46
|
+
const types = typesOf(property);
|
|
47
|
+
if (types.includes("integer") || types.includes("number")) {
|
|
48
|
+
const number = Number(value);
|
|
49
|
+
if (value.trim() === "" || !Number.isFinite(number)) {
|
|
50
|
+
throw new Error(`${toolName} --${name} takes a number, not ${JSON.stringify(value)}.`);
|
|
51
|
+
}
|
|
52
|
+
return number;
|
|
53
|
+
}
|
|
54
|
+
if (types.includes("boolean") && !types.includes("string")) {
|
|
55
|
+
if (value === "true" || value === "false") return value === "true";
|
|
56
|
+
throw new Error(`${toolName} --${name} is true or false, not ${JSON.stringify(value)}.`);
|
|
57
|
+
}
|
|
58
|
+
if (types.includes("object") && !types.includes("string")) {
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(value);
|
|
61
|
+
} catch {
|
|
62
|
+
throw new Error(`${toolName} --${name} takes a JSON object.`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// An array argument gathers each use of its flag; an argument that takes a
|
|
69
|
+
// string or an array of them, such as method_compile's source, is a string
|
|
70
|
+
// until the flag comes a second time.
|
|
71
|
+
function assign(parsed, name, value, property, toolName) {
|
|
72
|
+
const types = typesOf(property);
|
|
73
|
+
if (types.includes("array")) {
|
|
74
|
+
const item = scalar(value, property.items ?? {}, name, toolName);
|
|
75
|
+
if (!Object.hasOwn(parsed, name)) {
|
|
76
|
+
parsed[name] = types.includes("string") ? value : [item];
|
|
77
|
+
} else {
|
|
78
|
+
parsed[name] = [parsed[name]].flat().concat(item);
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (Object.hasOwn(parsed, name)) {
|
|
83
|
+
throw new Error(`${toolName} takes --${name} once.`);
|
|
84
|
+
}
|
|
85
|
+
parsed[name] = scalar(value, property, name, toolName);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The tool arguments the words give. `schema` is the tool's input schema;
|
|
90
|
+
* `readStdin` answers standard input, for a value written as `-`.
|
|
91
|
+
*/
|
|
92
|
+
export function toolArguments(toolName, words, schema, readStdin = () => fs.readFileSync(0, "utf8")) {
|
|
93
|
+
const properties = schema?.properties ?? {};
|
|
94
|
+
const flags = {};
|
|
95
|
+
let stdinUsed = false;
|
|
96
|
+
const valueOf = (word) => {
|
|
97
|
+
if (word !== "-") return word;
|
|
98
|
+
if (stdinUsed) throw new Error(`${toolName} reads one value from stdin, not two.`);
|
|
99
|
+
stdinUsed = true;
|
|
100
|
+
return readStdin().replace(/\r?\n$/, "");
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
for (let index = 0; index < words.length; index += 1) {
|
|
104
|
+
const word = words[index];
|
|
105
|
+
if (word.startsWith("--") && word.length > 2) {
|
|
106
|
+
const [flag, inline] = word.slice(2).split(/=(.*)/s, 2);
|
|
107
|
+
const negated = flag.startsWith("no-") && !Object.hasOwn(properties, flag)
|
|
108
|
+
&& !Object.keys(properties).some((each) => kebab(each) === flag);
|
|
109
|
+
const name = propertyNamed(negated ? flag.slice(3) : flag, schema, toolName);
|
|
110
|
+
const property = properties[name];
|
|
111
|
+
const types = typesOf(property);
|
|
112
|
+
if (types.includes("boolean") && !types.includes("string")) {
|
|
113
|
+
if (inline !== undefined) assign(flags, name, inline, property, toolName);
|
|
114
|
+
else if (words[index + 1] === "true" || words[index + 1] === "false") {
|
|
115
|
+
assign(flags, name, words[index + 1], property, toolName);
|
|
116
|
+
index += 1;
|
|
117
|
+
} else {
|
|
118
|
+
assign(flags, name, negated ? "false" : "true", property, toolName);
|
|
119
|
+
}
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (negated) throw new Error(`${toolName} --${name} is not a boolean, so --no-${flag.slice(3)} means nothing.`);
|
|
123
|
+
let value = inline;
|
|
124
|
+
if (value === undefined) {
|
|
125
|
+
value = words[index + 1];
|
|
126
|
+
if (value === undefined) throw new Error(`${toolName} --${name} needs a value; - reads it from stdin.`);
|
|
127
|
+
index += 1;
|
|
128
|
+
}
|
|
129
|
+
assign(flags, name, valueOf(value), property, toolName);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const positional = positionalProperty(schema);
|
|
133
|
+
if (!positional) {
|
|
134
|
+
throw new Error(`${toolName} takes its arguments as flags: ${flagsText(schema)}.`);
|
|
135
|
+
}
|
|
136
|
+
// Bare words for a text argument are one text, as a shell splits
|
|
137
|
+
// `tool_search repository commit` into two.
|
|
138
|
+
const types = typesOf(properties[positional]);
|
|
139
|
+
if (Object.hasOwn(flags, positional) && types.includes("string") && !types.includes("array")) {
|
|
140
|
+
flags[positional] = `${flags[positional]} ${valueOf(word)}`;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
assign(flags, positional, valueOf(word), properties[positional], toolName);
|
|
144
|
+
}
|
|
145
|
+
return flags;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** What a tool answered, as the text it wrote for a reader. */
|
|
149
|
+
export function resultText(result) {
|
|
150
|
+
const texts = (result?.content ?? [])
|
|
151
|
+
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
|
152
|
+
.map((part) => part.text);
|
|
153
|
+
if (texts.length > 0) return texts.join("\n");
|
|
154
|
+
return JSON.stringify(result?.structuredContent ?? result ?? {}, null, 2);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function firstLine(text) {
|
|
158
|
+
return String(text ?? "").split("\n", 1)[0].trim();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// What a list has room for: the first sentence, which every tool's
|
|
162
|
+
// description opens with what it does.
|
|
163
|
+
function firstSentence(text) {
|
|
164
|
+
const line = firstLine(text);
|
|
165
|
+
const end = line.search(/\.(\s|$)/);
|
|
166
|
+
return end === -1 ? line : line.slice(0, end + 1);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** One line per tool, then where the rest of the catalogue is. */
|
|
170
|
+
export function toolListing(tools) {
|
|
171
|
+
const width = Math.max(0, ...tools.map((tool) => tool.name.length));
|
|
172
|
+
return [
|
|
173
|
+
...tools.map((tool) => `${tool.name.padEnd(width)} ${firstSentence(tool.description)}`),
|
|
174
|
+
"",
|
|
175
|
+
"These are the advertised tools. pharo-agent tool_search <words> finds the rest;",
|
|
176
|
+
"pharo-agent <tool> --help shows a tool's arguments.",
|
|
177
|
+
].join("\n");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function typeText(property) {
|
|
181
|
+
const types = typesOf(property);
|
|
182
|
+
if (types.includes("array")) {
|
|
183
|
+
return `<${typesOf(property.items).join("|") || "value"}>, repeatable`;
|
|
184
|
+
}
|
|
185
|
+
if (types.includes("boolean")) return "";
|
|
186
|
+
return `<${types.join("|") || "value"}>`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** A tool's own usage, from its schema. */
|
|
190
|
+
export function toolUsage(tool) {
|
|
191
|
+
const schema = tool.inputSchema ?? {};
|
|
192
|
+
const required = new Set(schema.required ?? []);
|
|
193
|
+
const positional = positionalProperty(schema);
|
|
194
|
+
const entries = Object.entries(schema.properties ?? {}).map(([name, property]) => {
|
|
195
|
+
const head = [`--${name}`, typeText(property), required.has(name) ? "(required)" : ""]
|
|
196
|
+
.filter(Boolean).join(" ");
|
|
197
|
+
return { head, description: firstLine(property.description) };
|
|
198
|
+
});
|
|
199
|
+
const width = Math.max(0, ...entries.map((entry) => entry.head.length));
|
|
200
|
+
const usageLine = `pharo-agent ${tool.name}${positional ? ` [<${positional}>]` : ""}`
|
|
201
|
+
+ `${entries.length ? " [--<argument> <value>]..." : ""} [--image <id>] [--json]`;
|
|
202
|
+
return [
|
|
203
|
+
"Usage:",
|
|
204
|
+
` ${usageLine}`,
|
|
205
|
+
"",
|
|
206
|
+
firstLine(tool.description) ? String(tool.description).trim() : "",
|
|
207
|
+
...(entries.length ? ["", "Arguments:", ...entries.map((entry) => ` ${entry.head.padEnd(width)} ${entry.description}`)] : []),
|
|
208
|
+
"",
|
|
209
|
+
[
|
|
210
|
+
positional ? `A bare value is --${positional}.` : "",
|
|
211
|
+
Object.values(schema.properties ?? {}).some((property) => typesOf(property).includes("boolean"))
|
|
212
|
+
? "A flag without a type is true alone, false as --no-<flag>." : "",
|
|
213
|
+
"A value of - is read from stdin. --json prints the MCP result.",
|
|
214
|
+
].filter(Boolean).join(" "),
|
|
215
|
+
].join("\n");
|
|
216
|
+
}
|