Merge hooks and custom-tools into unified extensions system (#454)

Breaking changes:
- Settings: 'hooks' and 'customTools' arrays replaced with 'extensions'
- CLI: '--hook' and '--tool' flags replaced with '--extension' / '-e'
- API: HookMessage renamed to CustomMessage, role 'hookMessage' to 'custom'
- API: FileSlashCommand renamed to PromptTemplate
- API: discoverSlashCommands() renamed to discoverPromptTemplates()
- Directories: commands/ renamed to prompts/ for prompt templates

Migration:
- Session version bumped to 3 (auto-migrates v2 sessions)
- Old 'hookMessage' role entries converted to 'custom'

Structural changes:
- src/core/hooks/ and src/core/custom-tools/ merged into src/core/extensions/
- src/core/slash-commands.ts renamed to src/core/prompt-templates.ts
- examples/hooks/ and examples/custom-tools/ merged into examples/extensions/
- docs/hooks.md and docs/custom-tools.md merged into docs/extensions.md

New test coverage:
- test/extensions-runner.test.ts (10 tests)
- test/extensions-discovery.test.ts (26 tests)
- test/prompt-templates.test.ts
This commit is contained in:
Mario Zechner 2026-01-05 01:43:35 +01:00
parent 9794868b38
commit c6fc084534
112 changed files with 2842 additions and 6747 deletions

View file

@ -0,0 +1,79 @@
/**
* Question Tool - Let the LLM ask the user a question with options
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Text } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
interface QuestionDetails {
question: string;
options: string[];
answer: string | null;
}
const QuestionParams = Type.Object({
question: Type.String({ description: "The question to ask the user" }),
options: Type.Array(Type.String(), { description: "Options for the user to choose from" }),
});
export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "question",
label: "Question",
description: "Ask the user a question and let them pick from options. Use when you need user input to proceed.",
parameters: QuestionParams,
async execute(_toolCallId, params, _onUpdate, ctx, _signal) {
if (!ctx.hasUI) {
return {
content: [{ type: "text", text: "Error: UI not available (running in non-interactive mode)" }],
details: { question: params.question, options: params.options, answer: null } as QuestionDetails,
};
}
if (params.options.length === 0) {
return {
content: [{ type: "text", text: "Error: No options provided" }],
details: { question: params.question, options: [], answer: null } as QuestionDetails,
};
}
const answer = await ctx.ui.select(params.question, params.options);
if (answer === undefined) {
return {
content: [{ type: "text", text: "User cancelled the selection" }],
details: { question: params.question, options: params.options, answer: null } as QuestionDetails,
};
}
return {
content: [{ type: "text", text: `User selected: ${answer}` }],
details: { question: params.question, options: params.options, answer } as QuestionDetails,
};
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("question ")) + theme.fg("muted", args.question);
if (args.options?.length) {
text += `\n${theme.fg("dim", ` Options: ${args.options.join(", ")}`)}`;
}
return new Text(text, 0, 0);
},
renderResult(result, _options, theme) {
const details = result.details as QuestionDetails | undefined;
if (!details) {
const text = result.content[0];
return new Text(text?.type === "text" ? text.text : "", 0, 0);
}
if (details.answer === null) {
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
}
return new Text(theme.fg("success", "✓ ") + theme.fg("accent", details.answer), 0, 0);
},
});
}