singularity-forge/src/resources/extensions/sf/commands/legacy/create-extension.js
Mikael Hugo adb449d642 fix: consolidate extensions into sf, migrate kernel.ts, fix test suite
- Fold sf-usage-bar, sf-notify, sf-inturn-guard, sf-permissions,
  slash-commands into sf extension (ui/, notifications/, guards/,
  permissions/, commands/legacy/)
- Delete vectordrive extension
- Migrate uok/kernel.js to TypeScript (kernel.ts) with full interfaces
- Add allowJs/checkJs:false to tsconfig.resources.json for incremental TS migration
- Add symlink dedup to extension-discovery.ts (seenRealPaths Set)
- Add before_provider_request delegate back to native-search.js so
  session budget tests exercise the middleware end-to-end
- Fix parseSfNativeTools() to return all SF manifest tools (drop sf_ filter)
- Fix test assertions: plan_milestone/complete_task/validate_milestone
- Remove subagent from app-smoke.test.ts (folded into sf/subagent/)
- Remove sf-permissions/sf-inturn-guard/subagent from features-inventory test
- Fix resolveSearchProvider autonomous mode test to pass 'auto' explicitly
- Remove legacy /clear slash command (conflicts with built-in clear_terminal)
- Update web-command-parity-contract.test.ts for clear removal

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-11 02:40:52 +02:00

336 lines
12 KiB
JavaScript

import { showInterviewRound } from "../../../shared/tui.js";
export default function createExtension(pi) {
pi.registerCommand("create-extension", {
description:
"Scaffold a new pi extension with interview-driven context gathering",
async handler(args, ctx) {
const inlineName = (typeof args === "string" ? args : "").trim();
// ── Interview — always runs first ─────────────────────────────────────
const questions = [
...(!inlineName
? [
{
id: "purpose",
header: "Purpose",
question: "What should this extension do?",
options: [
{
label: "Add a custom tool",
description:
"Register a new tool the LLM can call (like sf_plan, plan_clarify).",
},
{
label: "Add a slash command",
description:
"A /command the user types — runs logic, optionally triggers an agent turn.",
},
{
label: "React to agent events",
description:
"Hook into turn_end, agent_end, tool_call, etc. to observe or intercept.",
},
{
label: "Custom TUI component",
description:
"Render a widget, overlay, dialog, or custom editor in the terminal UI.",
},
],
},
]
: []),
{
id: "ui",
header: "UI",
question: "Does this extension need any custom UI?",
options: [
{
label: "No UI",
description:
"Pure logic — no dialogs, widgets, or custom rendering needed.",
},
{
label: "Dialogs only",
description:
"Uses built-in ctx.ui.select / ctx.ui.input / ctx.ui.confirm dialogs.",
},
{
label: "Status / widget",
description:
"Shows a persistent status indicator or footer widget.",
},
{
label: "Full custom component",
description:
"Uses ctx.ui.custom() to render a fully bespoke TUI component.",
},
],
},
{
id: "events",
header: "Events",
question: "Does it need to hook into the agent lifecycle?",
options: [
{
label: "No — standalone",
description:
"Runs only when explicitly invoked — no event listeners needed.",
},
{
label: "Yes — tool_call",
description:
"Intercepts or observes tool calls before or after they run.",
},
{
label: "Yes — turn / session",
description:
"Reacts to turn_end, agent_end, session_start, or similar lifecycle events.",
},
{
label: "Yes — context / prompt",
description:
"Modifies the system prompt or filters messages via context / before_agent_start.",
},
],
},
{
id: "persistence",
header: "State",
question:
"Does this extension need to persist state across sessions?",
options: [
{
label: "No state needed",
description: "Stateless — each invocation is independent.",
},
{
label: "In-memory only",
description:
"Keeps state while the session is running but doesn't survive restarts.",
},
{
label: "Persisted to session",
description:
"Uses pi.appendEntry() to write state into the session JSONL for resume.",
},
],
},
{
id: "complexity",
header: "Complexity",
question: "How complex is the implementation?",
options: [
{
label: "Simple — one concern",
description:
"A single tool or command, minimal branching, easy to follow.",
},
{
label: "Moderate — a few parts",
description:
"A command plus an event hook, or a tool with custom rendering.",
},
{
label: "Complex — full extension",
description:
"Multiple tools, commands, events, UI, and state working together.",
},
],
},
];
const result = await showInterviewRound(
questions,
{
progress: "New pi extension · Context",
reviewHeadline: "Review your choices",
exitHeadline: "Cancel extension creation?",
exitLabel: "cancel",
},
ctx,
);
// User hit Esc — bail silently
if (!result.answers || Object.keys(result.answers).length === 0) {
ctx.ui.notify("Cancelled.", "info");
return;
}
// ── Resolve name / description ────────────────────────────────────────
let extensionDescription = inlineName;
if (!extensionDescription) {
const purpose = result.answers["purpose"];
if (purpose) {
extensionDescription = purpose.notes?.trim()
? purpose.notes.trim()
: Array.isArray(purpose.selected)
? purpose.selected[0]
: purpose.selected;
}
}
if (!extensionDescription) {
ctx.ui.notify(
"No description captured — add details in the notes field next time.",
"warning",
);
return;
}
// ── Build and send the enriched prompt ────────────────────────────────
sendPrompt(extensionDescription, result, pi);
},
});
}
// ─── Prompt builder ───────────────────────────────────────────────────────────
function formatAnswers(result) {
const lines = [];
const purpose = result.answers["purpose"];
if (purpose?.notes) {
lines.push(`- **Extension goal (user's words)**: ${purpose.notes}`);
}
const ui = result.answers["ui"];
if (ui) {
const selected = Array.isArray(ui.selected) ? ui.selected[0] : ui.selected;
lines.push(
`- **UI needs**: ${selected}${ui.notes ? `${ui.notes}` : ""}`,
);
}
const events = result.answers["events"];
if (events) {
const selected = Array.isArray(events.selected)
? events.selected[0]
: events.selected;
lines.push(
`- **Event hooks**: ${selected}${events.notes ? `${events.notes}` : ""}`,
);
}
const persistence = result.answers["persistence"];
if (persistence) {
const selected = Array.isArray(persistence.selected)
? persistence.selected[0]
: persistence.selected;
lines.push(
`- **State persistence**: ${selected}${persistence.notes ? `${persistence.notes}` : ""}`,
);
}
const complexity = result.answers["complexity"];
if (complexity) {
const selected = Array.isArray(complexity.selected)
? complexity.selected[0]
: complexity.selected;
lines.push(
`- **Complexity**: ${selected}${complexity.notes ? `${complexity.notes}` : ""}`,
);
}
return lines.join("\n");
}
function sendPrompt(description, result, pi) {
const contextSection = `\n## Context gathered from user\n${formatAnswers(result)}\n`;
// Determine which doc sections to highlight based on answers
const uiAnswer = result.answers["ui"];
const uiSelected = uiAnswer
? Array.isArray(uiAnswer.selected)
? uiAnswer.selected[0]
: uiAnswer.selected
: "";
const eventsAnswer = result.answers["events"];
const eventsSelected = eventsAnswer
? Array.isArray(eventsAnswer.selected)
? eventsAnswer.selected[0]
: eventsAnswer.selected
: "";
const persistenceAnswer = result.answers["persistence"];
const persistenceSelected = persistenceAnswer
? Array.isArray(persistenceAnswer.selected)
? persistenceAnswer.selected[0]
: persistenceAnswer.selected
: "";
const docHints = [
"- `~/.sf/agent/docs/extending-pi/01-what-are-extensions.md` — capabilities overview",
"- `~/.sf/agent/docs/extending-pi/03-getting-started.md` — minimal extension, hot reload",
"- `~/.sf/agent/docs/extending-pi/08-extensioncontext-what-you-can-access.md` — ExtensionContext API",
"- `~/.sf/agent/docs/extending-pi/09-extensionapi-what-you-can-do.md` — ExtensionAPI: registration, messaging",
"- `~/.sf/agent/docs/extending-pi/22-key-rules-gotchas.md` — must-read rules before shipping",
];
if (uiSelected.includes("custom component")) {
docHints.push(
"- `~/.sf/agent/docs/extending-pi/12-custom-ui-visual-components.md` — dialogs, widgets, overlays",
);
docHints.push(
"- `~/.sf/agent/docs/pi-ui-tui/06-ctx-ui-custom-full-custom-components.md` — ctx.ui.custom() API",
);
docHints.push(
"- `~/.sf/agent/docs/pi-ui-tui/07-built-in-components-the-building-blocks.md` — Text, Box, SelectList",
);
docHints.push(
"- `~/.sf/agent/docs/pi-ui-tui/09-keyboard-input-how-to-handle-keys.md` — Key, matchesKey",
);
docHints.push(
"- `~/.sf/agent/docs/pi-ui-tui/10-line-width-the-cardinal-rule.md` — truncation, width rules",
);
docHints.push(
"- `~/.sf/agent/docs/pi-ui-tui/19-building-a-complete-component-step-by-step.md` — step-by-step guide",
);
docHints.push(
"- `~/.sf/agent/docs/pi-ui-tui/21-common-mistakes-and-how-to-avoid-them.md` — pitfalls",
);
} else if (uiSelected.includes("Dialogs")) {
docHints.push(
"- `~/.sf/agent/docs/pi-ui-tui/04-built-in-dialog-methods.md` — select, confirm, input, editor",
);
} else if (uiSelected.includes("Status")) {
docHints.push(
"- `~/.sf/agent/docs/pi-ui-tui/05-persistent-ui-elements.md` — status, widgets, footer, header",
);
}
if (uiSelected.includes("tool") || result.answers["purpose"]) {
docHints.push(
"- `~/.sf/agent/docs/extending-pi/14-custom-rendering-controlling-what-the-user-sees.md` — renderCall / renderResult",
);
}
if (eventsSelected && !eventsSelected.includes("standalone")) {
docHints.push(
"- `~/.sf/agent/docs/extending-pi/07-events-the-nervous-system.md` — all events reference",
);
}
if (eventsSelected.includes("context / prompt")) {
docHints.push(
"- `~/.sf/agent/docs/extending-pi/15-system-prompt-modification.md` — system prompt hooks",
);
}
if (persistenceSelected.includes("session")) {
docHints.push(
"- `~/.sf/agent/docs/extending-pi/13-state-management-persistence.md` — pi.appendEntry, session state",
);
}
const prompt = `Create a new pi extension based on this description:
"${description}"
${contextSection}
## Reference documentation
Before writing any code, read the relevant docs below. They contain the exact APIs, rules, and patterns for building pi extensions — do not guess or rely on general TypeScript knowledge alone.
${docHints.join("\n")}
## Output
Write the complete implementation as a single self-contained extension file:
\`~/.sf/agent/extensions/<kebab-case-name>.ts\`
Then register it in the main extensions index:
\`~/.sf/agent/extensions/index.ts\` — import and call the new extension's default export alongside existing ones
## Rules you must follow exactly
- Extension entry point: \`export default function <camelCaseName>(pi: ExtensionAPI): void { ... }\`
- Import type: \`import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@singularity-forge/coding-agent";\`
- \`pi\` is the registration surface — call \`pi.registerCommand\`, \`pi.registerTool\`, \`pi.on\`, \`pi.registerShortcut\` inside the default export
- \`ctx\` (ExtensionCommandContext or ExtensionContext) is passed to handlers and event callbacks — never stored, never assumed available globally
- To send a message to the agent: \`pi.sendUserMessage("...")\` or \`pi.sendMessage({ content, display }, { triggerTurn })\`
- To show UI: \`ctx.ui.notify\`, \`ctx.ui.select\`, \`ctx.ui.input\`, \`ctx.ui.confirm\`, \`ctx.ui.custom\`
- To run shell commands: \`await pi.exec("cmd", ["arg1"])\` — returns \`{ stdout, stderr, exitCode }\`
- Events use \`pi.on("event_name", async (event, ctx) => { ... })\`
- No direct file I/O without \`node:fs\` — import it explicitly if needed
- Read the gotchas file before finalising: \`22-key-rules-gotchas.md\`
After writing the files, run \`/reload\` to load the new extension.`;
pi.sendUserMessage(prompt);
}