@bluewombat/slots 0.4.0 → 0.5.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/README.md +23 -9
- package/agents/claude-extras.mjs +77 -5
- package/agents/claude.mjs +19 -2
- package/agents/cursor-extras.mjs +103 -5
- package/agents/cursor.mjs +132 -4
- package/agents/tool-facts.mjs +77 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -92,8 +92,12 @@ or `--report`. A role slot after `--` is what names one:
|
|
|
92
92
|
- ./.mason/transcripts
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
-
`cursor.mjs` needs `cursor-agent` on PATH
|
|
96
|
-
|
|
95
|
+
`cursor.mjs` needs `cursor-agent` on PATH. Without `--agent-home` it uses the
|
|
96
|
+
CLI's own login (`cursor-agent login`). With `--agent-home DIR` and
|
|
97
|
+
`--api-key-env NAME` the vendor process gets that directory as `HOME` and
|
|
98
|
+
`CURSOR_API_KEY` from `NAME`; the value never appears on the argv. `claude.mjs`
|
|
99
|
+
needs `claude`, signed in once, and refuses those two flags: Claude is not
|
|
100
|
+
isolated yet, and starting it would keep the operator's own config.
|
|
97
101
|
|
|
98
102
|
| Option | Meaning |
|
|
99
103
|
| ---------------------------------- | -------------------------------------------------------------------------------- |
|
|
@@ -106,25 +110,34 @@ needs `claude`, signed in once.
|
|
|
106
110
|
| `--transcript-dir PATH` | Write one file per turn under here. Off unless given |
|
|
107
111
|
| `--transcript-part NAME` | `prompt`, `stdout`, `stderr`, `timing`. Repeatable. Default: all |
|
|
108
112
|
| `--agent-arg VALUE` | Appended to the CLI argv, before the prompt. Repeatable |
|
|
113
|
+
| `--agent-home DIR` | Isolate the vendor process: its `HOME` is this directory. Cursor only; Claude refuses |
|
|
114
|
+
| `--api-key-env NAME` | With `--agent-home`, read `process.env[NAME]` for the vendor key. Never the value itself |
|
|
109
115
|
| `--id` / `--attempt` / `--context` | How a transcript is filed. Forwarded by the role |
|
|
110
116
|
|
|
111
117
|
The agent CLI prints **one JSON line** — a `SerializedRun` from
|
|
112
|
-
`@bluewombat/slot-kit` — with the vendor's raw streams plus
|
|
118
|
+
`@bluewombat/slot-kit` — with the vendor's raw streams plus three extras the
|
|
113
119
|
wrapper fills when it can:
|
|
114
120
|
|
|
115
121
|
| Field | Meaning |
|
|
116
122
|
| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
117
123
|
| `skills` | `null` = unknown (not stream-json, or unparseable). `[]` = looked, none used. Otherwise skill names in order |
|
|
118
124
|
| `usage` | `null` = unknown. Otherwise token counts (`input`, `output`, `cacheRead`, `cacheWrite`) and `costUsd` (`null` when the vendor did not report a cost) |
|
|
125
|
+
| `tools` | `null` = unknown (not stream-json, or unparseable). Otherwise what the agent did with its tools: `calls` per tool name, `busyMs` (time with a tool running; `null` when the stream has no times, as Claude's), and the lists `web` (pages fetched, searches, URLs a shell command named), `mcp` (`server/tool`, plus `getMcpTools` when the agent only listed a server's tools), `subagents` (by description), `outside` (paths named outside the working directory, skill files excepted) |
|
|
119
126
|
|
|
120
127
|
Cursor extracts skills from `stream-json` `readToolCall` paths ending in
|
|
121
128
|
`/skills/<name>/SKILL.md`. Claude extracts them from `tool_use` blocks named
|
|
122
129
|
`Skill` (`input.skill`), and adds `--verbose` whenever the format is
|
|
123
|
-
`stream-json`.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
130
|
+
`stream-json`. Tools come from Cursor's `<name>ToolCall` events (`webFetch`,
|
|
131
|
+
`mcp`, `task`, `shell`, … — names seen on cursor-agent 2026.10.01) and from
|
|
132
|
+
Claude's `tool_use` blocks (`WebFetch`, `WebSearch`, `Bash`, `Task`,
|
|
133
|
+
`mcp__<server>__<tool>`); the vendor-free part is `agents/tool-facts.mjs`. A
|
|
134
|
+
future vendor agent (`codex.mjs`, …) uses the same fields: leave them `null`
|
|
135
|
+
when it cannot tell.
|
|
136
|
+
|
|
137
|
+
When `--transcript-dir` is set, the transcript header repeats `skills`,
|
|
138
|
+
`usage` and the tools — `tools`, `web`, `mcp`, `subagents`, `outside the
|
|
139
|
+
workspace` — each `unknown`, `(none)`, or the values. "Did it read the page I
|
|
140
|
+
linked?" is answered there, without reading the stream.
|
|
128
141
|
|
|
129
142
|
A global npm install belongs to one Node version: after `nvm use`, a `claude`
|
|
130
143
|
installed under another version is off PATH. Give `--bin` the absolute path, or
|
|
@@ -250,7 +263,8 @@ loop that misbehaves cannot be read back, only guessed at.
|
|
|
250
263
|
|
|
251
264
|
`--transcript-dir` writes one Markdown file per turn: the invocation, the prompt
|
|
252
265
|
as sent, stdout, stderr, how long it took, and — when the agent wrapper passed
|
|
253
|
-
them — `skills
|
|
266
|
+
them — `skills`, `usage` and what the tools did (`unknown`, `(none)`, or the
|
|
267
|
+
values). Files are
|
|
254
268
|
filed under the Feature the Task belongs to, named for the Subtask and the
|
|
255
269
|
Attempt. It belongs on the **agent** command, after `--`.
|
|
256
270
|
|
package/agents/claude-extras.mjs
CHANGED
|
@@ -9,24 +9,96 @@
|
|
|
9
9
|
* Usage: terminal `result` documents `usage` (`input_tokens`, …) and
|
|
10
10
|
* `total_cost_usd`.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* Tools: every `tool_use` block of an assistant message, counted under its
|
|
13
|
+
* `name`, as Claude Code's tools reference names them: `WebFetch` (`input.url`),
|
|
14
|
+
* `WebSearch` (`input.query`), `Bash` (`input.command`), `Task` / `Agent`
|
|
15
|
+
* (`input.description`), an MCP tool as `mcp__<server>__<tool>`, and
|
|
16
|
+
* `file_path` / `path` on the file tools. The stream carries no times:
|
|
17
|
+
* `busyMs` stays null.
|
|
18
|
+
*
|
|
19
|
+
* `skills` and `tools` are `null` when stdout is not NDJSON we can parse;
|
|
20
|
+
* `skills` is `[]` when parsed with no Skill tool_use.
|
|
14
21
|
*/
|
|
15
22
|
|
|
16
23
|
import { readResult } from "@bluewombat/slot-kit";
|
|
24
|
+
import { count, emptyTools, isOutside, urlsIn } from "./tool-facts.mjs";
|
|
17
25
|
|
|
18
26
|
/**
|
|
19
27
|
* @param {string} stdout
|
|
20
28
|
* @param {string} outputFormat
|
|
21
|
-
* @
|
|
29
|
+
* @param {string} cwd the directory the agent worked in
|
|
30
|
+
* @returns {import("@bluewombat/slot-kit").AgentExtras}
|
|
22
31
|
*/
|
|
23
|
-
export function extrasFromClaude(stdout, outputFormat) {
|
|
32
|
+
export function extrasFromClaude(stdout, outputFormat, cwd) {
|
|
33
|
+
const streamed = outputFormat === "stream-json";
|
|
24
34
|
return {
|
|
25
|
-
skills:
|
|
35
|
+
skills: streamed ? skillsFromClaude(stdout) : null,
|
|
26
36
|
usage: usageFromClaude(stdout),
|
|
37
|
+
tools: streamed ? toolsFromClaude(stdout, cwd) : null,
|
|
27
38
|
};
|
|
28
39
|
}
|
|
29
40
|
|
|
41
|
+
/**
|
|
42
|
+
* @param {string} stdout
|
|
43
|
+
* @param {string} cwd the directory the agent worked in
|
|
44
|
+
* @returns {import("@bluewombat/slot-kit").AgentTools | null}
|
|
45
|
+
*/
|
|
46
|
+
export function toolsFromClaude(stdout, cwd) {
|
|
47
|
+
const tools = emptyTools();
|
|
48
|
+
/** @type {Set<string>} */
|
|
49
|
+
const seen = new Set();
|
|
50
|
+
let parsedAny = false;
|
|
51
|
+
for (const line of stdout.split("\n")) {
|
|
52
|
+
let event;
|
|
53
|
+
try {
|
|
54
|
+
event = JSON.parse(line);
|
|
55
|
+
} catch {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (event === null || typeof event !== "object" || Array.isArray(event)) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
parsedAny = true;
|
|
62
|
+
const content = event.type === "assistant" ? event.message?.content : undefined;
|
|
63
|
+
if (!Array.isArray(content)) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
for (const block of content) {
|
|
67
|
+
if (block?.type !== "tool_use" || typeof block.name !== "string") {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
// A partial-message stream repeats a block; its id says it once.
|
|
71
|
+
if (typeof block.id === "string") {
|
|
72
|
+
if (seen.has(block.id)) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
seen.add(block.id);
|
|
76
|
+
}
|
|
77
|
+
const name = block.name;
|
|
78
|
+
const input = block.input ?? {};
|
|
79
|
+
count(tools, name);
|
|
80
|
+
const mcp = /^mcp__(.+?)__(.+)$/.exec(name);
|
|
81
|
+
if (mcp !== null) {
|
|
82
|
+
tools.mcp.push(`${mcp[1]}/${mcp[2]}`);
|
|
83
|
+
} else if (name === "WebFetch" && typeof input.url === "string") {
|
|
84
|
+
tools.web.push(input.url);
|
|
85
|
+
} else if (name === "WebSearch" && typeof input.query === "string") {
|
|
86
|
+
tools.web.push(`search: ${input.query}`);
|
|
87
|
+
} else if (name === "Bash") {
|
|
88
|
+
tools.web.push(...urlsIn(input.command).map((url) => `${url} (shell)`));
|
|
89
|
+
} else if ((name === "Task" || name === "Agent") && typeof input.description === "string") {
|
|
90
|
+
tools.subagents.push(input.description);
|
|
91
|
+
}
|
|
92
|
+
for (const path of [input.file_path, input.path, input.notebook_path]) {
|
|
93
|
+
if (isOutside(path, cwd)) {
|
|
94
|
+
tools.outside.push(path);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return parsedAny ? tools : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
30
102
|
/**
|
|
31
103
|
* @param {string} stdout
|
|
32
104
|
* @returns {string[] | null}
|
package/agents/claude.mjs
CHANGED
|
@@ -26,6 +26,8 @@ import { extrasFromClaude } from "./claude-extras.mjs";
|
|
|
26
26
|
// --transcript-dir PATH write one file per turn. Off by default
|
|
27
27
|
// --transcript-part NAME prompt, stdout, stderr, timing. Repeatable
|
|
28
28
|
// --agent-arg VALUE appended before the prompt. Repeatable
|
|
29
|
+
// --agent-home / --api-key-env recognised, then refused. Claude is not
|
|
30
|
+
// isolated yet; starting would keep the operator's config
|
|
29
31
|
// --id / --attempt / --context how a transcript is filed
|
|
30
32
|
|
|
31
33
|
const NAME = "Claude Code";
|
|
@@ -47,6 +49,15 @@ if (!parsed.ok) {
|
|
|
47
49
|
}
|
|
48
50
|
const options = parsed.values;
|
|
49
51
|
|
|
52
|
+
if (
|
|
53
|
+
textOption(options, "agentHome") !== undefined ||
|
|
54
|
+
textOption(options, "apiKeyEnv") !== undefined
|
|
55
|
+
) {
|
|
56
|
+
refuse(
|
|
57
|
+
"Claude Code does not isolate the operator home yet. Remove --agent-home and --api-key-env; the vendor was not started.",
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
50
61
|
const loaded = readAgentPrompt(options);
|
|
51
62
|
if (!loaded.ok) {
|
|
52
63
|
refuse(loaded.reason);
|
|
@@ -83,7 +94,7 @@ const transcript = transcriptFor(process.argv, options, {
|
|
|
83
94
|
prompt,
|
|
84
95
|
});
|
|
85
96
|
const run = await runAgent({ file: bin, args, cwd });
|
|
86
|
-
const extras = extrasFromClaude(run.stdout, outputFormat);
|
|
97
|
+
const extras = extrasFromClaude(run.stdout, outputFormat, cwd);
|
|
87
98
|
transcript.write(run, extras);
|
|
88
99
|
answer(run, bin, extras);
|
|
89
100
|
|
|
@@ -102,7 +113,13 @@ function refuse(reason) {
|
|
|
102
113
|
function answer(run, binPath, extras) {
|
|
103
114
|
writeContract(
|
|
104
115
|
`${JSON.stringify(
|
|
105
|
-
serializeRun(run, {
|
|
116
|
+
serializeRun(run, {
|
|
117
|
+
name: NAME,
|
|
118
|
+
bin: binPath,
|
|
119
|
+
skills: extras.skills,
|
|
120
|
+
usage: extras.usage,
|
|
121
|
+
tools: extras.tools,
|
|
122
|
+
}),
|
|
106
123
|
)}\n`,
|
|
107
124
|
);
|
|
108
125
|
process.exit(0);
|
package/agents/cursor-extras.mjs
CHANGED
|
@@ -9,24 +9,122 @@
|
|
|
9
9
|
* with camelCase token fields (observed; not in the published json schema).
|
|
10
10
|
* No cost field is documented for Cursor — `costUsd` stays null.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
* `
|
|
12
|
+
* Tools: every `tool_call` (`<name>ToolCall`, args under it) counted once, on
|
|
13
|
+
* `started`; its `completed` closes the interval for the busy time
|
|
14
|
+
* (`timestamp_ms`). Observed on cursor-agent 2026.10.01: `webFetchToolCall`
|
|
15
|
+
* (`args.url`), `mcpToolCall` (`args.providerIdentifier` / `args.toolName`),
|
|
16
|
+
* `getMcpToolsToolCall` (a catalogue read, counted in `mcp` so a server that
|
|
17
|
+
* was only listed still shows up), `taskToolCall` (`args.description`),
|
|
18
|
+
* `shellToolCall` (`args.command`), and `path` / `targetDirectory` on the
|
|
19
|
+
* file tools.
|
|
20
|
+
*
|
|
21
|
+
* `skills` and `tools` are `null` when the stdout is not NDJSON we can parse
|
|
22
|
+
* (unknown); `skills` is `[]` when we parsed and found no skill reads.
|
|
14
23
|
*/
|
|
15
24
|
|
|
16
25
|
import { readResult } from "@bluewombat/slot-kit";
|
|
26
|
+
import { busyMs, count, emptyTools, isOutside, urlsIn } from "./tool-facts.mjs";
|
|
17
27
|
|
|
18
28
|
/**
|
|
19
29
|
* @param {string} stdout
|
|
20
30
|
* @param {string} outputFormat
|
|
21
|
-
* @
|
|
31
|
+
* @param {string} cwd the directory the agent worked in
|
|
32
|
+
* @returns {import("@bluewombat/slot-kit").AgentExtras}
|
|
22
33
|
*/
|
|
23
|
-
export function extrasFromCursor(stdout, outputFormat) {
|
|
34
|
+
export function extrasFromCursor(stdout, outputFormat, cwd) {
|
|
35
|
+
const streamed = outputFormat === "stream-json";
|
|
24
36
|
return {
|
|
25
|
-
skills:
|
|
37
|
+
skills: streamed ? skillsFromCursor(stdout) : null,
|
|
26
38
|
usage: usageFromCursor(stdout),
|
|
39
|
+
tools: streamed ? toolsFromCursor(stdout, cwd) : null,
|
|
27
40
|
};
|
|
28
41
|
}
|
|
29
42
|
|
|
43
|
+
/**
|
|
44
|
+
* @param {string} stdout
|
|
45
|
+
* @param {string} cwd the directory the agent worked in
|
|
46
|
+
* @returns {import("@bluewombat/slot-kit").AgentTools | null}
|
|
47
|
+
*/
|
|
48
|
+
export function toolsFromCursor(stdout, cwd) {
|
|
49
|
+
const tools = emptyTools();
|
|
50
|
+
/** @type {Map<string, number>} */
|
|
51
|
+
const open = new Map();
|
|
52
|
+
/** @type {[number, number][]} */
|
|
53
|
+
const intervals = [];
|
|
54
|
+
let parsedAny = false;
|
|
55
|
+
for (const line of stdout.split("\n")) {
|
|
56
|
+
let event;
|
|
57
|
+
try {
|
|
58
|
+
event = JSON.parse(line);
|
|
59
|
+
} catch {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (event === null || typeof event !== "object" || Array.isArray(event)) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
parsedAny = true;
|
|
66
|
+
if (
|
|
67
|
+
event.type !== "tool_call" ||
|
|
68
|
+
event.tool_call === null ||
|
|
69
|
+
typeof event.tool_call !== "object"
|
|
70
|
+
) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const callId = typeof event.call_id === "string" ? event.call_id : "";
|
|
74
|
+
const at = Number(event.timestamp_ms);
|
|
75
|
+
if (event.subtype === "completed") {
|
|
76
|
+
const started = open.get(callId);
|
|
77
|
+
if (started !== undefined && Number.isFinite(at)) {
|
|
78
|
+
intervals.push([started, at]);
|
|
79
|
+
}
|
|
80
|
+
open.delete(callId);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (event.subtype !== "started" || open.has(callId)) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (Number.isFinite(at)) {
|
|
87
|
+
open.set(callId, at);
|
|
88
|
+
}
|
|
89
|
+
const key = Object.keys(event.tool_call).find((name) => name.endsWith("ToolCall"));
|
|
90
|
+
if (key === undefined) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const name = key.slice(0, -"ToolCall".length);
|
|
94
|
+
const args = event.tool_call[key]?.args ?? {};
|
|
95
|
+
count(tools, name);
|
|
96
|
+
if (name === "webFetch" && typeof args.url === "string") {
|
|
97
|
+
tools.web.push(args.url);
|
|
98
|
+
} else if (name === "webSearch") {
|
|
99
|
+
const term = args.searchTerm ?? args.query;
|
|
100
|
+
if (typeof term === "string") {
|
|
101
|
+
tools.web.push(`search: ${term}`);
|
|
102
|
+
}
|
|
103
|
+
} else if (name === "shell") {
|
|
104
|
+
tools.web.push(...urlsIn(args.command).map((url) => `${url} (shell)`));
|
|
105
|
+
} else if (name === "mcp") {
|
|
106
|
+
tools.mcp.push(`${args.providerIdentifier ?? args.server ?? "?"}/${args.toolName ?? "?"}`);
|
|
107
|
+
} else if (name === "getMcpTools") {
|
|
108
|
+
const server = args.server ?? args.providerIdentifier ?? args.provider;
|
|
109
|
+
tools.mcp.push(
|
|
110
|
+
typeof server === "string" && server.length > 0 ? `${server}/getMcpTools` : "getMcpTools",
|
|
111
|
+
);
|
|
112
|
+
} else if (name === "task" && typeof args.description === "string") {
|
|
113
|
+
tools.subagents.push(args.description);
|
|
114
|
+
}
|
|
115
|
+
for (const path of [args.path, args.targetDirectory]) {
|
|
116
|
+
if (isOutside(path, cwd)) {
|
|
117
|
+
tools.outside.push(path);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (!parsedAny) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
tools.busyMs = intervals.length === 0 ? null : busyMs(intervals);
|
|
125
|
+
return tools;
|
|
126
|
+
}
|
|
127
|
+
|
|
30
128
|
/**
|
|
31
129
|
* @param {string} stdout
|
|
32
130
|
* @returns {string[] | null}
|
package/agents/cursor.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
4
5
|
import {
|
|
5
6
|
AGENT_OPTIONS,
|
|
6
7
|
findExecutable,
|
|
@@ -25,6 +26,9 @@ import { extrasFromCursor } from "./cursor-extras.mjs";
|
|
|
25
26
|
// --transcript-dir PATH write one file per turn. Off by default
|
|
26
27
|
// --transcript-part NAME prompt, stdout, stderr, timing. Repeatable
|
|
27
28
|
// --agent-arg VALUE appended before the prompt. Repeatable
|
|
29
|
+
// --agent-home DIR isolate this process: HOME is DIR, not the operator's
|
|
30
|
+
// --api-key-env NAME required with --agent-home. Read process.env[NAME];
|
|
31
|
+
// the value is never put on the argv
|
|
28
32
|
// --id / --attempt / --context how a transcript is filed
|
|
29
33
|
|
|
30
34
|
const NAME = "Cursor CLI";
|
|
@@ -70,17 +74,135 @@ const args = [
|
|
|
70
74
|
prompt,
|
|
71
75
|
];
|
|
72
76
|
|
|
77
|
+
const isolation = cursorIsolation(options, cwd);
|
|
78
|
+
if (!isolation.ok) {
|
|
79
|
+
refuse(isolation.reason);
|
|
80
|
+
}
|
|
81
|
+
|
|
73
82
|
const transcript = transcriptFor(process.argv, options, {
|
|
74
83
|
slot: NAME,
|
|
75
84
|
bin,
|
|
76
85
|
args,
|
|
77
86
|
prompt,
|
|
78
87
|
});
|
|
79
|
-
const run = await runAgent({
|
|
80
|
-
|
|
88
|
+
const run = await runAgent({
|
|
89
|
+
file: bin,
|
|
90
|
+
args,
|
|
91
|
+
cwd,
|
|
92
|
+
...(isolation.env === undefined ? {} : { env: isolation.env }),
|
|
93
|
+
});
|
|
94
|
+
const extras = extrasFromCursor(run.stdout, outputFormat, cwd);
|
|
81
95
|
transcript.write(run, extras);
|
|
82
96
|
answer(run, bin, extras);
|
|
83
97
|
|
|
98
|
+
/**
|
|
99
|
+
* `--agent-home` isolates the vendor process. The wrapper keeps the parent's
|
|
100
|
+
* HOME, so `findExecutable` still sees the operator's install directories.
|
|
101
|
+
* No link to the login keychain: auth is the named variable, held in memory.
|
|
102
|
+
*
|
|
103
|
+
* @param {import("@bluewombat/slot-kit").OptionValues} options
|
|
104
|
+
* @param {string} dir
|
|
105
|
+
* @returns {{ ok: true, env: NodeJS.ProcessEnv | undefined } | { ok: false, reason: string }}
|
|
106
|
+
*/
|
|
107
|
+
function cursorIsolation(options, dir) {
|
|
108
|
+
const home = textOption(options, "agentHome");
|
|
109
|
+
const keyEnv = textOption(options, "apiKeyEnv");
|
|
110
|
+
if (home === undefined && keyEnv === undefined) {
|
|
111
|
+
return { ok: true, env: undefined };
|
|
112
|
+
}
|
|
113
|
+
if (home === undefined || keyEnv === undefined) {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
reason: "An isolated Cursor agent needs both --agent-home and --api-key-env.",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(keyEnv)) {
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
reason: `--api-key-env must name an environment variable, not "${keyEnv}".`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const apiKey = process.env[keyEnv];
|
|
126
|
+
if (apiKey === undefined || apiKey.length === 0) {
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
reason: `The environment variable ${keyEnv} is empty. An isolated Cursor agent needs it, or the CLI tries to store credentials in the login keychain.`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return { ok: true, env: isolatedVendorEnv(resolve(dir, home), apiKey) };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @param {string} agentHome
|
|
137
|
+
* @param {string} apiKey
|
|
138
|
+
* @returns {NodeJS.ProcessEnv}
|
|
139
|
+
*/
|
|
140
|
+
function isolatedVendorEnv(agentHome, apiKey) {
|
|
141
|
+
mkdirSync(agentHome, { recursive: true });
|
|
142
|
+
const parentHome = homedir();
|
|
143
|
+
/** @type {NodeJS.ProcessEnv} */
|
|
144
|
+
const env = { ...process.env, HOME: agentHome };
|
|
145
|
+
if (process.env.PATH !== undefined) {
|
|
146
|
+
env.PATH = process.env.PATH;
|
|
147
|
+
}
|
|
148
|
+
env.CURSOR_API_KEY = apiKey;
|
|
149
|
+
env.AGENT_CLI_CREDENTIAL_STORE = "memory";
|
|
150
|
+
const gitConfig = parentGitConfig(parentHome);
|
|
151
|
+
if (gitConfig === undefined) {
|
|
152
|
+
delete env.GIT_CONFIG_GLOBAL;
|
|
153
|
+
} else {
|
|
154
|
+
env.GIT_CONFIG_GLOBAL = gitConfig;
|
|
155
|
+
}
|
|
156
|
+
const npmCache = process.env.npm_config_cache;
|
|
157
|
+
env.npm_config_cache =
|
|
158
|
+
npmCache !== undefined && npmCache.length > 0 ? npmCache : join(parentHome, ".npm");
|
|
159
|
+
const corepack = parentCorepack(parentHome);
|
|
160
|
+
if (corepack === undefined) {
|
|
161
|
+
delete env.COREPACK_HOME;
|
|
162
|
+
} else {
|
|
163
|
+
env.COREPACK_HOME = corepack;
|
|
164
|
+
}
|
|
165
|
+
return env;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The global git file the parent already resolves. `~/.gitconfig` wins over
|
|
170
|
+
* the XDG file, which is git's own order when both exist.
|
|
171
|
+
*
|
|
172
|
+
* @param {string} parentHome
|
|
173
|
+
* @returns {string | undefined}
|
|
174
|
+
*/
|
|
175
|
+
function parentGitConfig(parentHome) {
|
|
176
|
+
const fromEnv = process.env.GIT_CONFIG_GLOBAL;
|
|
177
|
+
if (fromEnv !== undefined && fromEnv.length > 0 && existsSync(fromEnv)) {
|
|
178
|
+
return fromEnv;
|
|
179
|
+
}
|
|
180
|
+
const dot = join(parentHome, ".gitconfig");
|
|
181
|
+
if (existsSync(dot)) {
|
|
182
|
+
return dot;
|
|
183
|
+
}
|
|
184
|
+
const xdgRoot = process.env.XDG_CONFIG_HOME;
|
|
185
|
+
const xdg = join(
|
|
186
|
+
xdgRoot !== undefined && xdgRoot.length > 0 ? xdgRoot : join(parentHome, ".config"),
|
|
187
|
+
"git",
|
|
188
|
+
"config",
|
|
189
|
+
);
|
|
190
|
+
return existsSync(xdg) ? xdg : undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* @param {string} parentHome
|
|
195
|
+
* @returns {string | undefined}
|
|
196
|
+
*/
|
|
197
|
+
function parentCorepack(parentHome) {
|
|
198
|
+
const fromEnv = process.env.COREPACK_HOME;
|
|
199
|
+
if (fromEnv !== undefined && fromEnv.length > 0 && existsSync(fromEnv)) {
|
|
200
|
+
return fromEnv;
|
|
201
|
+
}
|
|
202
|
+
const fallback = join(parentHome, ".cache", "node", "corepack");
|
|
203
|
+
return existsSync(fallback) ? fallback : undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
84
206
|
/** @param {string} reason @returns {never} */
|
|
85
207
|
function refuse(reason) {
|
|
86
208
|
writeContract(`${JSON.stringify({ error: reason })}\n`);
|
|
@@ -96,7 +218,13 @@ function refuse(reason) {
|
|
|
96
218
|
function answer(run, binPath, extras) {
|
|
97
219
|
writeContract(
|
|
98
220
|
`${JSON.stringify(
|
|
99
|
-
serializeRun(run, {
|
|
221
|
+
serializeRun(run, {
|
|
222
|
+
name: NAME,
|
|
223
|
+
bin: binPath,
|
|
224
|
+
skills: extras.skills,
|
|
225
|
+
usage: extras.usage,
|
|
226
|
+
tools: extras.tools,
|
|
227
|
+
}),
|
|
100
228
|
)}\n`,
|
|
101
229
|
);
|
|
102
230
|
process.exit(0);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What both vendors' tool summaries share: the facts a stream yields once each
|
|
3
|
+
* tool call has been read in that vendor's own shape. Vendor-free.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
7
|
+
|
|
8
|
+
const URL_IN_TEXT = /https?:\/\/[^\s'"`<>|)\]]+/g;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* URLs a shell command names: `curl`, `wget` or a script reaching the web.
|
|
12
|
+
* @param {unknown} command
|
|
13
|
+
* @returns {string[]}
|
|
14
|
+
*/
|
|
15
|
+
export function urlsIn(command) {
|
|
16
|
+
return typeof command === "string"
|
|
17
|
+
? [...command.matchAll(URL_IN_TEXT)].map((match) => match[0])
|
|
18
|
+
: [];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether a path a tool named is outside `cwd`. A skill file is not reported:
|
|
23
|
+
* it is read from the plugin directories Mason itself points the agent at.
|
|
24
|
+
* @param {unknown} path
|
|
25
|
+
* @param {unknown} cwd
|
|
26
|
+
* @returns {path is string}
|
|
27
|
+
*/
|
|
28
|
+
export function isOutside(path, cwd) {
|
|
29
|
+
if (typeof path !== "string" || path.length === 0 || typeof cwd !== "string") {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
if (/(?:^|[/\\])skills[/\\][^/\\]+[/\\]SKILL\.md$/i.test(path)) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
const absolute = isAbsolute(path) ? path : resolve(cwd, path);
|
|
36
|
+
const fromCwd = relative(cwd, absolute);
|
|
37
|
+
return fromCwd === ".." || fromCwd.startsWith(`..${"/"}`) || isAbsolute(fromCwd);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Wall time covered by at least one `[start, end]` interval, in ms.
|
|
42
|
+
* @param {[number, number][]} intervals
|
|
43
|
+
* @returns {number}
|
|
44
|
+
*/
|
|
45
|
+
export function busyMs(intervals) {
|
|
46
|
+
let total = 0;
|
|
47
|
+
/** @type {[number, number] | undefined} */
|
|
48
|
+
let current;
|
|
49
|
+
for (const [start, end] of [...intervals].sort((a, b) => a[0] - b[0])) {
|
|
50
|
+
if (current === undefined || start > current[1]) {
|
|
51
|
+
if (current !== undefined) {
|
|
52
|
+
total += current[1] - current[0];
|
|
53
|
+
}
|
|
54
|
+
current = [start, end];
|
|
55
|
+
} else {
|
|
56
|
+
current[1] = Math.max(current[1], end);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return current === undefined ? total : total + current[1] - current[0];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* An empty summary, filled by a vendor's reader.
|
|
64
|
+
* @returns {import("@bluewombat/slot-kit").AgentTools}
|
|
65
|
+
*/
|
|
66
|
+
export function emptyTools() {
|
|
67
|
+
return { calls: {}, busyMs: null, web: [], mcp: [], subagents: [], outside: [] };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* One call more under `name`.
|
|
72
|
+
* @param {import("@bluewombat/slot-kit").AgentTools} tools
|
|
73
|
+
* @param {string} name
|
|
74
|
+
*/
|
|
75
|
+
export function count(tools, name) {
|
|
76
|
+
tools.calls[name] = (tools.calls[name] ?? 0) + 1;
|
|
77
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bluewombat/slots",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Shipped mason slots: Planners, Builders, Gates, publishers, and refreshers a Project names in its config.",
|
|
6
6
|
"keywords": [
|
|
@@ -53,6 +53,6 @@
|
|
|
53
53
|
"test": "tsx --test $(find test -name '*.test.ts')"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@bluewombat/slot-kit": "^0.
|
|
56
|
+
"@bluewombat/slot-kit": "^0.5.0"
|
|
57
57
|
}
|
|
58
58
|
}
|