- 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>
336 lines
12 KiB
JavaScript
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);
|
|
}
|