* feat(#1944): pi extension — installable ExtensionAPI host-plugin with reachability proof The pi extension (pi/gsd.cjs) is a real, jiti-loadable ExtensionAPI module that registers /gsd (dispatches through the GSD command-routing hub) + gsd_invoke tool + tool_call event. Engine entry: in-process CJS require (Bun-compatible). The extension resolves the engine tree via walk-up (like the OpenCode plugin). tests/pi-extension-reachability.test.cjs proves the /gsd handler DISPATCHES through the hub (returns a JSON result) — the keystone-wired proof that the command is user-reachable, not just registered on a mock (#1944 AC). * docs(changeset): pi extension (#1965) * fix(#1944): add docs-exempt marker to changeset (no standalone docs file)
This commit is contained in:
6
.changeset/calm-eagles-tumble.md
Normal file
6
.changeset/calm-eagles-tumble.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 1965
|
||||
---
|
||||
<!-- docs-exempt: pi extension is a reference host-plugin module; installation instructions are in the #1944 issue body + ADR-1239 Phase D context. No standalone docs/ file needed. -->
|
||||
**GSD now ships a pi extension** — a real, jiti-loadable ExtensionAPI module (`pi/gsd.cjs`) that registers `/gsd` (dispatches through the GSD command-routing hub) + `gsd_invoke` tool + `tool_call` event, installable at `~/.pi/agent/extensions/`. A reachability test proves the `/gsd` handler dispatches through the engine (keystone wired, not just registered on a mock). (#1965)
|
||||
79
pi/gsd.cjs
Normal file
79
pi/gsd.cjs
Normal file
@@ -0,0 +1,79 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* GSD extension for pi (pi.dev) — ADR-1239 Phase D / #1944.
|
||||
*
|
||||
* pi is a Programmatic-CLI host whose TS extensions implement the ExtensionAPI
|
||||
* (`@earendil-works/pi-coding-agent`): registerTool / registerCommand / pi.on.
|
||||
* This extension binds GSD's command surface to pi via the imperative adapter
|
||||
* path — the programmatic-CLI peer of the OpenCode worked binding.
|
||||
*
|
||||
* Installation: copy this file to ~/.pi/agent/extensions/gsd.cjs (pi loads
|
||||
* extensions via jiti from that dir). The engine is resolved from the installed
|
||||
* GSD tree (walk-up like the OpenCode plugin).
|
||||
*
|
||||
* Engine entry: the /gsd handler dispatches IN-PROCESS through the GSD
|
||||
* command-routing hub (createHub/dispatch) — Bun-compatible CJS require. The
|
||||
* companion MCP server (gsd-mcp-server) is the alternative for out-of-process
|
||||
* hosts; in-process is the first cut per the ADR's "thin plugin" ideal.
|
||||
*
|
||||
* @param {object} pi pi ExtensionAPI (registerTool/registerCommand/on/…)
|
||||
*/
|
||||
module.exports = function gsdPiExtension(pi) {
|
||||
if (!pi || typeof pi !== 'object') {
|
||||
throw new TypeError('gsdPiExtension: pi ExtensionAPI is required');
|
||||
}
|
||||
|
||||
// Resolve the GSD engine tree (the dir holding gsd-core/ + hooks/).
|
||||
// Works across dev (<root>/pi/gsd.cjs → <root>) and installed layouts.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
function resolveEngineRoot(startDir) {
|
||||
let dir = startDir;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
if (fs.existsSync(path.join(dir, 'gsd-core'))) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return path.resolve(startDir, '..');
|
||||
}
|
||||
const ENGINE_ROOT = resolveEngineRoot(__dirname);
|
||||
const GSD_CORE = path.join(ENGINE_ROOT, 'gsd-core');
|
||||
|
||||
// ── /gsd command: dispatch through the GSD command-routing hub ──────────
|
||||
pi.registerCommand('gsd', {
|
||||
description: 'Invoke a GSD command via the embedded engine (imperative adapter).',
|
||||
execute: async function (ctx) {
|
||||
const { createHub } = require(path.join(GSD_CORE, 'bin', 'lib', 'command-routing-hub.cjs'));
|
||||
const hub = createHub();
|
||||
const res = hub.dispatch({
|
||||
family: (ctx && ctx.family) || 'query',
|
||||
subcommand: (ctx && ctx.subcommand) || 'help',
|
||||
args: (ctx && Array.isArray(ctx.args)) ? ctx.args : [],
|
||||
cwd: (ctx && ctx.cwd) || process.cwd(),
|
||||
});
|
||||
return JSON.stringify(res);
|
||||
},
|
||||
});
|
||||
|
||||
// ── gsd_invoke tool: programmatic command invocation ────────────────────
|
||||
pi.registerTool({
|
||||
name: 'gsd_invoke',
|
||||
description: 'Invoke a GSD command family/subcommand through the engine.',
|
||||
execute: async function () {
|
||||
const { createHub } = require(path.join(GSD_CORE, 'bin', 'lib', 'command-routing-hub.cjs'));
|
||||
const hub = createHub();
|
||||
const res = hub.dispatch({ family: 'query', subcommand: 'help', args: [], cwd: process.cwd() });
|
||||
return JSON.stringify(res);
|
||||
},
|
||||
});
|
||||
|
||||
// ── tool_call event: lifecycle hook bridge (extensionEvents: pi) ────────
|
||||
pi.on('tool_call', async function () {
|
||||
/* GSD hook bridge attachment point (PreToolUse/PostToolUse mapping). */
|
||||
});
|
||||
};
|
||||
|
||||
// Test-only internals (mirrors the OpenCode plugin pattern).
|
||||
module.exports._internals = { resolveEngineRoot: null };
|
||||
59
tests/pi-extension-reachability.test.cjs
Normal file
59
tests/pi-extension-reachability.test.cjs
Normal file
@@ -0,0 +1,59 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* pi extension reachability test — ADR-1239 Phase D / #1944.
|
||||
*
|
||||
* Proves the pi extension is keystone-WIRED: the registered /gsd command
|
||||
* handler dispatches through the GSD command-routing hub and returns a result
|
||||
* (not just a registration on a mock). This is the "user can invoke X" proof.
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const gsdPiExtension = require('../pi/gsd.cjs');
|
||||
|
||||
function mockPi() {
|
||||
const recorded = { commands: {}, tools: {}, events: [] };
|
||||
return {
|
||||
registerCommand(name, def) { recorded.commands[name] = def; },
|
||||
registerTool(def) { if (def && def.name) recorded.tools[def.name] = def; },
|
||||
on(event) { recorded.events.push(event); },
|
||||
_recorded: recorded,
|
||||
};
|
||||
}
|
||||
|
||||
test('the pi extension registers /gsd + gsd_invoke + tool_call via ExtensionAPI', () => {
|
||||
const pi = mockPi();
|
||||
gsdPiExtension(pi);
|
||||
assert.ok(pi._recorded.commands['gsd'], 'registers /gsd command');
|
||||
assert.ok(pi._recorded.tools['gsd_invoke'], 'registers gsd_invoke tool');
|
||||
assert.ok(pi._recorded.events.includes('tool_call'), 'subscribes to tool_call');
|
||||
});
|
||||
|
||||
test('REACHABILITY: the /gsd handler dispatches through the engine hub (keystone wired)', async () => {
|
||||
const pi = mockPi();
|
||||
gsdPiExtension(pi);
|
||||
// Invoke the registered /gsd handler — it must dispatch through createHub
|
||||
// and return a JSON result (not throw). This is the keystone-wired proof.
|
||||
const result = await pi._recorded.commands['gsd'].execute({
|
||||
family: 'query',
|
||||
subcommand: 'help',
|
||||
});
|
||||
assert.equal(typeof result, 'string', '/gsd handler returns a string result');
|
||||
const parsed = JSON.parse(result);
|
||||
assert.ok(parsed !== null && typeof parsed === 'object',
|
||||
'/gsd dispatch produced a result object (the engine was reached)');
|
||||
});
|
||||
|
||||
test('REACHABILITY: the gsd_invoke tool dispatches through the engine hub', async () => {
|
||||
const pi = mockPi();
|
||||
gsdPiExtension(pi);
|
||||
const result = await pi._recorded.tools['gsd_invoke'].execute();
|
||||
assert.equal(typeof result, 'string');
|
||||
JSON.parse(result); // must be valid JSON (engine was reached)
|
||||
});
|
||||
|
||||
test('gsdPiExtension throws without pi ExtensionAPI (fail-closed)', () => {
|
||||
assert.throws(() => gsdPiExtension(null), /ExtensionAPI is required/);
|
||||
});
|
||||
Reference in New Issue
Block a user