@geohar/pi-svg-mcp 0.0.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/LICENSE +338 -0
- package/README.md +84 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +258 -0
- package/dist/index.js.map +1 -0
- package/dist/pi.d.ts +70 -0
- package/dist/pi.d.ts.map +1 -0
- package/dist/pi.js +10 -0
- package/dist/pi.js.map +1 -0
- package/dist/sharedserver-resolve.d.ts +19 -0
- package/dist/sharedserver-resolve.d.ts.map +1 -0
- package/dist/sharedserver-resolve.js +188 -0
- package/dist/sharedserver-resolve.js.map +1 -0
- package/instructions.txt +56 -0
- package/mcp.json.example +7 -0
- package/package.json +56 -0
- package/src/index.ts +291 -0
- package/src/pi.ts +70 -0
- package/src/sharedserver-resolve.ts +221 -0
package/instructions.txt
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Diagrams & SVGs (svg-mcp)
|
|
2
|
+
|
|
3
|
+
<!-- Canonical reach-for-svg-mcp directive. Copy into your global
|
|
4
|
+
$CLAUDE_CONFIG_DIR/CLAUDE.md, or let an svg-mcp plugin inject it each session
|
|
5
|
+
(the plugins symlink this file). It directs *approach*; the MCP tool
|
|
6
|
+
descriptions carry the mechanics. -->
|
|
7
|
+
|
|
8
|
+
Three rules. Each fires on a **cue you can catch yourself in the middle of**, not on a
|
|
9
|
+
topic you have to remember to think about. That's deliberate: you will not remember to
|
|
10
|
+
"use the tools" — but you can notice yourself about to type a `<` .
|
|
11
|
+
|
|
12
|
+
### 1. About to write `<svg`, or hand-edit XML inside one? That's the cue.
|
|
13
|
+
|
|
14
|
+
Writing markup by hand is the wrong move **even when the shape looks trivial** — that's
|
|
15
|
+
exactly when it feels justified, and it skips the only part that matters: seeing the
|
|
16
|
+
result. At that exact moment:
|
|
17
|
+
|
|
18
|
+
| your next move was | do this instead |
|
|
19
|
+
|---|---|
|
|
20
|
+
| write `<svg>`/`<path>`/`<rect>` markup for something new | `create_document`, then the `add_*` primitives — `boolean` for union/difference/intersection, plus gradients / filters / transforms |
|
|
21
|
+
| hand-patch the markup of an SVG you have | `import_svg` → `find` / `describe_node` to locate → `edit_*` / `apply_*` / `delete_node` |
|
|
22
|
+
| hand a result back after building it | `export_render` (or `render_document`) and **actually look at the image** first |
|
|
23
|
+
|
|
24
|
+
Construct → render → see → iterate. **The loop is the product, not the XML.** One pass
|
|
25
|
+
without rendering isn't a faster version of this — it's a different, worse thing.
|
|
26
|
+
|
|
27
|
+
**Catch yourself:** if you're about to show or save SVG source you have never rendered,
|
|
28
|
+
you are guessing at your own output. Render it, look, *then* hand it over.
|
|
29
|
+
|
|
30
|
+
### 2. About to place boxes, arrows, bars, or rows by coordinate? Declare, don't draw.
|
|
31
|
+
|
|
32
|
+
Hand-computing positions for structured content is the diagram version of hand-writing
|
|
33
|
+
markup — it feels precise and produces drift, overlaps, and arrows through boxes. The
|
|
34
|
+
cue is reaching for `add_rect` + `add_line` to represent *things and relationships*:
|
|
35
|
+
|
|
36
|
+
| you're about to | do this instead |
|
|
37
|
+
|---|---|
|
|
38
|
+
| draw an architecture / flowchart / pipeline from rects and lines | `add_diagram_node(kind, label)` + `add_diagram_edge(source, target, kind)` + `add_diagram_container(members)`, then `layout_diagram` — zero coordinates |
|
|
39
|
+
| hand-place bars, axes, tick labels (or shell out to matplotlib for a small figure) | `add_chart(kind, data)` — bar/line/donut/scatter/sparkline, scales and margins derived from the data |
|
|
40
|
+
| lay out rows of text as a grid | `add_table(rows, header)` — column widths measured, numerics right-aligned |
|
|
41
|
+
| pick colors and fonts shape by shape | `load_theme` first and say what things ARE (`role=`, `kind=`); the theme paints them. Read the guidance the load returns — it's the house style speaking |
|
|
42
|
+
| annotate or key the result | `add_callout(target, text)` (points at ids, survives reflow), `add_legend()` (generated from what the document uses) |
|
|
43
|
+
|
|
44
|
+
After moving, resizing, or deleting diagram nodes: **`reflow()`** — one call re-routes
|
|
45
|
+
edges, re-anchors callouts, and re-fits containers. Layout is opt-in; it never reruns
|
|
46
|
+
behind your back.
|
|
47
|
+
|
|
48
|
+
### 3. More than a couple of tool calls? Start the preview and say so.
|
|
49
|
+
|
|
50
|
+
Cue: you can tell the build will take more than two or three calls. Call `start_preview`
|
|
51
|
+
**at the start**, not when you're done, and put the exact URL in your reply unprompted:
|
|
52
|
+
"Live preview: <url> — it refreshes on every change."
|
|
53
|
+
|
|
54
|
+
The reason is timing, not courtesy. Someone watching the drawing take shape can redirect
|
|
55
|
+
you mid-build; the same person handed a finished image can only accept it or ask for the
|
|
56
|
+
whole thing again. A preview URL shared at the end has none of that value.
|
package/mcp.json.example
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@geohar/pi-svg-mcp",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Pi extension: run the svg-mcp MCP server via sharedserver and inject the diagram-authoring directive. Pairs with pi-mcp-adapter, which makes svg-mcp's tools reachable from Pi.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"pi": {
|
|
9
|
+
"extensions": [
|
|
10
|
+
"./src/index.ts"
|
|
11
|
+
]
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"dist",
|
|
16
|
+
"instructions.txt",
|
|
17
|
+
"mcp.json.example",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc",
|
|
23
|
+
"clean": "rm -rf dist",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"prepack": "node -e \"require('fs').copyFileSync('../../CLAUDE.md.example', './instructions.txt')\"",
|
|
26
|
+
"prepublishOnly": "npm run clean && npm run build"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"pi",
|
|
30
|
+
"pi-extension",
|
|
31
|
+
"pi-mono",
|
|
32
|
+
"mcp",
|
|
33
|
+
"svg-mcp",
|
|
34
|
+
"svg",
|
|
35
|
+
"diagram",
|
|
36
|
+
"sharedserver",
|
|
37
|
+
"extension"
|
|
38
|
+
],
|
|
39
|
+
"author": "George Harker <george@georgeharker.com>",
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "git+https://github.com/georgeharker/svg-mcp.git",
|
|
44
|
+
"directory": "plugins/pi"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^22.0.0",
|
|
51
|
+
"typescript": "^5.6.0"
|
|
52
|
+
},
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=18.0.0"
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// Pi extension: run the `svg-mcp` MCP server via the `sharedserver` CLI and inject the
|
|
2
|
+
// diagram-authoring directive into the system prompt.
|
|
3
|
+
//
|
|
4
|
+
// It is the Pi counterpart of svg-mcp's Claude Code and OpenCode plugins, and mirrors
|
|
5
|
+
// their behaviour:
|
|
6
|
+
//
|
|
7
|
+
// 1. Stand-down switch — if a combiner already serves svg-mcp (global MCP_COMBINER, or
|
|
8
|
+
// the per-backend MCP_COMBINER_SERVES_SVG_MCP override, which wins), do NOT launch
|
|
9
|
+
// a standalone backend. The combiner owns svg-mcp's lifecycle. Only the launch is
|
|
10
|
+
// gated — the directive applies either way, since svg-mcp's tools are present via
|
|
11
|
+
// the combiner too.
|
|
12
|
+
// 2. Process — on `session_start`, drive `sharedserver use … -- <svg-mcp serve argv>`
|
|
13
|
+
// so one warm svg-mcp is running and refcounted (shared across clients), paying its
|
|
14
|
+
// cold start (numpy + pillow) once. Released on `session_shutdown` when
|
|
15
|
+
// `reason === "quit"` (reload/resume/fork keep the process and re-attach).
|
|
16
|
+
// 3. Directive — append the diagram-authoring text to the system prompt via
|
|
17
|
+
// `before_agent_start` (analogue of CC's additionalContext / OpenCode's
|
|
18
|
+
// system.transform).
|
|
19
|
+
//
|
|
20
|
+
// WHICH svg-mcp RUNS (mirrors the OpenCode plugin's resolveServeArgv):
|
|
21
|
+
// default uvx svg-mcp@<version> (published release, pinned)
|
|
22
|
+
// SVG_MCP_DEV=<dir> uv run --project <dir> (a dev checkout)
|
|
23
|
+
// SVG_MCP_DEV=1 uv run --project <repo> (in-repo source, if resolvable)
|
|
24
|
+
//
|
|
25
|
+
// MCP registration itself (pointing pi-mcp-adapter at svg-mcp) is a single mcp.json
|
|
26
|
+
// entry — see mcp.json.example and the README; static, and unnecessary when
|
|
27
|
+
// combiner-served. The sharedserver resolution is ported from plugins/opencode.
|
|
28
|
+
|
|
29
|
+
import { spawnSync } from "node:child_process"
|
|
30
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
31
|
+
import { dirname, join } from "node:path"
|
|
32
|
+
import { fileURLToPath } from "node:url"
|
|
33
|
+
import type {
|
|
34
|
+
AutocompleteItem,
|
|
35
|
+
ExtensionAPI,
|
|
36
|
+
ExtensionCommandContext,
|
|
37
|
+
ExtensionContext,
|
|
38
|
+
SessionShutdownEvent,
|
|
39
|
+
} from "./pi.js"
|
|
40
|
+
import { resolveSharedserver } from "./sharedserver-resolve.js"
|
|
41
|
+
|
|
42
|
+
const DEFAULT_PORT = 7731
|
|
43
|
+
const DEFAULT_NAME = "svg-mcp"
|
|
44
|
+
const DEFAULT_GRACE = "1h"
|
|
45
|
+
// Floor-only against sharedserver's latest release: svg-mcp consumes sharedserver rather
|
|
46
|
+
// than shipping it. Kept equal to the sibling plugins' value.
|
|
47
|
+
const SHAREDSERVER_MIN_VERSION = "0.6.7"
|
|
48
|
+
// The svg-mcp PyPI release this extension runs by default (`uvx svg-mcp@<v>`). Decoupled
|
|
49
|
+
// from this package's OWN version (matching the OpenCode plugin); override per-launch
|
|
50
|
+
// with $SVG_MCP_VERSION. Keep it pointing at a real svg-mcp release.
|
|
51
|
+
const SVG_MCP_TOOL_VERSION = "0.2.6"
|
|
52
|
+
|
|
53
|
+
type LogFn = (level: "info" | "warn" | "error", message: string) => void
|
|
54
|
+
|
|
55
|
+
// ── the diagram-authoring directive ────────────────────────────────
|
|
56
|
+
// Appended to the system prompt so the agent reaches for svg-mcp's tools instead of
|
|
57
|
+
// hand-writing SVG XML. Canonical source: CLAUDE.md.example at the repo root; a
|
|
58
|
+
// release-time `prepack` copies it to this package's root as instructions.txt (see
|
|
59
|
+
// package.json). A dev/unbuilt run without the copy falls back to empty and injects
|
|
60
|
+
// nothing.
|
|
61
|
+
const SVG_DIAGRAM_DIRECTIVE: string = (() => {
|
|
62
|
+
try {
|
|
63
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
64
|
+
return readFileSync(join(here, "..", "instructions.txt"), "utf8")
|
|
65
|
+
} catch {
|
|
66
|
+
return ""
|
|
67
|
+
}
|
|
68
|
+
})()
|
|
69
|
+
const DIRECTIVE_MARKER = SVG_DIAGRAM_DIRECTIVE.split("\n", 1)[0] ?? ""
|
|
70
|
+
|
|
71
|
+
// ── env configuration ──────────────────────────────────────────────
|
|
72
|
+
// svg-mcp's tool knobs use the shared SVG_MCP_* namespace (as its OpenCode plugin does),
|
|
73
|
+
// so a user's SVG_MCP_PORT/VERSION/DEV apply across every client. Pi-extension-specific
|
|
74
|
+
// toggles use PI_SVG_MCP_*.
|
|
75
|
+
|
|
76
|
+
function env(name: string): string | undefined {
|
|
77
|
+
const v = process.env[name]
|
|
78
|
+
return v !== undefined && v !== "" ? v : undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── stand-down switch (mirrors the CC hook's combiner_serves) ──────
|
|
82
|
+
|
|
83
|
+
function truthy(v: string | undefined): boolean {
|
|
84
|
+
if (v == null) return false
|
|
85
|
+
return !["", "0", "false", "no", "off"].includes(v.trim().toLowerCase())
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Does a combiner serve `name`? The per-backend `MCP_COMBINER_SERVES_<NAME>` override
|
|
89
|
+
* wins over the global `MCP_COMBINER` switch (presence, even empty, counts). Shared
|
|
90
|
+
* cross-tool switches — NOT PI_-namespaced. */
|
|
91
|
+
function combinerServes(name: string): boolean {
|
|
92
|
+
const key = "MCP_COMBINER_SERVES_" + name.toUpperCase().replace(/[-\s]/g, "_")
|
|
93
|
+
if (key in process.env) return truthy(process.env[key])
|
|
94
|
+
return truthy(process.env.MCP_COMBINER)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function onPath(cmd: string): boolean {
|
|
98
|
+
return spawnSync(cmd, ["--version"], { stdio: "ignore", env: process.env }).status === 0
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Repo-root guess for `SVG_MCP_DEV=1` — three levels up from dist/index.js
|
|
102
|
+
* (plugins/pi/dist → repo root), only if it holds svg-mcp source. */
|
|
103
|
+
function inRepoSource(): string | undefined {
|
|
104
|
+
try {
|
|
105
|
+
const root = fileURLToPath(new URL("../../..", import.meta.url))
|
|
106
|
+
return existsSync(join(root, "pyproject.toml")) ? root : undefined
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Resolve the argv that serves svg-mcp over streamable-http, or a `missing` runner. */
|
|
113
|
+
function resolveServeArgv(port: string): { argv: string[]; missing?: string } {
|
|
114
|
+
const dev = env("SVG_MCP_DEV")
|
|
115
|
+
if (dev) {
|
|
116
|
+
if (!onPath("uv")) return { argv: [], missing: "uv" }
|
|
117
|
+
const project = dev !== "1" && existsSync(dev) ? dev : inRepoSource()
|
|
118
|
+
if (project) {
|
|
119
|
+
return {
|
|
120
|
+
argv: ["uv", "run", "--project", project, "svg-mcp", "--transport", "streamable-http", "--port", port],
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// dev requested but no checkout given and no in-repo source — fall through.
|
|
124
|
+
}
|
|
125
|
+
if (!onPath("uvx")) return { argv: [], missing: "uvx" }
|
|
126
|
+
const ver = env("SVG_MCP_VERSION") ?? SVG_MCP_TOOL_VERSION
|
|
127
|
+
const spec = ver ? `svg-mcp@${ver}` : "svg-mcp"
|
|
128
|
+
return { argv: ["uvx", spec, "--transport", "streamable-http", "--port", port] }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── sharedserver lifecycle ─────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
type Attachment = { binary: string; name: string }
|
|
134
|
+
let attachment: Attachment | null = null
|
|
135
|
+
let cleanupInstalled = false
|
|
136
|
+
|
|
137
|
+
function installProcessCleanup() {
|
|
138
|
+
if (cleanupInstalled) return
|
|
139
|
+
cleanupInstalled = true
|
|
140
|
+
process.on("exit", () => detach())
|
|
141
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as NodeJS.Signals[]) {
|
|
142
|
+
process.on(sig, () => {
|
|
143
|
+
detach()
|
|
144
|
+
process.kill(process.pid, sig)
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function detach() {
|
|
150
|
+
if (!attachment) return
|
|
151
|
+
const { binary, name } = attachment
|
|
152
|
+
attachment = null
|
|
153
|
+
spawnSync(binary, ["unuse", name, "--pid", String(process.pid)], { stdio: "ignore", env: process.env })
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── the extension ──────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
export default function svgMcp(pi: ExtensionAPI): void {
|
|
159
|
+
const notify = env("PI_SVG_MCP_NOTIFY") !== "false"
|
|
160
|
+
const wantInstructions = env("PI_SVG_MCP_INSTRUCTIONS") !== "false"
|
|
161
|
+
const manage = env("PI_SVG_MCP_MANAGE") !== "false"
|
|
162
|
+
const name = env("PI_SVG_MCP_NAME") ?? DEFAULT_NAME
|
|
163
|
+
const served = combinerServes(name)
|
|
164
|
+
|
|
165
|
+
// ── directive: appended every turn (dup-guarded across turns) ──
|
|
166
|
+
pi.on("before_agent_start", (event) => {
|
|
167
|
+
if (!wantInstructions || !SVG_DIAGRAM_DIRECTIVE) return
|
|
168
|
+
if (DIRECTIVE_MARKER && event.systemPrompt.includes(DIRECTIVE_MARKER)) return
|
|
169
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${SVG_DIAGRAM_DIRECTIVE}` }
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// ── /svg-mcp command: inspect the extension (verb: system-prompt) ──
|
|
173
|
+
pi.registerCommand("svg-mcp", {
|
|
174
|
+
description: "svg-mcp extension — verb: system-prompt (show the injected directive)",
|
|
175
|
+
getArgumentCompletions: (prefix) => completeVerbs(prefix),
|
|
176
|
+
handler: (args, ctx) => {
|
|
177
|
+
const verb = args.trim()
|
|
178
|
+
if (verb === "" || verb === "system-prompt") {
|
|
179
|
+
showDirective(ctx, "svg-mcp", SVG_DIAGRAM_DIRECTIVE, wantInstructions)
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
ctx.ui?.notify?.(`svg-mcp: unknown verb "${verb}". Try: system-prompt`, "warn")
|
|
183
|
+
},
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
// Combiner-served or manage=false: nothing to launch. The directive still applies.
|
|
187
|
+
if (served || !manage) return
|
|
188
|
+
|
|
189
|
+
// ── process: launch on session_start, release on session_shutdown("quit") ──
|
|
190
|
+
pi.on("session_start", (_event, ctx) => {
|
|
191
|
+
if (attachment) return
|
|
192
|
+
|
|
193
|
+
const log = makeLog(ctx, notify)
|
|
194
|
+
const binary = resolveSharedserver(
|
|
195
|
+
{
|
|
196
|
+
label: "svg-mcp",
|
|
197
|
+
minVersion: SHAREDSERVER_MIN_VERSION,
|
|
198
|
+
installerUrl:
|
|
199
|
+
"https://github.com/georgeharker/sharedserver/releases/latest/download/sharedserver-installer.sh",
|
|
200
|
+
},
|
|
201
|
+
env("SHAREDSERVER_BIN"),
|
|
202
|
+
process.env,
|
|
203
|
+
log,
|
|
204
|
+
)
|
|
205
|
+
if (!binary) {
|
|
206
|
+
log("error", "sharedserver binary not found; set $SHAREDSERVER_BIN, or PI_SVG_MCP_MANAGE=false")
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const port = env("SVG_MCP_PORT") ?? String(DEFAULT_PORT)
|
|
211
|
+
const { argv, missing } = resolveServeArgv(port)
|
|
212
|
+
if (missing) {
|
|
213
|
+
log("error", `\`${missing}\` not on PATH; install uv (https://docs.astral.sh/uv/), or PI_SVG_MCP_MANAGE=false`)
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const grace = env("PI_SVG_MCP_GRACE") ?? DEFAULT_GRACE
|
|
218
|
+
const useArgs = [
|
|
219
|
+
"use",
|
|
220
|
+
name,
|
|
221
|
+
"--pid",
|
|
222
|
+
String(process.pid),
|
|
223
|
+
"--grace-period",
|
|
224
|
+
grace,
|
|
225
|
+
"--metadata",
|
|
226
|
+
`pi-${process.pid}`,
|
|
227
|
+
]
|
|
228
|
+
const logFile = env("PI_SVG_MCP_LOG")
|
|
229
|
+
if (logFile && logFile !== "none") useArgs.push("--log-file", logFile)
|
|
230
|
+
useArgs.push("--", ...argv)
|
|
231
|
+
|
|
232
|
+
installProcessCleanup()
|
|
233
|
+
const result = spawnSync(binary, useArgs, { stdio: "pipe", env: process.env })
|
|
234
|
+
if (result.error) {
|
|
235
|
+
log("error", `${name}: failed to spawn sharedserver (${result.error.message})`)
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
if (result.status !== 0) {
|
|
239
|
+
const stderr = result.stderr?.toString().trim()
|
|
240
|
+
log("error", `${name}: sharedserver use exited ${result.status}${stderr ? ` (${stderr})` : ""}`)
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
attachment = { binary, name }
|
|
245
|
+
log("info", `svg-mcp "${name}" attached on port ${port} (${argv.join(" ")})`)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
pi.on("session_shutdown", (event: SessionShutdownEvent) => {
|
|
249
|
+
if (event.reason === "quit") detach()
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── helpers ────────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
// The verbs the extension's slash command understands. `system-prompt` shows the
|
|
256
|
+
// directive this extension injects — the show-command pattern from pi-custom-system-prompt,
|
|
257
|
+
// since `before_agent_start` injections are per-turn and never appear in Pi's own
|
|
258
|
+
// `/system-prompt` (which reports the base prompt only).
|
|
259
|
+
const COMMAND_VERBS = ["system-prompt"]
|
|
260
|
+
function completeVerbs(prefix: string): AutocompleteItem[] | null {
|
|
261
|
+
const p = prefix.trim()
|
|
262
|
+
const matches = COMMAND_VERBS.filter((v) => v.startsWith(p))
|
|
263
|
+
return matches.length ? matches.map((v) => ({ value: v, label: v })) : null
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const SHOW_LIMIT = 1600
|
|
267
|
+
function showDirective(ctx: ExtensionCommandContext, label: string, directive: string, enabled: boolean): void {
|
|
268
|
+
if (!directive) {
|
|
269
|
+
ctx.ui?.notify?.(`${label}: no directive bundled (instructions.txt missing)`, "warn")
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
const head = enabled
|
|
273
|
+
? `${label} directive — injected into the system prompt on every turn (before_agent_start):`
|
|
274
|
+
: `${label} directive — injection is DISABLED this session; it would be:`
|
|
275
|
+
const body =
|
|
276
|
+
directive.length > SHOW_LIMIT
|
|
277
|
+
? `${directive.slice(0, SHOW_LIMIT)}\n\n… (${directive.length} chars total)`
|
|
278
|
+
: directive
|
|
279
|
+
ctx.ui?.notify?.(`${head}\n\n${body}`, "info")
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function makeLog(ctx: ExtensionContext, notify: boolean): LogFn {
|
|
283
|
+
return (level, message) => {
|
|
284
|
+
const line = `svg-mcp: ${message}`
|
|
285
|
+
if (notify && ctx.hasUI && ctx.ui?.notify) {
|
|
286
|
+
ctx.ui.notify(line, level === "error" ? "error" : level === "warn" ? "warn" : "info")
|
|
287
|
+
} else if (level === "error" || level === "warn") {
|
|
288
|
+
process.stderr.write(`${line}\n`)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
package/src/pi.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Narrow, local typing for the slice of Pi's extension API this plugin uses.
|
|
2
|
+
//
|
|
3
|
+
// Pi (badlogic/pi-mono, earendil-works/pi) ships its ExtensionAPI types with the
|
|
4
|
+
// harness rather than as a standalone npm package we can depend on, so we declare
|
|
5
|
+
// exactly the surface we touch — three lifecycle events, `registerCommand`, `exec`,
|
|
6
|
+
// and `sendMessage`. Kept deliberately minimal: a wider mirror would rot against a
|
|
7
|
+
// moving upstream. Signatures follow the published extension docs
|
|
8
|
+
// (https://pi.dev/docs/latest/extensions).
|
|
9
|
+
|
|
10
|
+
export type SessionStartReason = "startup" | "reload" | "new" | "resume" | "fork"
|
|
11
|
+
export type SessionShutdownReason = "quit" | "reload" | "new" | "resume" | "fork"
|
|
12
|
+
|
|
13
|
+
export type SessionStartEvent = { reason: SessionStartReason; previousSessionFile?: string }
|
|
14
|
+
export type SessionShutdownEvent = { reason: SessionShutdownReason; targetSessionFile?: string }
|
|
15
|
+
export type BeforeAgentStartEvent = { systemPrompt: string }
|
|
16
|
+
export type BeforeAgentStartResult = { systemPrompt?: string } | void
|
|
17
|
+
|
|
18
|
+
export type ExtensionContext = {
|
|
19
|
+
cwd: string
|
|
20
|
+
mode: "tui" | "rpc" | "json" | "print"
|
|
21
|
+
hasUI: boolean
|
|
22
|
+
signal?: AbortSignal
|
|
23
|
+
ui?: { notify?: (message: string, level?: "info" | "warn" | "error") => void }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Context handed to a command handler. Superset of ExtensionContext in practice; we
|
|
27
|
+
* only read `signal`, `ui`, and `hasUI`. */
|
|
28
|
+
export type ExtensionCommandContext = ExtensionContext
|
|
29
|
+
|
|
30
|
+
export type AutocompleteItem = { value: string; label?: string }
|
|
31
|
+
|
|
32
|
+
export type ExecResult = { stdout: string; stderr: string; code: number; killed: boolean }
|
|
33
|
+
export type ExecOptions = { signal?: AbortSignal; timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv }
|
|
34
|
+
|
|
35
|
+
/** An LLM-visible message injected from a command. `display:true` shows it in the TUI;
|
|
36
|
+
* `{triggerTurn:true, deliverAs:"steer"}` makes the model act on it this turn. */
|
|
37
|
+
export type SendMessage = {
|
|
38
|
+
customType: string
|
|
39
|
+
content: string
|
|
40
|
+
display?: boolean
|
|
41
|
+
details?: Record<string, unknown>
|
|
42
|
+
}
|
|
43
|
+
export type SendMessageOptions = { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" }
|
|
44
|
+
|
|
45
|
+
export type CommandSpec = {
|
|
46
|
+
description: string
|
|
47
|
+
handler: (args: string, ctx: ExtensionCommandContext) => void | Promise<void>
|
|
48
|
+
getArgumentCompletions?: (prefix: string) => AutocompleteItem[] | null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ExtensionAPI {
|
|
52
|
+
on(
|
|
53
|
+
event: "session_start",
|
|
54
|
+
handler: (event: SessionStartEvent, ctx: ExtensionContext) => void | Promise<void>,
|
|
55
|
+
): void
|
|
56
|
+
on(
|
|
57
|
+
event: "session_shutdown",
|
|
58
|
+
handler: (event: SessionShutdownEvent, ctx: ExtensionContext) => void | Promise<void>,
|
|
59
|
+
): void
|
|
60
|
+
on(
|
|
61
|
+
event: "before_agent_start",
|
|
62
|
+
handler: (
|
|
63
|
+
event: BeforeAgentStartEvent,
|
|
64
|
+
ctx: ExtensionContext,
|
|
65
|
+
) => BeforeAgentStartResult | Promise<BeforeAgentStartResult>,
|
|
66
|
+
): void
|
|
67
|
+
registerCommand(name: string, spec: CommandSpec): void
|
|
68
|
+
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>
|
|
69
|
+
sendMessage(message: SendMessage, options?: SendMessageOptions): void
|
|
70
|
+
}
|