feat(coding-agent): removed recipe tool and all runner implementations

- Deleted RecipeTool, runner logic, and all task runner backends (just, make, cargo, pkg, task).
- Removed recipe from BUILTIN_TOOLS, auto-injection in createTools, and HTML export renderer.
- Deleted recipe tool prompt template and runner module exports.
This commit is contained in:
can1357
2026-05-31 01:42:47 +02:00
parent a1ba50b4da
commit dfa6007f36
21 changed files with 7 additions and 1303 deletions
@@ -2036,7 +2036,7 @@ export const SETTINGS_SCHEMA = {
value: "write",
label: "Write",
description:
"Auto-approve read-only and write tools; require confirmation for exec tools such as bash, eval, browser, task, recipe, and ssh.",
"Auto-approve read-only and write tools; require confirmation for exec tools such as bash, eval, browser, task, and ssh.",
},
{
value: "yolo",
@@ -2214,16 +2214,6 @@ export const SETTINGS_SCHEMA = {
description: "Enable the tts tool for xAI Grok Voice speech synthesis",
},
},
"recipe.enabled": {
type: "boolean",
default: true,
ui: {
tab: "tools",
label: "Recipe",
description:
"Enable the recipe tool when a justfile / package.json / Cargo.toml / Makefile / Taskfile is present",
},
},
"inspect_image.enabled": {
type: "boolean",
File diff suppressed because one or more lines are too long
@@ -1476,17 +1476,6 @@
return html;
}
function renderRecipe(name, args, result, ctx) {
const op = str(args.op) || '?';
let html = toolHead('recipe', '<span class="tool-arg-val">' + escapeHtml(op) + '</span>');
if (result) {
html += ctx.renderResultImages();
const output = ctx.getResultText();
if (output) html += formatExpandableOutput(output, 10);
}
return html;
}
function renderIrc(name, args, result, ctx) {
const op = str(args.op) || '?';
const badges = [op];
@@ -1551,7 +1540,6 @@
poll: renderJob,
cancel_job: renderJob,
job: renderJob,
recipe: renderRecipe,
irc: renderIrc,
};
@@ -2,7 +2,7 @@
The user's message above is an **orchestration request**. Execute it as the orchestrator under the contract below. This contract overrides any default tendency to yield early, narrate, or do the work yourself.
<role>
You decompose, dispatch, verify, and iterate. You do **not** edit code. Every file mutation goes through a `task` subagent. Your tool budget is: reading for planning, `task` for dispatch, verification (`bun check`, `bun test`, `recipe`, `lsp diagnostics`), git via `bash`, and `todo_write` for tracking.
You decompose, dispatch, verify, and iterate. You do **not** edit code. Every file mutation goes through a `task` subagent. Your tool budget is: reading for planning, `task` for dispatch, verification (`bun check`, `bun test`, `lsp diagnostics`), git via `bash`, and `todo_write` for tracking.
</role>
<rules>
@@ -1,16 +0,0 @@
Run a recipe / script / target from the project's task runners.
<instruction>
- `op` is a single string: task name plus any args, e.g. `{op: "test"}` or `{op: "build --release"}`.
- In monorepos, package and Cargo target tasks are namespaced with `/`, e.g. `{op: "pkg-a/test"}` or `{op: "crate/bin/server"}`.
{{#if hasMultipleRunners}}- When the same task name exists in more than one runner, prefix with the runner id, e.g. `{op: "{{ambiguityExampleRunner}}:{{ambiguityExampleTask}}"}`. The available runner ids are: {{#each runners}}`{{id}}`{{#unless @last}}, {{/unless}}{{/each}}.
{{/if}}- Runs in the session's cwd. Output and exit code are returned in the same shape as `bash`.
</instruction>
{{#each runners}}
<runner id="{{id}}" label="{{label}}" command="{{commandPrefix}}">
{{#each tasks}}
- `{{name}}{{#if paramSig}} {{paramSig}}{{/if}}`{{#if doc}} — {{doc}}{{/if}}{{#if command}} (`{{command}}`{{#if cwd}} in `{{cwd}}`{{/if}}){{/if}}
{{/each}}
</runner>
{{/each}}
-11
View File
@@ -43,7 +43,6 @@ import { MemoryReflectTool } from "./memory-reflect";
import { MemoryRetainTool } from "./memory-retain";
import { wrapToolWithMetaNotice } from "./output-meta";
import { ReadTool } from "./read";
import { RecipeTool } from "./recipe";
import { RenderMermaidTool } from "./render-mermaid";
import { createReportToolIssueTool, isAutoQaEnabled } from "./report-tool-issue";
import { ResolveTool } from "./resolve";
@@ -84,7 +83,6 @@ export * from "./memory-recall";
export * from "./memory-reflect";
export * from "./memory-retain";
export * from "./read";
export * from "./recipe";
export * from "./render-mermaid";
export * from "./report-tool-issue";
export * from "./resolve";
@@ -300,7 +298,6 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
rewind: RewindTool.createIf,
task: s => TaskTool.create(s),
job: JobTool.createIf,
recipe: RecipeTool.createIf,
irc: IrcTool.createIf,
todo_write: s => new TodoWriteTool(s),
web_search: s => new WebSearchTool(s),
@@ -416,13 +413,6 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
) {
requestedTools.push("ast_edit");
}
if (
requestedTools.includes("bash") &&
!requestedTools.includes("recipe") &&
session.settings.get("recipe.enabled")
) {
requestedTools.push("recipe");
}
if (["hindsight", "mnemosyne"].includes(session.settings.get("memory.backend") ?? "")) {
for (const name of ["recall", "retain", "reflect"]) {
if (!requestedTools.includes(name)) requestedTools.push(name);
@@ -467,7 +457,6 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
if (!session.settings.get("async.enabled") && session.getAgentId?.() === MAIN_AGENT_ID) return false;
return true;
}
if (name === "recipe") return session.settings.get("recipe.enabled");
if (name === "retain" || name === "recall" || name === "reflect") {
return ["hindsight", "mnemosyne"].includes(session.settings.get("memory.backend") ?? "");
}
@@ -1,81 +0,0 @@
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import type { Component } from "@oh-my-pi/pi-tui";
import { prompt } from "@oh-my-pi/pi-utils";
import * as z from "zod/v4";
import type { RenderResultOptions } from "../../extensibility/custom-tools/types";
import type { Theme } from "../../modes/theme/theme";
import recipeDescription from "../../prompts/tools/recipe.md" with { type: "text" };
import type { ToolSession } from "..";
import { type BashRenderContext, BashTool, type BashToolDetails } from "../bash";
import { createRecipeToolRenderer, type RecipeRenderArgs } from "./render";
import { buildPromptModel, type DetectedRunner, resolveCommand } from "./runner";
import { RUNNERS } from "./runners";
const recipeSchema = z
.object({
op: z.string().describe('task name and args, e.g. "test" or "build --release"'),
})
.strict();
type RecipeParams = z.infer<typeof recipeSchema>;
type RecipeRenderResult = {
content: Array<{ type: string; text?: string }>;
details?: BashToolDetails;
isError?: boolean;
};
export class RecipeTool implements AgentTool<typeof recipeSchema, BashToolDetails, Theme> {
readonly name = "recipe";
readonly label = "Run";
readonly approval = "exec" as const;
readonly description: string;
readonly parameters = recipeSchema;
readonly strict = true;
readonly concurrency = "exclusive";
readonly loadMode = "discoverable";
readonly summary = "Execute a saved bash recipe (multi-step shell command preset)";
readonly mergeCallAndResult = true;
readonly inline = true;
readonly renderCall: (args: RecipeRenderArgs, options: RenderResultOptions, uiTheme: Theme) => Component;
readonly renderResult: (
result: RecipeRenderResult,
options: RenderResultOptions & { renderContext?: BashRenderContext },
uiTheme: Theme,
args?: RecipeRenderArgs,
) => Component;
readonly #bash: BashTool;
readonly #runners: DetectedRunner[];
constructor(session: ToolSession, runners: DetectedRunner[]) {
this.#runners = runners;
this.#bash = new BashTool(session);
this.description = prompt.render(recipeDescription, buildPromptModel(runners));
const renderer = createRecipeToolRenderer(runners);
this.renderCall = renderer.renderCall;
this.renderResult = renderer.renderResult;
}
static async createIf(session: ToolSession): Promise<RecipeTool | null> {
if (!session.settings.get("recipe.enabled")) return null;
const detected = (await Promise.all(RUNNERS.map(runner => runner.detect(session.cwd)))).filter(
(runner): runner is DetectedRunner => runner !== null && runner.tasks.length > 0,
);
if (detected.length === 0) return null;
return new RecipeTool(session, detected);
}
async execute(
toolCallId: string,
{ op }: RecipeParams,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<BashToolDetails>,
ctx?: AgentToolContext,
): Promise<AgentToolResult<BashToolDetails>> {
const { command, cwd } = resolveCommand(op, this.#runners);
return await this.#bash.execute(toolCallId, { command, cwd }, signal, onUpdate, ctx);
}
}
export * from "./runner";
export { tasksFromCargoMetadata } from "./runners/cargo";
@@ -1,19 +0,0 @@
import { createShellRenderer } from "../bash";
import type { DetectedRunner } from "./runner";
import { commandFromOp, cwdFromOp, titleFromOp } from "./runner";
export interface RecipeRenderArgs {
op?: string;
__partialJson?: string;
[key: string]: unknown;
}
export function createRecipeToolRenderer(runners: DetectedRunner[]) {
return createShellRenderer<RecipeRenderArgs>({
resolveTitle: args => titleFromOp(args?.op, runners),
resolveCommand: args => commandFromOp(args?.op, runners),
resolveCwd: args => cwdFromOp(args?.op, runners),
});
}
export const recipeToolRenderer = createRecipeToolRenderer([]);
@@ -1,219 +0,0 @@
import { ToolError } from "../tool-errors";
export interface RunnerTask {
name: string;
doc?: string;
/** Parameter names only; used for the `name foo bar` signature line in the description. */
parameters: string[];
/** Override for this specific task, e.g. `cargo run --package crate --bin`. */
commandPrefix?: string;
/** Token passed to the runner command; defaults to `name`. Used when display names are namespaced. */
commandName?: string;
/** Working directory for the task, relative to the session cwd; absent means the runner's root cwd. */
cwd?: string;
}
export interface DetectedRunner {
id: string;
label: string;
/** Resolved shell prefix, e.g. "just" or "bun run" or "make". */
commandPrefix: string;
tasks: RunnerTask[];
}
export interface TaskRunner {
id: string;
label: string;
/**
* Probe `cwd` for the manifest, the binary, and the task list.
* Returns null when this runner does not apply.
*/
detect(cwd: string): Promise<DetectedRunner | null>;
}
interface ParsedOp {
head: string;
tail: string;
}
interface PromptTaskModel {
name: string;
paramSig?: string;
command?: string;
doc?: string;
cwd?: string;
}
const PROMPT_TASK_LIMIT = 20;
interface PromptRunnerModel {
id: string;
label: string;
commandPrefix: string;
tasks: PromptTaskModel[];
hiddenTaskCount?: number;
}
export interface RecipePromptModel {
[key: string]: unknown;
hasMultipleRunners: boolean;
ambiguityExampleRunner?: string;
ambiguityExampleTask?: string;
runners: PromptRunnerModel[];
}
function parseOp(op: string): ParsedOp {
const trimmedStart = op.trimStart();
if (trimmedStart.length === 0) return { head: "", tail: "" };
const match = /^(\S+)(?:\s+([\s\S]*))?$/u.exec(trimmedStart);
return { head: match?.[1] ?? "", tail: match?.[2] ?? "" };
}
function findRunnerById(id: string, runners: DetectedRunner[]): DetectedRunner | undefined {
return runners.find(runner => runner.id === id);
}
function hasTask(runner: DetectedRunner, taskName: string): boolean {
return runner.tasks.some(task => task.name === taskName);
}
function findMatchingRunners(taskName: string, runners: DetectedRunner[]): DetectedRunner[] {
return runners.filter(runner => hasTask(runner, taskName));
}
function formatAvailableTasks(runners: DetectedRunner[]): string {
return runners
.map(runner => {
const names = runner.tasks.map(task => task.name).join(", ");
return `- ${runner.id}: ${names || "(no tasks)"}`;
})
.join("\n");
}
function formatRunnerIds(runners: DetectedRunner[]): string {
return runners.map(runner => runner.id).join(", ");
}
function buildCommand(commandPrefix: string, taskName: string, tail: string): string {
return [commandPrefix, taskName, tail]
.filter(part => part.trim().length > 0)
.join(" ")
.trim();
}
function resolveRunnerAndTask(
op: string,
runners: DetectedRunner[],
): { runner: DetectedRunner; task: RunnerTask; tail: string } {
const { head, tail } = parseOp(op);
if (!head) {
throw new ToolError(`recipe op is empty. Available tasks:\n${formatAvailableTasks(runners)}`);
}
const colonIndex = head.indexOf(":");
if (colonIndex > 0) {
const maybeRunnerId = head.slice(0, colonIndex);
const explicitRunner = findRunnerById(maybeRunnerId, runners);
if (explicitRunner) {
const taskName = head.slice(colonIndex + 1);
const explicitTask = explicitRunner.tasks.find(task => task.name === taskName);
if (!taskName || !explicitTask) {
throw new ToolError(
`Task \`${taskName || "(empty)"}\` not found in runner \`${explicitRunner.id}\`. Available tasks:\n${formatAvailableTasks(runners)}`,
);
}
return { runner: explicitRunner, task: explicitTask, tail };
}
}
const matches = findMatchingRunners(head, runners);
if (matches.length === 1) {
return { runner: matches[0]!, task: matches[0]!.tasks.find(task => task.name === head)!, tail };
}
if (matches.length > 1) {
const ids = matches.map(runner => runner.id).join(", ");
throw new ToolError(
`Task \`${head}\` exists in multiple runners (${ids}). Use \`<runner-id>:<task>\`, for example \`${matches[0]!.id}:${head}\`. Available tasks:\n${formatAvailableTasks(runners)}`,
);
}
throw new ToolError(
`No runner task named \`${head}\`. Use one of the available runner ids (${formatRunnerIds(runners)}) as a prefix when needed, e.g. \`pkg:${head}\`. Available tasks:\n${formatAvailableTasks(runners)}`,
);
}
export interface ResolvedTask {
command: string;
cwd?: string;
}
export function resolveCommand(op: string, runners: DetectedRunner[]): ResolvedTask {
const { runner, task, tail } = resolveRunnerAndTask(op, runners);
const command = buildCommand(task.commandPrefix ?? runner.commandPrefix, task.commandName ?? task.name, tail);
return task.cwd ? { command, cwd: task.cwd } : { command };
}
export function resolveTaskFromOp(op: string | undefined, runners: DetectedRunner[]): ResolvedTask | undefined {
if (!op) return undefined;
try {
return resolveCommand(op, runners);
} catch {
return undefined;
}
}
export function commandFromOp(op: string | undefined, runners: DetectedRunner[]): string | undefined {
return resolveTaskFromOp(op, runners)?.command;
}
export function cwdFromOp(op: string | undefined, runners: DetectedRunner[]): string | undefined {
return resolveTaskFromOp(op, runners)?.cwd;
}
export function titleFromOp(op: string | undefined, runners: DetectedRunner[]): string {
if (!op) return "Run";
const { head } = parseOp(op);
if (!head) return "Run";
const colonIndex = head.indexOf(":");
if (colonIndex > 0) {
const runner = findRunnerById(head.slice(0, colonIndex), runners);
return runner?.label ?? "Run";
}
const matches = findMatchingRunners(head, runners);
return matches.length === 1 ? matches[0]!.label : "Run";
}
function findAmbiguityExample(runners: DetectedRunner[]): { runner: string; task: string } | undefined {
const seen = new Map<string, string>();
for (const runner of runners) {
for (const task of runner.tasks) {
const previousRunner = seen.get(task.name);
if (previousRunner) return { runner: previousRunner, task: task.name };
seen.set(task.name, runner.id);
}
}
const firstRunner = runners[0];
const firstTask = firstRunner?.tasks[0];
return firstRunner && firstTask ? { runner: firstRunner.id, task: firstTask.name } : undefined;
}
export function buildPromptModel(runners: DetectedRunner[]): RecipePromptModel {
const ambiguityExample = findAmbiguityExample(runners);
return {
hasMultipleRunners: runners.length > 1,
ambiguityExampleRunner: ambiguityExample?.runner,
ambiguityExampleTask: ambiguityExample?.task,
runners: runners.map(runner => ({
id: runner.id,
label: runner.label,
commandPrefix: runner.commandPrefix,
tasks: runner.tasks.slice(0, PROMPT_TASK_LIMIT).map(task => ({
name: task.name,
paramSig: task.parameters.length > 0 ? task.parameters.join(" ") : undefined,
command: buildCommand(task.commandPrefix ?? runner.commandPrefix, task.commandName ?? task.name, ""),
doc: task.doc,
cwd: task.cwd,
})),
})),
};
}
@@ -1,131 +0,0 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { $which, isEnoent, logger } from "@oh-my-pi/pi-utils";
import type { DetectedRunner, RunnerTask, TaskRunner } from "../runner";
export interface CargoMetadataTarget {
kind?: string[];
name?: string;
}
export interface CargoMetadataPackage {
id?: string;
name?: string;
targets?: CargoMetadataTarget[];
}
export interface CargoMetadata {
packages?: CargoMetadataPackage[];
workspace_members?: string[];
}
type CargoTargetKind = "bin" | "example" | "test";
async function hasCargoManifest(cwd: string): Promise<boolean> {
try {
const stat = await fs.stat(path.join(cwd, "Cargo.toml"));
return stat.isFile();
} catch (err) {
if (isEnoent(err)) return false;
throw err;
}
}
function shellQuote(value: string): string {
return `'${value.replaceAll("'", `'\\''`)}'`;
}
function cargoTargetKind(target: CargoMetadataTarget): CargoTargetKind | undefined {
if (target.kind?.includes("bin")) return "bin";
if (target.kind?.includes("example")) return "example";
if (target.kind?.includes("test")) return "test";
return undefined;
}
function commandPrefixForTarget(packageName: string, kind: CargoTargetKind): string {
const packageFlag = `--package ${shellQuote(packageName)}`;
switch (kind) {
case "bin":
return `cargo run ${packageFlag} --bin`;
case "example":
return `cargo run ${packageFlag} --example`;
case "test":
return `cargo test ${packageFlag} --test`;
}
}
function taskNameForTarget(
packageName: string,
kind: CargoTargetKind,
targetName: string,
isWorkspace: boolean,
): string {
const category = kind === "bin" ? "bin" : kind;
return isWorkspace ? `${packageName}/${category}/${targetName}` : `${category}/${targetName}`;
}
export function tasksFromCargoMetadata(metadata: CargoMetadata): RunnerTask[] {
const workspaceMembers = new Set(metadata.workspace_members ?? []);
const workspacePackages = (metadata.packages ?? []).filter(pkg => pkg.id && workspaceMembers.has(pkg.id));
const packages = workspacePackages.length > 0 ? workspacePackages : (metadata.packages ?? []);
const isWorkspace = packages.length > 1;
const tasks: RunnerTask[] = [];
const seen = new Set<string>();
for (const pkg of packages) {
if (!pkg.name) continue;
for (const target of pkg.targets ?? []) {
if (!target.name) continue;
const kind = cargoTargetKind(target);
if (!kind) continue;
const name = taskNameForTarget(pkg.name, kind, target.name, isWorkspace);
if (seen.has(name)) continue;
seen.add(name);
tasks.push({
name,
doc: `${pkg.name} ${kind} target ${target.name}`,
parameters: [],
commandPrefix: commandPrefixForTarget(pkg.name, kind),
commandName: shellQuote(target.name),
});
}
}
return tasks;
}
async function readCargoMetadata(cwd: string): Promise<CargoMetadata | null> {
try {
const proc = Bun.spawn(["cargo", "metadata", "--no-deps", "--format-version=1"], {
cwd,
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
});
const [stdout, exit] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
if (exit !== 0) return null;
return JSON.parse(stdout) as CargoMetadata;
} catch (err) {
logger.debug("cargo metadata failed", { error: err instanceof Error ? err.message : String(err) });
return null;
}
}
export const cargoRunner: TaskRunner = {
id: "cargo",
label: "Cargo",
async detect(cwd: string): Promise<DetectedRunner | null> {
try {
if (!$which("cargo")) return null;
if (!(await hasCargoManifest(cwd))) return null;
const metadata = await readCargoMetadata(cwd);
if (!metadata) return null;
const tasks = tasksFromCargoMetadata(metadata);
if (tasks.length === 0) return null;
return { id: "cargo", label: "Cargo", commandPrefix: "cargo", tasks };
} catch (err) {
logger.debug("cargo runner probe failed", { error: err instanceof Error ? err.message : String(err) });
return null;
}
},
};
@@ -1,8 +0,0 @@
import type { TaskRunner } from "../runner";
import { cargoRunner } from "./cargo";
import { justRunner } from "./just";
import { makeRunner } from "./make";
import { pkgRunner } from "./pkg";
import { taskRunner } from "./task";
export const RUNNERS: TaskRunner[] = [justRunner, pkgRunner, cargoRunner, makeRunner, taskRunner];
@@ -1,73 +0,0 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { $which, isEnoent, logger } from "@oh-my-pi/pi-utils";
import type { DetectedRunner, RunnerTask, TaskRunner } from "../runner";
interface JustDumpRecipeRaw {
name?: string;
doc?: string | null;
private?: boolean;
parameters?: Array<{ name?: string }>;
}
interface JustDump {
recipes?: Record<string, JustDumpRecipeRaw>;
}
const JUSTFILE_NAMES = ["justfile", "Justfile", ".justfile"] as const;
async function hasJustfile(cwd: string): Promise<boolean> {
for (const name of JUSTFILE_NAMES) {
try {
const stat = await fs.stat(path.join(cwd, name));
if (stat.isFile()) return true;
} catch (err) {
if (!isEnoent(err)) throw err;
}
}
return false;
}
async function dumpJustTasks(cwd: string): Promise<RunnerTask[] | null> {
try {
const proc = Bun.spawn(["just", "--dump", "--dump-format=json"], {
cwd,
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
});
const [stdout, exit] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
if (exit !== 0) return null;
const dump = JSON.parse(stdout) as JustDump;
const tasks: RunnerTask[] = [];
for (const recipe of Object.values(dump.recipes ?? {})) {
if (!recipe.name || recipe.private) continue;
const parameters = (recipe.parameters ?? [])
.map(parameter => parameter.name)
.filter((name): name is string => typeof name === "string" && name.length > 0);
const doc = typeof recipe.doc === "string" && recipe.doc.length > 0 ? recipe.doc : undefined;
tasks.push({ name: recipe.name, doc, parameters });
}
return tasks;
} catch (err) {
logger.debug("just task detection failed", { error: err instanceof Error ? err.message : String(err) });
return null;
}
}
export const justRunner: TaskRunner = {
id: "just",
label: "Just",
async detect(cwd: string): Promise<DetectedRunner | null> {
try {
if (!$which("just")) return null;
if (!(await hasJustfile(cwd))) return null;
const tasks = await dumpJustTasks(cwd);
if (!tasks || tasks.length === 0) return null;
return { id: "just", label: "Just", commandPrefix: "just", tasks };
} catch (err) {
logger.debug("just runner probe failed", { error: err instanceof Error ? err.message : String(err) });
return null;
}
},
};
@@ -1,101 +0,0 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { $which, isEnoent, logger } from "@oh-my-pi/pi-utils";
import type { DetectedRunner, RunnerTask, TaskRunner } from "../runner";
const MAKEFILE_NAMES = ["Makefile", "makefile", "GNUmakefile"] as const;
const TARGET_PATTERN = /^(?<name>[A-Za-z_][A-Za-z0-9_-]*)\s*:(?!=).*?(?:##\s*(?<doc>.+))?$/u;
const PHONY_PATTERN = /^\.PHONY\s*:\s*(?<targets>.*)$/u;
interface MakeTargetInfo {
name: string;
doc?: string;
order: number;
phony: boolean;
}
async function findMakefile(cwd: string): Promise<string | null> {
for (const name of MAKEFILE_NAMES) {
const candidate = path.join(cwd, name);
try {
const stat = await fs.stat(candidate);
if (stat.isFile()) return candidate;
} catch (err) {
if (!isEnoent(err)) throw err;
}
}
return null;
}
function isVariableAssignment(line: string, name: string): boolean {
return new RegExp(`^\\s*${name}\\s*[:?+]?=`, "u").test(line);
}
function parsePhonyTargets(line: string): string[] {
const match = PHONY_PATTERN.exec(line);
if (!match?.groups?.targets) return [];
return match.groups.targets
.split(/\s+/u)
.map(target => target.trim())
.filter(target => /^[A-Za-z_][A-Za-z0-9_-]*$/u.test(target));
}
function parseMakeTargets(text: string): RunnerTask[] {
const targets = new Map<string, MakeTargetInfo>();
const phonyTargets: string[] = [];
let order = 0;
for (const line of text.split("\n")) {
for (const target of parsePhonyTargets(line)) {
if (!phonyTargets.includes(target)) phonyTargets.push(target);
}
const match = TARGET_PATTERN.exec(line);
const name = match?.groups?.name;
if (!name || name === ".PHONY" || isVariableAssignment(line, name)) continue;
if (targets.has(name)) continue;
const rawDoc = match?.groups?.doc?.trim();
const doc = rawDoc && rawDoc.length > 0 ? rawDoc : undefined;
targets.set(name, { name, doc, order, phony: false });
order += 1;
}
for (const phony of phonyTargets) {
const existing = targets.get(phony);
if (existing) {
existing.phony = true;
continue;
}
targets.set(phony, { name: phony, order, phony: true });
order += 1;
}
const hasPhonyTargets = phonyTargets.length > 0;
return [...targets.values()]
.sort((left, right) => left.order - right.order)
.flatMap(target => {
if (!hasPhonyTargets || target.phony) {
return [{ name: target.name, doc: target.doc, parameters: [] }];
}
if (!target.doc) return [];
return [{ name: target.name, doc: `${target.doc} (file target)`, parameters: [] }];
});
}
export const makeRunner: TaskRunner = {
id: "make",
label: "Make",
async detect(cwd: string): Promise<DetectedRunner | null> {
try {
if (!$which("make")) return null;
const makefile = await findMakefile(cwd);
if (!makefile) return null;
const tasks = parseMakeTargets(await Bun.file(makefile).text());
if (tasks.length === 0) return null;
return { id: "make", label: "Make", commandPrefix: "make", tasks };
} catch (err) {
logger.debug("make runner probe failed", { error: err instanceof Error ? err.message : String(err) });
return null;
}
},
};
@@ -1,167 +0,0 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { $which, isEnoent, logger } from "@oh-my-pi/pi-utils";
import type { DetectedRunner, RunnerTask, TaskRunner } from "../runner";
interface PackageJsonInfo {
name?: string;
scripts: string[];
workspaces: string[];
}
async function resolvePackageRunner(cwd: string): Promise<string> {
const [bunLock, bunLockb, pnpmLock, yarnLock, npmLock, npmShrink] = await Promise.all([
isFile(path.join(cwd, "bun.lock")),
isFile(path.join(cwd, "bun.lockb")),
isFile(path.join(cwd, "pnpm-lock.yaml")),
isFile(path.join(cwd, "yarn.lock")),
isFile(path.join(cwd, "package-lock.json")),
isFile(path.join(cwd, "npm-shrinkwrap.json")),
]);
if (bunLock || bunLockb) return "bun run";
if (pnpmLock) return "pnpm run";
if (yarnLock) return "yarn";
if (npmLock || npmShrink) return "npm run";
if ($which("bun")) return "bun run";
return "npm run";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function shellQuote(value: string): string {
return `'${value.replaceAll("'", `'\\''`)}'`;
}
async function isFile(filePath: string): Promise<boolean> {
try {
const stat = await fs.stat(filePath);
return stat.isFile();
} catch (err) {
if (isEnoent(err)) return false;
throw err;
}
}
function parseWorkspacePatterns(pkg: Record<string, unknown>): string[] {
const { workspaces } = pkg;
if (Array.isArray(workspaces)) return workspaces.filter((entry): entry is string => typeof entry === "string");
if (isRecord(workspaces) && Array.isArray(workspaces.packages)) {
return workspaces.packages.filter((entry): entry is string => typeof entry === "string");
}
return [];
}
function normalizeWorkspacePattern(pattern: string): string {
const negated = pattern.startsWith("!");
const body = negated ? pattern.slice(1) : pattern;
const normalizedBody = body.endsWith("package.json") ? body : `${body.replace(/\/+$/u, "")}/package.json`;
return negated ? `!${normalizedBody}` : normalizedBody;
}
async function readPackageJson(filePath: string): Promise<PackageJsonInfo | null> {
try {
const pkg = (await Bun.file(filePath).json()) as unknown;
if (!isRecord(pkg)) return null;
const scripts = isRecord(pkg.scripts)
? Object.entries(pkg.scripts)
.filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[0].length > 0)
.map(([name]) => name)
: [];
const name = typeof pkg.name === "string" && pkg.name.length > 0 ? pkg.name : undefined;
return { name, scripts, workspaces: parseWorkspacePatterns(pkg) };
} catch (err) {
if (!isEnoent(err)) {
logger.debug("package.json script detection failed", {
error: err instanceof Error ? err.message : String(err),
});
}
return null;
}
}
async function findWorkspacePackageJsons(cwd: string, patterns: string[]): Promise<string[]> {
const includePatterns = patterns.filter(pattern => !pattern.startsWith("!")).map(normalizeWorkspacePattern);
const excludePatterns = patterns.filter(pattern => pattern.startsWith("!")).map(normalizeWorkspacePattern);
const collect = async (pattern: string): Promise<string[]> => {
const out: string[] = [];
for await (const entry of new Bun.Glob(pattern).scan({ cwd, onlyFiles: true })) {
out.push(path.normalize(String(entry)));
}
return out;
};
const [excludedLists, includedLists] = await Promise.all([
Promise.all(excludePatterns.map(pattern => collect(pattern.slice(1)))),
Promise.all(includePatterns.map(pattern => collect(pattern))),
]);
const excluded = new Set<string>(excludedLists.flat());
const files = new Set<string>();
for (const entry of includedLists.flat()) {
if (entry !== "package.json" && !excluded.has(entry)) files.add(entry);
}
return [...files].sort((left, right) => left.localeCompare(right));
}
function packageTaskName(packageName: string | undefined, packageDir: string, scriptName: string): string {
return `${packageName ?? packageDir}/${scriptName}`;
}
function tasksForPackage(options: { pkg: PackageJsonInfo; packageDir: string; namespaced: boolean }): RunnerTask[] {
return options.pkg.scripts.map(scriptName => ({
name: options.namespaced ? packageTaskName(options.pkg.name, options.packageDir, scriptName) : scriptName,
doc: options.namespaced ? options.packageDir : undefined,
parameters: [],
cwd: options.namespaced ? options.packageDir : undefined,
commandName: shellQuote(scriptName),
}));
}
async function readPackageTasks(cwd: string): Promise<RunnerTask[] | null> {
const rootPkg = await readPackageJson(path.join(cwd, "package.json"));
if (!rootPkg) return null;
const workspacePackageJsons = await findWorkspacePackageJsons(cwd, rootPkg.workspaces);
const tasks: RunnerTask[] = [];
if (rootPkg.scripts.length > 0) {
tasks.push(
...tasksForPackage({
pkg: rootPkg,
packageDir: ".",
namespaced: false,
}),
);
}
const pkgs = await Promise.all(workspacePackageJsons.map(p => readPackageJson(path.join(cwd, p))));
pkgs.forEach((pkg, index) => {
if (!pkg || pkg.scripts.length === 0) return;
const packageDir = path.dirname(workspacePackageJsons[index]);
tasks.push(
...tasksForPackage({
pkg,
packageDir,
namespaced: true,
}),
);
});
return tasks.length > 0 ? tasks : null;
}
export const pkgRunner: TaskRunner = {
id: "pkg",
label: "Pkg",
async detect(cwd: string): Promise<DetectedRunner | null> {
try {
const [commandPrefix, tasks] = await Promise.all([resolvePackageRunner(cwd), readPackageTasks(cwd)]);
if (!tasks || tasks.length === 0) return null;
return { id: "pkg", label: "Pkg", commandPrefix, tasks };
} catch (err) {
logger.debug("package runner probe failed", { error: err instanceof Error ? err.message : String(err) });
return null;
}
},
};
@@ -1,72 +0,0 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { $which, isEnoent, logger } from "@oh-my-pi/pi-utils";
import type { DetectedRunner, RunnerTask, TaskRunner } from "../runner";
interface TaskListEntry {
name?: string;
desc?: string;
summary?: string;
}
interface TaskListJson {
tasks?: TaskListEntry[];
}
const TASKFILE_NAMES = ["Taskfile.yml", "Taskfile.yaml"] as const;
async function hasTaskfile(cwd: string): Promise<boolean> {
for (const name of TASKFILE_NAMES) {
try {
const stat = await fs.stat(path.join(cwd, name));
if (stat.isFile()) return true;
} catch (err) {
if (!isEnoent(err)) throw err;
}
}
return false;
}
async function listTaskfileTasks(cwd: string): Promise<RunnerTask[] | null> {
try {
const proc = Bun.spawn(["task", "--list-all", "--json"], {
cwd,
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
});
const [stdout, exit] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
if (exit !== 0) return null;
const list = JSON.parse(stdout) as TaskListJson;
const tasks = (list.tasks ?? [])
.filter(
(task): task is TaskListEntry & { name: string } => typeof task.name === "string" && task.name.length > 0,
)
.map(task => {
const desc = typeof task.desc === "string" && task.desc.length > 0 ? task.desc : undefined;
const summary = typeof task.summary === "string" && task.summary.length > 0 ? task.summary : undefined;
return { name: task.name, doc: desc ?? summary, parameters: [] };
});
return tasks.length > 0 ? tasks : null;
} catch (err) {
logger.debug("task runner list failed", { error: err instanceof Error ? err.message : String(err) });
return null;
}
}
export const taskRunner: TaskRunner = {
id: "task",
label: "Task",
async detect(cwd: string): Promise<DetectedRunner | null> {
try {
if (!$which("task")) return null;
if (!(await hasTaskfile(cwd))) return null;
const tasks = await listTaskfileTasks(cwd);
if (!tasks || tasks.length === 0) return null;
return { id: "task", label: "Task", commandPrefix: "task", tasks };
} catch (err) {
logger.debug("task runner probe failed", { error: err instanceof Error ? err.message : String(err) });
return null;
}
},
};
@@ -24,7 +24,6 @@ import { inspectImageToolRenderer } from "./inspect-image-renderer";
import { jobToolRenderer } from "./job";
import { recallToolRenderer, reflectToolRenderer, retainToolRenderer } from "./memory-render";
import { readToolRenderer } from "./read";
import { recipeToolRenderer } from "./recipe/render";
import { resolveToolRenderer } from "./resolve";
import { searchToolRenderer } from "./search";
import { searchToolBm25Renderer } from "./search-tool-bm25";
@@ -51,7 +50,6 @@ export const toolRenderers: Record<string, ToolRenderer> = {
ast_edit: astEditToolRenderer as ToolRenderer,
bash: bashToolRenderer as ToolRenderer,
browser: browserToolRenderer as ToolRenderer,
recipe: recipeToolRenderer as ToolRenderer,
debug: debugToolRenderer as ToolRenderer,
eval: evalToolRenderer as ToolRenderer,
edit: editToolRenderer as ToolRenderer,