From dfa6007f36beb09f09314d32d9e4635fed34e8f5 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 31 May 2026 01:41:01 +0200 Subject: [PATCH] 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. --- README.md | 1 - docs/tools/recipe.md | 155 ------------- packages/coding-agent/CHANGELOG.md | 4 + .../src/config/settings-schema.ts | 12 +- .../src/export/html/template.generated.ts | 2 +- .../coding-agent/src/export/html/template.js | 12 - .../src/prompts/system/orchestrate-notice.md | 2 +- .../coding-agent/src/prompts/tools/recipe.md | 16 -- packages/coding-agent/src/tools/index.ts | 11 - .../coding-agent/src/tools/recipe/index.ts | 81 ------- .../coding-agent/src/tools/recipe/render.ts | 19 -- .../coding-agent/src/tools/recipe/runner.ts | 219 ------------------ .../src/tools/recipe/runners/cargo.ts | 131 ----------- .../src/tools/recipe/runners/index.ts | 8 - .../src/tools/recipe/runners/just.ts | 73 ------ .../src/tools/recipe/runners/make.ts | 101 -------- .../src/tools/recipe/runners/pkg.ts | 167 ------------- .../src/tools/recipe/runners/task.ts | 72 ------ packages/coding-agent/src/tools/renderers.ts | 2 - .../test/tool-discovery/initial-tools.test.ts | 3 - .../coding-agent/test/tools/recipe.test.ts | 219 ------------------ 21 files changed, 7 insertions(+), 1303 deletions(-) delete mode 100644 docs/tools/recipe.md delete mode 100644 packages/coding-agent/src/prompts/tools/recipe.md delete mode 100644 packages/coding-agent/src/tools/recipe/index.ts delete mode 100644 packages/coding-agent/src/tools/recipe/render.ts delete mode 100644 packages/coding-agent/src/tools/recipe/runner.ts delete mode 100644 packages/coding-agent/src/tools/recipe/runners/cargo.ts delete mode 100644 packages/coding-agent/src/tools/recipe/runners/index.ts delete mode 100644 packages/coding-agent/src/tools/recipe/runners/just.ts delete mode 100644 packages/coding-agent/src/tools/recipe/runners/make.ts delete mode 100644 packages/coding-agent/src/tools/recipe/runners/pkg.ts delete mode 100644 packages/coding-agent/src/tools/recipe/runners/task.ts delete mode 100644 packages/coding-agent/test/tools/recipe.test.ts diff --git a/README.md b/README.md index fcf77bf70..cf28855c2 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,6 @@ Stealth's on by default, so pages see a normal user instead of a headless bot. T - `bash` — workspace shell, with optional PTY or background-job dispatch. - `eval` — persistent Python and JavaScript cells with shared prelude and tool re-entry. -- `recipe` — invoke a target from a detected task runner — bun, just, make, cargo. - `ssh` — one remote command against a configured host. **Code intelligence** diff --git a/docs/tools/recipe.md b/docs/tools/recipe.md deleted file mode 100644 index 9589c670e..000000000 --- a/docs/tools/recipe.md +++ /dev/null @@ -1,155 +0,0 @@ -# recipe - -> Run a task exposed by a detected project task runner. - -## Source -- Entry: `packages/coding-agent/src/tools/recipe/index.ts` -- Model-facing prompt: `packages/coding-agent/src/prompts/tools/recipe.md` -- Key collaborators: - - `packages/coding-agent/src/tools/recipe/runner.ts` — op parsing, task resolution, prompt model. - - `packages/coding-agent/src/tools/recipe/render.ts` — shell-style call/result rendering. - - `packages/coding-agent/src/tools/recipe/runners/index.ts` — runner registration order. - - `packages/coding-agent/src/tools/recipe/runners/just.ts` — detect `just` recipes from justfiles. - - `packages/coding-agent/src/tools/recipe/runners/pkg.ts` — detect `package.json` scripts and workspaces. - - `packages/coding-agent/src/tools/recipe/runners/cargo.ts` — detect Cargo run/test targets. - - `packages/coding-agent/src/tools/recipe/runners/make.ts` — parse make targets from makefiles. - - `packages/coding-agent/src/tools/recipe/runners/task.ts` — detect Taskfile tasks via `task --list-all`. - - `packages/coding-agent/src/tools/bash.ts` — actual command execution, truncation, cwd/env handling. - -## Inputs - -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `op` | `string` | Yes | Single string containing the task selector plus trailing arguments. The first whitespace-delimited token selects the task; the remainder is appended verbatim to the resolved runner command. Examples from schema/prompt: `test`, `build --release`, `pkg-a/test`, `crate/bin/server`, `pkg:test --watch`. | - -### `op` grammar - -```text -op := S* head (S+ tail)? -head := explicit-runner / implicit-task -explicit-runner := runner-id ":" task-token -implicit-task := task-token -runner-id := detected runner id (`just` | `pkg` | `cargo` | `make` | `task`) -task-token := first non-whitespace token; may contain `/` -tail := remaining characters after the first whitespace run -``` - -Resolution rules from `resolveRunnerAndTask()`: -- Leading whitespace is ignored; an empty `op` throws `ToolError` with the available task list. -- Only the first token is parsed structurally. Everything after the first whitespace run becomes `tail` and is appended to the command unchanged. -- If `head` contains `:` and the prefix matches a detected runner id, the suffix must exactly match a task in that runner. -- Otherwise `head` is treated as a task name and matched across all detected runners. -- If exactly one runner has that task, it is used. -- If multiple runners have that task, the call is rejected and the error tells the model to use `:`. -- Namespaced task names generated by runners use `/`, not `:`. `/` is part of the task name, not a parser separator. - -## Outputs -- Delegates directly to `BashTool.execute()` and returns the same `AgentToolResult` shape. -- Success path: one text content block containing merged command output (`result.output` from bash execution, or `(no output)`), plus any timeout clamp notice appended after a blank line. -- Recipe does not return separate `stdout`, `stderr`, or `exitCode` fields. `stdout`/`stderr` are already merged into the text block by bash execution; `exitCode` is only observed indirectly (success requires `0`, non-zero becomes an error). -- Error path: throws `ToolError`; for non-zero exits the message is the merged output followed by `Command exited with code `. -- `details` may include: - - `timeoutSeconds`: effective timeout used by bash. - - `requestedTimeoutSeconds`: only when bash clamped a requested timeout; recipe never sets one itself. - - `meta`: output truncation metadata from bash execution. - - `async`: defined by bash background execution paths, but recipe does not expose an `async` input. -- When bash output is truncated, the full text is stored in an artifact and referenced via bash truncation metadata. -- Call/result rendering in the TUI uses bash shell rendering with a resolved title, command preview, and optional task cwd. - -## Flow -1. `RecipeTool.createIf()` in `packages/coding-agent/src/tools/recipe/index.ts` checks `session.settings.get("recipe.enabled")`; disabled returns `null`. -2. It probes every runner in `RUNNERS` from `packages/coding-agent/src/tools/recipe/runners/index.ts` with `Promise.all(...)` in this order: `just`, `pkg`, `cargo`, `make`, `task`. -3. Each runner returns either `null` or a `DetectedRunner { id, label, commandPrefix, tasks }`; runners with zero tasks are discarded. -4. If no runners remain, the tool is not registered. -5. Constructor stores detected runners, instantiates `BashTool`, renders the model-facing description by passing `buildPromptModel(runners)` into `packages/coding-agent/src/prompts/tools/recipe.md`, and builds shell renderers from `createRecipeToolRenderer()`. -6. On execution, `RecipeTool.execute()` calls `resolveCommand(op, this.#runners)`. -7. `resolveCommand()` in `packages/coding-agent/src/tools/recipe/runner.ts`: - 1. `parseOp()` trims only leading whitespace, extracts the first non-whitespace token as `head`, and keeps the remainder as `tail`. - 2. `resolveRunnerAndTask()` resolves `head` either as `runnerId:taskName` or as an unqualified task name. - 3. It throws `ToolError` for empty ops, missing explicit tasks, ambiguous task names, or unknown tasks; all error variants include the available task list. - 4. It builds the final shell command with `buildCommand(commandPrefix, commandName, tail)`, joining non-empty parts with spaces. - 5. If the task defines `cwd`, that relative path is returned alongside the command. -8. `RecipeTool.execute()` forwards `{ command, cwd }` into `BashTool.execute()`; recipe does not pass timeout, env, async, or pty options. -9. `BashTool.execute()` resolves internal URLs, validates/normalizes cwd against `session.cwd`, clamps timeout, applies bash interception rules, runs the command, and formats the final result. - -## Modes / Variants -- Tool enablement: - - Disabled by `recipe.enabled` setting: tool is absent. - - Enabled but no detected tasks: tool is absent. -- Task selection: - - Unqualified task name: succeeds only when exactly one detected runner owns that task. - - Explicit runner-qualified task: `:`. -- Runner detection paths: - - `just`: requires `just` on `PATH`, a justfile, and successful `just --dump --dump-format=json`. - - `pkg`: requires a readable root `package.json`; picks a package manager command from lockfiles or `bun` availability; discovers root scripts and workspace package scripts. - - `cargo`: requires `cargo` on `PATH`, `Cargo.toml`, and successful `cargo metadata --no-deps --format-version=1`. - - `make`: requires `make` on `PATH` and a makefile; parses targets statically. - - `task`: requires `task` on `PATH`, a Taskfile, and successful `task --list-all --json`. -- Execution path: - - Always the synchronous `bash` call surface from recipe inputs. - - Bash may still auto-background long-running work if `bash.autoBackground.enabled` and session async job support are enabled. - -## Side Effects -- Filesystem - - Reads manifests from the session cwd during detection: justfiles, `package.json`, workspace `package.json` files, `Cargo.toml`, makefiles, `Taskfile.yml` / `Taskfile.yaml`. - - Command execution runs in `session.cwd` or a task-specific relative cwd resolved under it. - - Bash may allocate output artifacts for truncated command output. -- Subprocesses / native bindings - - Detection may spawn `just --dump --dump-format=json`, `cargo metadata --no-deps --format-version=1`, and `task --list-all --json`. - - Execution spawns the resolved shell command through `BashTool` / `executeBash()`. -- Session state (transcript, memory, jobs, checkpoints, registries) - - Tool availability depends on session settings. - - Constructor prompt text is specialized to detected runners/tasks. - - Bash execution may create async job records and output artifacts if bash auto-background triggers. -- User-visible prompts / interactive UI - - The model-facing tool description lists detected runners and up to 20 tasks per runner. - - TUI rendering shows a shell-style preview using the resolved title/command/cwd. -- Background work / cancellation - - Detection is parallelized across runners. - - Runtime command execution honors the passed abort signal through `BashTool`. - -## Limits & Caps -- Prompt task listing is capped at `PROMPT_TASK_LIMIT = 20` per runner in `packages/coding-agent/src/tools/recipe/runner.ts`; this affects the rendered tool description, not execution. -- Recipe itself defines no timeout input; delegated bash execution therefore uses bash's default `timeout = 300` seconds from `packages/coding-agent/src/tools/bash.ts`. -- Bash clamps timeouts to the configured bash range (`clampTimeout("bash", ...)` in `packages/coding-agent/src/tools/bash.ts`), but recipe cannot request a custom value. -- `pkg` workspace discovery normalizes workspace globs to `.../package.json` and sorts matched package files lexicographically before task generation. -- `cargo` deduplicates generated task names with a `Set`, so duplicate targets collapse to one recipe task. - -## Errors -- Detection failures in runner modules are mostly soft-failed: - - Missing binaries, missing manifests, parse failures, or non-zero probe exits usually return `null` and log with `logger.debug(...)`. - - Result: the affected runner disappears instead of surfacing an error to the model. -- Invocation failures are hard errors from `resolveRunnerAndTask()`: - - Empty `op`. - - Explicit runner prefix with missing/empty task. - - Ambiguous unqualified task name across runners. - - Unknown task name. -- Execution failures come from `BashTool.execute()`: - - Invalid cwd. - - Bash interceptor blocks. - - Aborts/timeouts. - - Non-zero exit codes. - - Missing exit status. -- All `resolveRunnerAndTask()` errors include the current available task list to help the model retry. - -## Notes -- `RecipeTool` sets `concurrency = "exclusive"`; calls do not run concurrently with other exclusive tools. -- Tool registration is all-or-nothing per runner: a detected runner with zero tasks is dropped. -- Runner ids are fixed string literals from the runner modules: `just`, `pkg`, `cargo`, `make`, `task`. -- `buildPromptModel()` includes each task's rendered command (`commandPrefix` + `commandName`) and relative cwd when present; the prompt therefore exposes the exact shell form recipe will run. -- `pkg` task names: - - Root `package.json` scripts keep bare names like `test`. - - Workspace scripts are always namespaced as `/\n \n \n \n\n\n"; +export const TEMPLATE = "\n\n\n \n \n Session Export\n \n \n\n\n \n
\n
\n \n
\n
\n
\n
\n
\n
\n \"\"\n
\n
\n\n \n \n \n \n\n\n"; diff --git a/packages/coding-agent/src/export/html/template.js b/packages/coding-agent/src/export/html/template.js index 3df112bea..0faea4a1d 100644 --- a/packages/coding-agent/src/export/html/template.js +++ b/packages/coding-agent/src/export/html/template.js @@ -1476,17 +1476,6 @@ return html; } - function renderRecipe(name, args, result, ctx) { - const op = str(args.op) || '?'; - let html = toolHead('recipe', '' + escapeHtml(op) + ''); - 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, }; diff --git a/packages/coding-agent/src/prompts/system/orchestrate-notice.md b/packages/coding-agent/src/prompts/system/orchestrate-notice.md index b627e610f..2f5e56fad 100644 --- a/packages/coding-agent/src/prompts/system/orchestrate-notice.md +++ b/packages/coding-agent/src/prompts/system/orchestrate-notice.md @@ -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. -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. diff --git a/packages/coding-agent/src/prompts/tools/recipe.md b/packages/coding-agent/src/prompts/tools/recipe.md deleted file mode 100644 index 24436a884..000000000 --- a/packages/coding-agent/src/prompts/tools/recipe.md +++ /dev/null @@ -1,16 +0,0 @@ -Run a recipe / script / target from the project's task runners. - - -- `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`. - - -{{#each runners}} - -{{#each tasks}} -- `{{name}}{{#if paramSig}} {{paramSig}}{{/if}}`{{#if doc}} — {{doc}}{{/if}}{{#if command}} (`{{command}}`{{#if cwd}} in `{{cwd}}`{{/if}}){{/if}} -{{/each}} - -{{/each}} diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index 59dac87e3..34f6ad600 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -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 = { 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") ?? ""); } diff --git a/packages/coding-agent/src/tools/recipe/index.ts b/packages/coding-agent/src/tools/recipe/index.ts deleted file mode 100644 index 90a696e4f..000000000 --- a/packages/coding-agent/src/tools/recipe/index.ts +++ /dev/null @@ -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; - -type RecipeRenderResult = { - content: Array<{ type: string; text?: string }>; - details?: BashToolDetails; - isError?: boolean; -}; - -export class RecipeTool implements AgentTool { - 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 { - 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, - ctx?: AgentToolContext, - ): Promise> { - 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"; diff --git a/packages/coding-agent/src/tools/recipe/render.ts b/packages/coding-agent/src/tools/recipe/render.ts deleted file mode 100644 index cecc49043..000000000 --- a/packages/coding-agent/src/tools/recipe/render.ts +++ /dev/null @@ -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({ - resolveTitle: args => titleFromOp(args?.op, runners), - resolveCommand: args => commandFromOp(args?.op, runners), - resolveCwd: args => cwdFromOp(args?.op, runners), - }); -} - -export const recipeToolRenderer = createRecipeToolRenderer([]); diff --git a/packages/coding-agent/src/tools/recipe/runner.ts b/packages/coding-agent/src/tools/recipe/runner.ts deleted file mode 100644 index 253ba5d64..000000000 --- a/packages/coding-agent/src/tools/recipe/runner.ts +++ /dev/null @@ -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; -} - -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 \`:\`, 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(); - 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, - })), - })), - }; -} diff --git a/packages/coding-agent/src/tools/recipe/runners/cargo.ts b/packages/coding-agent/src/tools/recipe/runners/cargo.ts deleted file mode 100644 index e51c04697..000000000 --- a/packages/coding-agent/src/tools/recipe/runners/cargo.ts +++ /dev/null @@ -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 { - 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(); - - 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 { - 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 { - 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; - } - }, -}; diff --git a/packages/coding-agent/src/tools/recipe/runners/index.ts b/packages/coding-agent/src/tools/recipe/runners/index.ts deleted file mode 100644 index 534bb3470..000000000 --- a/packages/coding-agent/src/tools/recipe/runners/index.ts +++ /dev/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]; diff --git a/packages/coding-agent/src/tools/recipe/runners/just.ts b/packages/coding-agent/src/tools/recipe/runners/just.ts deleted file mode 100644 index 8bc0c23a0..000000000 --- a/packages/coding-agent/src/tools/recipe/runners/just.ts +++ /dev/null @@ -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; -} - -const JUSTFILE_NAMES = ["justfile", "Justfile", ".justfile"] as const; - -async function hasJustfile(cwd: string): Promise { - 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 { - 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 { - 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; - } - }, -}; diff --git a/packages/coding-agent/src/tools/recipe/runners/make.ts b/packages/coding-agent/src/tools/recipe/runners/make.ts deleted file mode 100644 index 4366a7daf..000000000 --- a/packages/coding-agent/src/tools/recipe/runners/make.ts +++ /dev/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 = /^(?[A-Za-z_][A-Za-z0-9_-]*)\s*:(?!=).*?(?:##\s*(?.+))?$/u; -const PHONY_PATTERN = /^\.PHONY\s*:\s*(?.*)$/u; - -interface MakeTargetInfo { - name: string; - doc?: string; - order: number; - phony: boolean; -} - -async function findMakefile(cwd: string): Promise { - 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(); - 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 { - 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; - } - }, -}; diff --git a/packages/coding-agent/src/tools/recipe/runners/pkg.ts b/packages/coding-agent/src/tools/recipe/runners/pkg.ts deleted file mode 100644 index eedca6707..000000000 --- a/packages/coding-agent/src/tools/recipe/runners/pkg.ts +++ /dev/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 { - 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 { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function shellQuote(value: string): string { - return `'${value.replaceAll("'", `'\\''`)}'`; -} - -async function isFile(filePath: string): Promise { - try { - const stat = await fs.stat(filePath); - return stat.isFile(); - } catch (err) { - if (isEnoent(err)) return false; - throw err; - } -} - -function parseWorkspacePatterns(pkg: Record): 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 { - 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 { - const includePatterns = patterns.filter(pattern => !pattern.startsWith("!")).map(normalizeWorkspacePattern); - const excludePatterns = patterns.filter(pattern => pattern.startsWith("!")).map(normalizeWorkspacePattern); - - const collect = async (pattern: string): Promise => { - 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(excludedLists.flat()); - const files = new Set(); - 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 { - 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 { - 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; - } - }, -}; diff --git a/packages/coding-agent/src/tools/recipe/runners/task.ts b/packages/coding-agent/src/tools/recipe/runners/task.ts deleted file mode 100644 index 6e79ef4ed..000000000 --- a/packages/coding-agent/src/tools/recipe/runners/task.ts +++ /dev/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 { - 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 { - 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 { - 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; - } - }, -}; diff --git a/packages/coding-agent/src/tools/renderers.ts b/packages/coding-agent/src/tools/renderers.ts index f36f2c97a..cfaafc8dc 100644 --- a/packages/coding-agent/src/tools/renderers.ts +++ b/packages/coding-agent/src/tools/renderers.ts @@ -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 = { 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, diff --git a/packages/coding-agent/test/tool-discovery/initial-tools.test.ts b/packages/coding-agent/test/tool-discovery/initial-tools.test.ts index 12e1c33a2..801189744 100644 --- a/packages/coding-agent/test/tool-discovery/initial-tools.test.ts +++ b/packages/coding-agent/test/tool-discovery/initial-tools.test.ts @@ -9,7 +9,6 @@ import { DEFAULT_ESSENTIAL_TOOL_NAMES, IrcTool, JobTool, - RecipeTool, SshTool, } from "../../src/tools/index"; @@ -26,7 +25,6 @@ const allToolsSettings = Settings.isolated({ "web_search.enabled": true, "browser.enabled": true, "checkpoint.enabled": true, - "recipe.enabled": true, "todo.enabled": true, "memory.backend": "mnemosyne", "tools.discoveryMode": "all", @@ -50,7 +48,6 @@ async function getToolMetadata(): Promise null, - getSessionSpawns: () => "*", - settings, - }; -} - -describe("recipe", () => { - afterEach(async () => { - await Promise.all(tempDirs.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true }))); - }); - - it("resolves bare unique tasks and preserves forwarded args", () => { - expect(resolveCommand("build --release --flag", detectedRunners)).toEqual({ - command: "just build --release --flag", - }); - }); - - it("requires runner id when a bare task is ambiguous", () => { - expect(() => resolveCommand("test", detectedRunners)).toThrow(/multiple runners \(just, pkg\)/); - expect(() => resolveCommand("test", detectedRunners)).toThrow(/just:test/); - }); - - it("allows colon-containing task names when the prefix is not a runner id", () => { - expect(resolveCommand("test:unit --watch", detectedRunners)).toEqual({ command: "bun run test:unit --watch" }); - }); - - it("routes explicit runner-prefixed tasks", () => { - expect(resolveCommand("pkg:test --watch", detectedRunners)).toEqual({ command: "bun run test --watch" }); - expect(titleFromOp("pkg:test", detectedRunners)).toBe("Pkg"); - }); - - it("routes namespaced tasks through task-specific command prefixes", () => { - expect(resolveCommand("pkg:test --watch", detectedRunners)).toEqual({ command: "bun run test --watch" }); - expect(resolveCommand("cargo:server/bin/serve -- --port 0", detectedRunners)).toEqual({ - command: "cargo run --package 'server' --bin 'serve' -- --port 0", - }); - }); - - it("propagates per-task cwd through resolveCommand", () => { - const runners: DetectedRunner[] = [ - { - id: "pkg", - label: "Pkg", - commandPrefix: "bun run", - tasks: [{ name: "pkg-a/test", parameters: [], cwd: "packages/pkg-a", commandName: "'test'" }], - }, - ]; - expect(resolveCommand("pkg-a/test --watch", runners)).toEqual({ - command: "bun run 'test' --watch", - cwd: "packages/pkg-a", - }); - }); - - it("returns renderer fallbacks for unresolved or streaming ops", () => { - expect(commandFromOp("", detectedRunners)).toBeUndefined(); - expect(commandFromOp("missing", detectedRunners)).toBeUndefined(); - expect(titleFromOp("test", detectedRunners)).toBe("Run"); - expect(titleFromOp("", detectedRunners)).toBe("Run"); - }); - - it("builds prompt model with parameter signatures and ambiguity guidance", () => { - const model = buildPromptModel(detectedRunners); - expect(model.hasMultipleRunners).toBe(true); - expect(model.ambiguityExampleRunner).toBe("just"); - expect(model.ambiguityExampleTask).toBe("test"); - expect(model.runners[0]?.tasks[1]?.paramSig).toBe("filter"); - }); - - it("maps Cargo workspace bins examples and tests to namespaced tasks", () => { - const tasks = tasksFromCargoMetadata({ - workspace_members: ["crate-a-id", "crate-b-id"], - packages: [ - { - id: "crate-a-id", - name: "crate-a", - targets: [ - { name: "server", kind: ["bin"] }, - { name: "demo", kind: ["example"] }, - { name: "integration", kind: ["test"] }, - { name: "crate_a", kind: ["lib"] }, - ], - }, - { - id: "crate-b-id", - name: "crate-b", - targets: [{ name: "worker", kind: ["bin"] }], - }, - ], - }); - - expect(tasks.map(task => task.name)).toEqual([ - "crate-a/bin/server", - "crate-a/example/demo", - "crate-a/test/integration", - "crate-b/bin/worker", - ]); - expect( - resolveCommand("cargo:crate-a/example/demo", [{ id: "cargo", label: "Cargo", commandPrefix: "cargo", tasks }]), - ).toEqual({ command: "cargo run --package 'crate-a' --example 'demo'" }); - }); - - it("detects package scripts and forwards execution through bash", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-recipe-")); - tempDirs.push(dir); - await Bun.write(path.join(dir, "package.json"), JSON.stringify({ scripts: { "say-ok": "echo ok" } }, null, 2)); - await Bun.write(path.join(dir, "bun.lock"), ""); - - const tool = await RecipeTool.createIf(createTestSession(dir)); - expect(tool).not.toBeNull(); - const result = await tool!.execute("tool-call", { op: "say-ok" }); - const text = result.content.find(block => block.type === "text")?.text ?? ""; - expect(text).toContain("ok"); - }); - - it("keeps root package scripts bare and exposes workspace package scripts as package-name/script tasks", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-recipe-workspace-")); - tempDirs.push(dir); - await Bun.write( - path.join(dir, "package.json"), - JSON.stringify({ name: "root-app", workspaces: ["packages/*"], scripts: { root: "echo root" } }, null, 2), - ); - await Bun.write(path.join(dir, "bun.lock"), ""); - await fs.mkdir(path.join(dir, "packages", "pkg-a"), { recursive: true }); - await Bun.write( - path.join(dir, "packages", "pkg-a", "package.json"), - JSON.stringify({ name: "pkg-a", scripts: { "say-ok": "echo workspace-ok" } }, null, 2), - ); - - const tool = await RecipeTool.createIf(createTestSession(dir)); - expect(tool).not.toBeNull(); - expect(tool!.description).toContain("root"); - expect(tool!.description).not.toContain("root-app/root"); - expect(tool!.description).toContain("pkg-a/say-ok"); - const rootResult = await tool!.execute("tool-call", { op: "root" }); - const rootText = rootResult.content.find(block => block.type === "text")?.text ?? ""; - expect(rootText).toContain("root"); - const result = await tool!.execute("tool-call", { op: "pkg-a/say-ok" }); - const text = result.content.find(block => block.type === "text")?.text ?? ""; - expect(text).toContain("workspace-ok"); - }); - - it("auto-includes recipe when bash is requested and a runner is detected", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-recipe-auto-")); - tempDirs.push(dir); - await Bun.write(path.join(dir, "package.json"), JSON.stringify({ scripts: { test: "echo t" } }, null, 2)); - await Bun.write(path.join(dir, "bun.lock"), ""); - - const tools = await createTools(createTestSession(dir), ["bash"]); - const names = tools.map(tool => tool.name); - expect(names).toContain("bash"); - expect(names).toContain("recipe"); - }); - - it("is absent when disabled even if a package manifest is present", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-recipe-disabled-")); - tempDirs.push(dir); - await Bun.write(path.join(dir, "package.json"), JSON.stringify({ scripts: { test: "echo t" } }, null, 2)); - const settings = Settings.isolated({ "recipe.enabled": false }); - - expect(await RecipeTool.createIf(createTestSession(dir, settings))).toBeNull(); - }); -});