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:
@@ -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**
|
||||
|
||||
@@ -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 `<runner-id>:<task>`.
|
||||
- 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<BashToolDetails>` 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 <n>`.
|
||||
- `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-id>:<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 `<package-name-or-dir>/<script>` and set `cwd` to that package directory.
|
||||
- Script names are shell-quoted into `commandName`, so a task like `build` becomes `bun run 'build'` / `npm run 'build'` / similar.
|
||||
- `pkg` command prefix selection prefers lockfiles in this order: `bun.lock`/`bun.lockb`, `pnpm-lock.yaml`, `yarn.lock`, `package-lock.json`/`npm-shrinkwrap.json`; otherwise it falls back to `bun run` if `bun` exists, else `npm run`.
|
||||
- `cargo` task names are generated from metadata targets:
|
||||
- Single-package manifests: `bin/<name>`, `example/<name>`, `test/<name>`.
|
||||
- Multi-package workspaces: `<package>/bin/<name>`, `<package>/example/<name>`, `<package>/test/<name>`.
|
||||
- Each task overrides `commandPrefix` to the full `cargo run ... --bin|--example` or `cargo test ... --test` prefix, and `commandName` to the quoted target name.
|
||||
- `make` target parsing is static text parsing, not `make -qp` output:
|
||||
- Recognizes makefiles named `Makefile`, `makefile`, `GNUmakefile`.
|
||||
- Uses `.PHONY` lines to decide whether to include undocumented file targets; without any `.PHONY`, all parsed targets are exposed.
|
||||
- If `.PHONY` exists, documented non-phony targets are kept with ` (file target)` appended to `doc`.
|
||||
- `just` detection ignores private recipes and preserves declared parameter names only for prompt display; execution still accepts arbitrary `tail` text.
|
||||
- `task` detection uses `desc` first, then `summary`, for task documentation.
|
||||
- Recipe has no env input of its own. Commands inherit whatever environment `BashTool` supplies for normal bash execution in the session.
|
||||
@@ -25,6 +25,10 @@
|
||||
- Fixed session-title generation latching onto literal text inside fenced code blocks — a pasted UI mockup containing "Welcome to Claude Code v2.1.158" titled the session "Setup Screen for Claude Code v2.1.158" instead of capturing the actual request. The first user message now has fenced code blocks stripped before titling (both the online `pi/smol` and local CPU model paths share the same preprocessing), with a fallback to the original message when stripping would leave too little to title from (e.g. a message that is essentially just a code block).
|
||||
- Fixed slash-command autocomplete repaint requests so Windows Terminal sessions with unknown native viewport state keep updating the input box and candidate list. ([#1550](https://github.com/can1357/oh-my-pi/issues/1550))
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed the `recipe` tool and its `recipe.enabled` setting. Task-runner targets (just/package.json/Cargo/make/Taskfile) are invoked directly through `bash`.
|
||||
|
||||
## [15.6.0] - 2026-05-30
|
||||
### Added
|
||||
|
||||
|
||||
@@ -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}}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Map<string, { loadMode?: string; summa
|
||||
new AskTool({ ...toolSession, hasUI: true }),
|
||||
new SshTool(toolSession, [], new Map(), ""),
|
||||
new JobTool(toolSession),
|
||||
new RecipeTool(toolSession, []),
|
||||
new IrcTool(toolSession),
|
||||
]) {
|
||||
metadata.set(tool.name, { loadMode: tool.loadMode, summary: tool.summary });
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import {
|
||||
buildPromptModel,
|
||||
commandFromOp,
|
||||
createTools,
|
||||
type DetectedRunner,
|
||||
RecipeTool,
|
||||
resolveCommand,
|
||||
type ToolSession,
|
||||
tasksFromCargoMetadata,
|
||||
titleFromOp,
|
||||
} from "@oh-my-pi/pi-coding-agent/tools";
|
||||
|
||||
const detectedRunners: DetectedRunner[] = [
|
||||
{
|
||||
id: "just",
|
||||
label: "Just",
|
||||
commandPrefix: "just",
|
||||
tasks: [
|
||||
{ name: "build", parameters: [] },
|
||||
{ name: "test", doc: "Run just tests", parameters: ["filter"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "pkg",
|
||||
label: "Pkg",
|
||||
commandPrefix: "bun run",
|
||||
tasks: [
|
||||
{ name: "test", parameters: [] },
|
||||
{ name: "test:unit", parameters: [] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cargo",
|
||||
label: "Cargo",
|
||||
commandPrefix: "cargo",
|
||||
tasks: [
|
||||
{
|
||||
name: "server/bin/serve",
|
||||
parameters: [],
|
||||
commandPrefix: "cargo run --package 'server' --bin",
|
||||
commandName: "'serve'",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createTestSession(cwd: string, settings = Settings.isolated()): ToolSession {
|
||||
return {
|
||||
cwd,
|
||||
hasUI: false,
|
||||
getSessionFile: () => 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user