@oh-my-pi/pi-coding-agent 14.5.7 → 14.5.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/package.json +7 -7
- package/src/config/model-registry.ts +23 -1
- package/src/config/settings-schema.ts +23 -0
- package/src/modes/components/settings-defs.ts +10 -0
- package/src/modes/controllers/event-controller.ts +14 -9
- package/src/modes/controllers/input-controller.ts +6 -0
- package/src/modes/interactive-mode.ts +15 -1
- package/src/modes/types.ts +1 -0
- package/src/prompts/tools/run-command.md +16 -0
- package/src/tools/bash.ts +149 -115
- package/src/tools/index.ts +11 -0
- package/src/tools/renderers.ts +2 -0
- package/src/tools/run-command/index.ts +80 -0
- package/src/tools/run-command/render.ts +18 -0
- package/src/tools/run-command/runner.ts +198 -0
- package/src/tools/run-command/runners/cargo.ts +131 -0
- package/src/tools/run-command/runners/index.ts +8 -0
- package/src/tools/run-command/runners/just.ts +73 -0
- package/src/tools/run-command/runners/make.ts +101 -0
- package/src/tools/run-command/runners/pkg.ts +195 -0
- package/src/tools/run-command/runners/task.ts +72 -0
package/src/tools/index.ts
CHANGED
|
@@ -41,6 +41,7 @@ import { RenderMermaidTool } from "./render-mermaid";
|
|
|
41
41
|
import { createReportToolIssueTool, isAutoQaEnabled } from "./report-tool-issue";
|
|
42
42
|
import { ResolveTool } from "./resolve";
|
|
43
43
|
import { reportFindingTool } from "./review";
|
|
44
|
+
import { RunCommandTool } from "./run-command";
|
|
44
45
|
import { SearchTool } from "./search";
|
|
45
46
|
import { SearchToolBm25Tool } from "./search-tool-bm25";
|
|
46
47
|
import { loadSshTool } from "./ssh";
|
|
@@ -79,6 +80,7 @@ export * from "./render-mermaid";
|
|
|
79
80
|
export * from "./report-tool-issue";
|
|
80
81
|
export * from "./resolve";
|
|
81
82
|
export * from "./review";
|
|
83
|
+
export * from "./run-command";
|
|
82
84
|
export * from "./search";
|
|
83
85
|
export * from "./search-tool-bm25";
|
|
84
86
|
export * from "./ssh";
|
|
@@ -224,6 +226,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
|
|
|
224
226
|
rewind: RewindTool.createIf,
|
|
225
227
|
task: TaskTool.create,
|
|
226
228
|
job: JobTool.createIf,
|
|
229
|
+
run_command: RunCommandTool.createIf,
|
|
227
230
|
irc: IrcTool.createIf,
|
|
228
231
|
todo_write: s => new TodoWriteTool(s),
|
|
229
232
|
web_search: s => new WebSearchTool(s),
|
|
@@ -370,6 +373,13 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
|
|
|
370
373
|
) {
|
|
371
374
|
requestedTools.push("ast_edit");
|
|
372
375
|
}
|
|
376
|
+
if (
|
|
377
|
+
requestedTools.includes("bash") &&
|
|
378
|
+
!requestedTools.includes("run_command") &&
|
|
379
|
+
session.settings.get("runCommand.enabled")
|
|
380
|
+
) {
|
|
381
|
+
requestedTools.push("run_command");
|
|
382
|
+
}
|
|
373
383
|
}
|
|
374
384
|
const allTools: Record<string, ToolFactory> = { ...BUILTIN_TOOLS, ...HIDDEN_TOOLS };
|
|
375
385
|
const isToolAllowed = (name: string) => {
|
|
@@ -392,6 +402,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
|
|
|
392
402
|
if (name === "browser") return session.settings.get("browser.enabled");
|
|
393
403
|
if (name === "checkpoint" || name === "rewind") return session.settings.get("checkpoint.enabled");
|
|
394
404
|
if (name === "irc") return session.settings.get("irc.enabled");
|
|
405
|
+
if (name === "run_command") return session.settings.get("runCommand.enabled");
|
|
395
406
|
if (name === "task") {
|
|
396
407
|
const maxDepth = session.settings.get("task.maxRecursionDepth") ?? 2;
|
|
397
408
|
const currentDepth = session.taskDepth ?? 0;
|
package/src/tools/renderers.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { notebookToolRenderer } from "./notebook";
|
|
|
24
24
|
import { pythonToolRenderer } from "./python";
|
|
25
25
|
import { readToolRenderer } from "./read";
|
|
26
26
|
import { resolveToolRenderer } from "./resolve";
|
|
27
|
+
import { runCommandToolRenderer } from "./run-command/render";
|
|
27
28
|
import { searchToolRenderer } from "./search";
|
|
28
29
|
import { searchToolBm25Renderer } from "./search-tool-bm25";
|
|
29
30
|
import { sshToolRenderer } from "./ssh";
|
|
@@ -48,6 +49,7 @@ export const toolRenderers: Record<string, ToolRenderer> = {
|
|
|
48
49
|
ast_grep: astGrepToolRenderer as ToolRenderer,
|
|
49
50
|
ast_edit: astEditToolRenderer as ToolRenderer,
|
|
50
51
|
bash: bashToolRenderer as ToolRenderer,
|
|
52
|
+
run_command: runCommandToolRenderer as ToolRenderer,
|
|
51
53
|
debug: debugToolRenderer as ToolRenderer,
|
|
52
54
|
python: pythonToolRenderer as ToolRenderer,
|
|
53
55
|
calc: calculatorToolRenderer as ToolRenderer,
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
|
2
|
+
import type { Component } from "@oh-my-pi/pi-tui";
|
|
3
|
+
import { prompt } from "@oh-my-pi/pi-utils";
|
|
4
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
5
|
+
import type { RenderResultOptions } from "../../extensibility/custom-tools/types";
|
|
6
|
+
import type { Theme } from "../../modes/theme/theme";
|
|
7
|
+
import runCommandDescription from "../../prompts/tools/run-command.md" with { type: "text" };
|
|
8
|
+
import type { ToolSession } from "..";
|
|
9
|
+
import { type BashRenderContext, BashTool, type BashToolDetails } from "../bash";
|
|
10
|
+
import { createRunCommandToolRenderer, type RunCommandRenderArgs } from "./render";
|
|
11
|
+
import { buildPromptModel, type DetectedRunner, resolveCommand } from "./runner";
|
|
12
|
+
import { RUNNERS } from "./runners";
|
|
13
|
+
|
|
14
|
+
const runCommandSchema = Type.Object({
|
|
15
|
+
op: Type.String({
|
|
16
|
+
description: 'task name and args, e.g. "test" or "build --release"',
|
|
17
|
+
examples: ["test", "build --release", "pkg:test --watch"],
|
|
18
|
+
}),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
type RunCommandParams = Static<typeof runCommandSchema>;
|
|
22
|
+
|
|
23
|
+
type RunCommandRenderResult = {
|
|
24
|
+
content: Array<{ type: string; text?: string }>;
|
|
25
|
+
details?: BashToolDetails;
|
|
26
|
+
isError?: boolean;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export class RunCommandTool implements AgentTool<typeof runCommandSchema, BashToolDetails, Theme> {
|
|
30
|
+
readonly name = "run_command";
|
|
31
|
+
readonly label = "Run";
|
|
32
|
+
readonly description: string;
|
|
33
|
+
readonly parameters = runCommandSchema;
|
|
34
|
+
readonly strict = true;
|
|
35
|
+
readonly concurrency = "exclusive";
|
|
36
|
+
readonly mergeCallAndResult = true;
|
|
37
|
+
readonly inline = true;
|
|
38
|
+
readonly renderCall: (args: RunCommandRenderArgs, options: RenderResultOptions, uiTheme: Theme) => Component;
|
|
39
|
+
readonly renderResult: (
|
|
40
|
+
result: RunCommandRenderResult,
|
|
41
|
+
options: RenderResultOptions & { renderContext?: BashRenderContext },
|
|
42
|
+
uiTheme: Theme,
|
|
43
|
+
args?: RunCommandRenderArgs,
|
|
44
|
+
) => Component;
|
|
45
|
+
|
|
46
|
+
readonly #bash: BashTool;
|
|
47
|
+
readonly #runners: DetectedRunner[];
|
|
48
|
+
|
|
49
|
+
constructor(session: ToolSession, runners: DetectedRunner[]) {
|
|
50
|
+
this.#runners = runners;
|
|
51
|
+
this.#bash = new BashTool(session);
|
|
52
|
+
this.description = prompt.render(runCommandDescription, buildPromptModel(runners));
|
|
53
|
+
const renderer = createRunCommandToolRenderer(runners);
|
|
54
|
+
this.renderCall = renderer.renderCall;
|
|
55
|
+
this.renderResult = renderer.renderResult;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
static async createIf(session: ToolSession): Promise<RunCommandTool | null> {
|
|
59
|
+
if (!session.settings.get("runCommand.enabled")) return null;
|
|
60
|
+
const detected = (await Promise.all(RUNNERS.map(runner => runner.detect(session.cwd)))).filter(
|
|
61
|
+
(runner): runner is DetectedRunner => runner !== null && runner.tasks.length > 0,
|
|
62
|
+
);
|
|
63
|
+
if (detected.length === 0) return null;
|
|
64
|
+
return new RunCommandTool(session, detected);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async execute(
|
|
68
|
+
toolCallId: string,
|
|
69
|
+
{ op }: RunCommandParams,
|
|
70
|
+
signal?: AbortSignal,
|
|
71
|
+
onUpdate?: AgentToolUpdateCallback<BashToolDetails>,
|
|
72
|
+
ctx?: AgentToolContext,
|
|
73
|
+
): Promise<AgentToolResult<BashToolDetails>> {
|
|
74
|
+
const command = resolveCommand(op, this.#runners);
|
|
75
|
+
return await this.#bash.execute(toolCallId, { command }, signal, onUpdate, ctx);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export * from "./runner";
|
|
80
|
+
export { tasksFromCargoMetadata } from "./runners/cargo";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createShellRenderer } from "../bash";
|
|
2
|
+
import type { DetectedRunner } from "./runner";
|
|
3
|
+
import { commandFromOp, titleFromOp } from "./runner";
|
|
4
|
+
|
|
5
|
+
export interface RunCommandRenderArgs {
|
|
6
|
+
op?: string;
|
|
7
|
+
__partialJson?: string;
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createRunCommandToolRenderer(runners: DetectedRunner[]) {
|
|
12
|
+
return createShellRenderer<RunCommandRenderArgs>({
|
|
13
|
+
resolveTitle: args => titleFromOp(args?.op, runners),
|
|
14
|
+
resolveCommand: args => commandFromOp(args?.op, runners),
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const runCommandToolRenderer = createRunCommandToolRenderer([]);
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { ToolError } from "../tool-errors";
|
|
2
|
+
|
|
3
|
+
export interface RunnerTask {
|
|
4
|
+
name: string;
|
|
5
|
+
doc?: string;
|
|
6
|
+
/** Parameter names only; used for the `name foo bar` signature line in the description. */
|
|
7
|
+
parameters: string[];
|
|
8
|
+
/** Override for this specific task, e.g. `cargo run --package crate --bin`. */
|
|
9
|
+
commandPrefix?: string;
|
|
10
|
+
/** Token passed to the runner command; defaults to `name`. Used when display names are namespaced. */
|
|
11
|
+
commandName?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface DetectedRunner {
|
|
15
|
+
id: string;
|
|
16
|
+
label: string;
|
|
17
|
+
/** Resolved shell prefix, e.g. "just" or "bun run" or "make". */
|
|
18
|
+
commandPrefix: string;
|
|
19
|
+
tasks: RunnerTask[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TaskRunner {
|
|
23
|
+
id: string;
|
|
24
|
+
label: string;
|
|
25
|
+
/**
|
|
26
|
+
* Probe `cwd` for the manifest, the binary, and the task list.
|
|
27
|
+
* Returns null when this runner does not apply.
|
|
28
|
+
*/
|
|
29
|
+
detect(cwd: string): Promise<DetectedRunner | null>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ParsedOp {
|
|
33
|
+
head: string;
|
|
34
|
+
tail: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface PromptTaskModel {
|
|
38
|
+
name: string;
|
|
39
|
+
paramSig?: string;
|
|
40
|
+
command?: string;
|
|
41
|
+
doc?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface PromptRunnerModel {
|
|
45
|
+
id: string;
|
|
46
|
+
label: string;
|
|
47
|
+
commandPrefix: string;
|
|
48
|
+
tasks: PromptTaskModel[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface RunCommandPromptModel {
|
|
52
|
+
[key: string]: unknown;
|
|
53
|
+
hasMultipleRunners: boolean;
|
|
54
|
+
ambiguityExampleRunner?: string;
|
|
55
|
+
ambiguityExampleTask?: string;
|
|
56
|
+
runners: PromptRunnerModel[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseOp(op: string): ParsedOp {
|
|
60
|
+
const trimmedStart = op.trimStart();
|
|
61
|
+
if (trimmedStart.length === 0) return { head: "", tail: "" };
|
|
62
|
+
const match = /^(\S+)(?:\s+([\s\S]*))?$/u.exec(trimmedStart);
|
|
63
|
+
return { head: match?.[1] ?? "", tail: match?.[2] ?? "" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function findRunnerById(id: string, runners: DetectedRunner[]): DetectedRunner | undefined {
|
|
67
|
+
return runners.find(runner => runner.id === id);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function hasTask(runner: DetectedRunner, taskName: string): boolean {
|
|
71
|
+
return runner.tasks.some(task => task.name === taskName);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function findMatchingRunners(taskName: string, runners: DetectedRunner[]): DetectedRunner[] {
|
|
75
|
+
return runners.filter(runner => hasTask(runner, taskName));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function formatAvailableTasks(runners: DetectedRunner[]): string {
|
|
79
|
+
return runners
|
|
80
|
+
.map(runner => {
|
|
81
|
+
const names = runner.tasks.map(task => task.name).join(", ");
|
|
82
|
+
return `- ${runner.id}: ${names || "(no tasks)"}`;
|
|
83
|
+
})
|
|
84
|
+
.join("\n");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function formatRunnerIds(runners: DetectedRunner[]): string {
|
|
88
|
+
return runners.map(runner => runner.id).join(", ");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function buildCommand(commandPrefix: string, taskName: string, tail: string): string {
|
|
92
|
+
return [commandPrefix, taskName, tail]
|
|
93
|
+
.filter(part => part.trim().length > 0)
|
|
94
|
+
.join(" ")
|
|
95
|
+
.trim();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function resolveRunnerAndTask(
|
|
99
|
+
op: string,
|
|
100
|
+
runners: DetectedRunner[],
|
|
101
|
+
): { runner: DetectedRunner; task: RunnerTask; tail: string } {
|
|
102
|
+
const { head, tail } = parseOp(op);
|
|
103
|
+
if (!head) {
|
|
104
|
+
throw new ToolError(`run_command op is empty. Available tasks:\n${formatAvailableTasks(runners)}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const colonIndex = head.indexOf(":");
|
|
108
|
+
if (colonIndex > 0) {
|
|
109
|
+
const maybeRunnerId = head.slice(0, colonIndex);
|
|
110
|
+
const explicitRunner = findRunnerById(maybeRunnerId, runners);
|
|
111
|
+
if (explicitRunner) {
|
|
112
|
+
const taskName = head.slice(colonIndex + 1);
|
|
113
|
+
const explicitTask = explicitRunner.tasks.find(task => task.name === taskName);
|
|
114
|
+
if (!taskName || !explicitTask) {
|
|
115
|
+
throw new ToolError(
|
|
116
|
+
`Task \`${taskName || "(empty)"}\` not found in runner \`${explicitRunner.id}\`. Available tasks:\n${formatAvailableTasks(runners)}`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return { runner: explicitRunner, task: explicitTask, tail };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const matches = findMatchingRunners(head, runners);
|
|
124
|
+
if (matches.length === 1) {
|
|
125
|
+
return { runner: matches[0]!, task: matches[0]!.tasks.find(task => task.name === head)!, tail };
|
|
126
|
+
}
|
|
127
|
+
if (matches.length > 1) {
|
|
128
|
+
const ids = matches.map(runner => runner.id).join(", ");
|
|
129
|
+
throw new ToolError(
|
|
130
|
+
`Task \`${head}\` exists in multiple runners (${ids}). Use \`<runner-id>:<task>\`, for example \`${matches[0]!.id}:${head}\`. Available tasks:\n${formatAvailableTasks(runners)}`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
throw new ToolError(
|
|
135
|
+
`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)}`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function resolveCommand(op: string, runners: DetectedRunner[]): string {
|
|
140
|
+
const { runner, task, tail } = resolveRunnerAndTask(op, runners);
|
|
141
|
+
return buildCommand(task.commandPrefix ?? runner.commandPrefix, task.commandName ?? task.name, tail);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function commandFromOp(op: string | undefined, runners: DetectedRunner[]): string | undefined {
|
|
145
|
+
if (!op) return undefined;
|
|
146
|
+
try {
|
|
147
|
+
return resolveCommand(op, runners);
|
|
148
|
+
} catch {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function titleFromOp(op: string | undefined, runners: DetectedRunner[]): string {
|
|
154
|
+
if (!op) return "Run";
|
|
155
|
+
const { head } = parseOp(op);
|
|
156
|
+
if (!head) return "Run";
|
|
157
|
+
const colonIndex = head.indexOf(":");
|
|
158
|
+
if (colonIndex > 0) {
|
|
159
|
+
const runner = findRunnerById(head.slice(0, colonIndex), runners);
|
|
160
|
+
return runner?.label ?? "Run";
|
|
161
|
+
}
|
|
162
|
+
const matches = findMatchingRunners(head, runners);
|
|
163
|
+
return matches.length === 1 ? matches[0]!.label : "Run";
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function findAmbiguityExample(runners: DetectedRunner[]): { runner: string; task: string } | undefined {
|
|
167
|
+
const seen = new Map<string, string>();
|
|
168
|
+
for (const runner of runners) {
|
|
169
|
+
for (const task of runner.tasks) {
|
|
170
|
+
const previousRunner = seen.get(task.name);
|
|
171
|
+
if (previousRunner) return { runner: previousRunner, task: task.name };
|
|
172
|
+
seen.set(task.name, runner.id);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const firstRunner = runners[0];
|
|
176
|
+
const firstTask = firstRunner?.tasks[0];
|
|
177
|
+
return firstRunner && firstTask ? { runner: firstRunner.id, task: firstTask.name } : undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function buildPromptModel(runners: DetectedRunner[]): RunCommandPromptModel {
|
|
181
|
+
const ambiguityExample = findAmbiguityExample(runners);
|
|
182
|
+
return {
|
|
183
|
+
hasMultipleRunners: runners.length > 1,
|
|
184
|
+
ambiguityExampleRunner: ambiguityExample?.runner,
|
|
185
|
+
ambiguityExampleTask: ambiguityExample?.task,
|
|
186
|
+
runners: runners.map(runner => ({
|
|
187
|
+
id: runner.id,
|
|
188
|
+
label: runner.label,
|
|
189
|
+
commandPrefix: runner.commandPrefix,
|
|
190
|
+
tasks: runner.tasks.map(task => ({
|
|
191
|
+
name: task.name,
|
|
192
|
+
paramSig: task.parameters.length > 0 ? task.parameters.join(" ") : undefined,
|
|
193
|
+
command: buildCommand(task.commandPrefix ?? runner.commandPrefix, task.commandName ?? task.name, ""),
|
|
194
|
+
doc: task.doc,
|
|
195
|
+
})),
|
|
196
|
+
})),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { $which, isEnoent, logger } from "@oh-my-pi/pi-utils";
|
|
4
|
+
import type { DetectedRunner, RunnerTask, TaskRunner } from "../runner";
|
|
5
|
+
|
|
6
|
+
export interface CargoMetadataTarget {
|
|
7
|
+
kind?: string[];
|
|
8
|
+
name?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface CargoMetadataPackage {
|
|
12
|
+
id?: string;
|
|
13
|
+
name?: string;
|
|
14
|
+
targets?: CargoMetadataTarget[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CargoMetadata {
|
|
18
|
+
packages?: CargoMetadataPackage[];
|
|
19
|
+
workspace_members?: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type CargoTargetKind = "bin" | "example" | "test";
|
|
23
|
+
|
|
24
|
+
async function hasCargoManifest(cwd: string): Promise<boolean> {
|
|
25
|
+
try {
|
|
26
|
+
const stat = await fs.stat(path.join(cwd, "Cargo.toml"));
|
|
27
|
+
return stat.isFile();
|
|
28
|
+
} catch (err) {
|
|
29
|
+
if (isEnoent(err)) return false;
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function shellQuote(value: string): string {
|
|
35
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function cargoTargetKind(target: CargoMetadataTarget): CargoTargetKind | undefined {
|
|
39
|
+
if (target.kind?.includes("bin")) return "bin";
|
|
40
|
+
if (target.kind?.includes("example")) return "example";
|
|
41
|
+
if (target.kind?.includes("test")) return "test";
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function commandPrefixForTarget(packageName: string, kind: CargoTargetKind): string {
|
|
46
|
+
const packageFlag = `--package ${shellQuote(packageName)}`;
|
|
47
|
+
switch (kind) {
|
|
48
|
+
case "bin":
|
|
49
|
+
return `cargo run ${packageFlag} --bin`;
|
|
50
|
+
case "example":
|
|
51
|
+
return `cargo run ${packageFlag} --example`;
|
|
52
|
+
case "test":
|
|
53
|
+
return `cargo test ${packageFlag} --test`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function taskNameForTarget(
|
|
58
|
+
packageName: string,
|
|
59
|
+
kind: CargoTargetKind,
|
|
60
|
+
targetName: string,
|
|
61
|
+
isWorkspace: boolean,
|
|
62
|
+
): string {
|
|
63
|
+
const category = kind === "bin" ? "bin" : kind;
|
|
64
|
+
return isWorkspace ? `${packageName}/${category}/${targetName}` : `${category}/${targetName}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function tasksFromCargoMetadata(metadata: CargoMetadata): RunnerTask[] {
|
|
68
|
+
const workspaceMembers = new Set(metadata.workspace_members ?? []);
|
|
69
|
+
const workspacePackages = (metadata.packages ?? []).filter(pkg => pkg.id && workspaceMembers.has(pkg.id));
|
|
70
|
+
const packages = workspacePackages.length > 0 ? workspacePackages : (metadata.packages ?? []);
|
|
71
|
+
const isWorkspace = packages.length > 1;
|
|
72
|
+
const tasks: RunnerTask[] = [];
|
|
73
|
+
const seen = new Set<string>();
|
|
74
|
+
|
|
75
|
+
for (const pkg of packages) {
|
|
76
|
+
if (!pkg.name) continue;
|
|
77
|
+
for (const target of pkg.targets ?? []) {
|
|
78
|
+
if (!target.name) continue;
|
|
79
|
+
const kind = cargoTargetKind(target);
|
|
80
|
+
if (!kind) continue;
|
|
81
|
+
const name = taskNameForTarget(pkg.name, kind, target.name, isWorkspace);
|
|
82
|
+
if (seen.has(name)) continue;
|
|
83
|
+
seen.add(name);
|
|
84
|
+
tasks.push({
|
|
85
|
+
name,
|
|
86
|
+
doc: `${pkg.name} ${kind} target ${target.name}`,
|
|
87
|
+
parameters: [],
|
|
88
|
+
commandPrefix: commandPrefixForTarget(pkg.name, kind),
|
|
89
|
+
commandName: shellQuote(target.name),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return tasks;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function readCargoMetadata(cwd: string): Promise<CargoMetadata | null> {
|
|
98
|
+
try {
|
|
99
|
+
const proc = Bun.spawn(["cargo", "metadata", "--no-deps", "--format-version=1"], {
|
|
100
|
+
cwd,
|
|
101
|
+
stdin: "ignore",
|
|
102
|
+
stdout: "pipe",
|
|
103
|
+
stderr: "pipe",
|
|
104
|
+
});
|
|
105
|
+
const [stdout, exit] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
|
|
106
|
+
if (exit !== 0) return null;
|
|
107
|
+
return JSON.parse(stdout) as CargoMetadata;
|
|
108
|
+
} catch (err) {
|
|
109
|
+
logger.debug("cargo metadata failed", { error: err instanceof Error ? err.message : String(err) });
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const cargoRunner: TaskRunner = {
|
|
115
|
+
id: "cargo",
|
|
116
|
+
label: "Cargo",
|
|
117
|
+
async detect(cwd: string): Promise<DetectedRunner | null> {
|
|
118
|
+
try {
|
|
119
|
+
if (!$which("cargo")) return null;
|
|
120
|
+
if (!(await hasCargoManifest(cwd))) return null;
|
|
121
|
+
const metadata = await readCargoMetadata(cwd);
|
|
122
|
+
if (!metadata) return null;
|
|
123
|
+
const tasks = tasksFromCargoMetadata(metadata);
|
|
124
|
+
if (tasks.length === 0) return null;
|
|
125
|
+
return { id: "cargo", label: "Cargo", commandPrefix: "cargo", tasks };
|
|
126
|
+
} catch (err) {
|
|
127
|
+
logger.debug("cargo runner probe failed", { error: err instanceof Error ? err.message : String(err) });
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { TaskRunner } from "../runner";
|
|
2
|
+
import { cargoRunner } from "./cargo";
|
|
3
|
+
import { justRunner } from "./just";
|
|
4
|
+
import { makeRunner } from "./make";
|
|
5
|
+
import { pkgRunner } from "./pkg";
|
|
6
|
+
import { taskRunner } from "./task";
|
|
7
|
+
|
|
8
|
+
export const RUNNERS: TaskRunner[] = [justRunner, pkgRunner, cargoRunner, makeRunner, taskRunner];
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { $which, isEnoent, logger } from "@oh-my-pi/pi-utils";
|
|
4
|
+
import type { DetectedRunner, RunnerTask, TaskRunner } from "../runner";
|
|
5
|
+
|
|
6
|
+
interface JustDumpRecipeRaw {
|
|
7
|
+
name?: string;
|
|
8
|
+
doc?: string | null;
|
|
9
|
+
private?: boolean;
|
|
10
|
+
parameters?: Array<{ name?: string }>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface JustDump {
|
|
14
|
+
recipes?: Record<string, JustDumpRecipeRaw>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const JUSTFILE_NAMES = ["justfile", "Justfile", ".justfile"] as const;
|
|
18
|
+
|
|
19
|
+
async function hasJustfile(cwd: string): Promise<boolean> {
|
|
20
|
+
for (const name of JUSTFILE_NAMES) {
|
|
21
|
+
try {
|
|
22
|
+
const stat = await fs.stat(path.join(cwd, name));
|
|
23
|
+
if (stat.isFile()) return true;
|
|
24
|
+
} catch (err) {
|
|
25
|
+
if (!isEnoent(err)) throw err;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function dumpJustTasks(cwd: string): Promise<RunnerTask[] | null> {
|
|
32
|
+
try {
|
|
33
|
+
const proc = Bun.spawn(["just", "--dump", "--dump-format=json"], {
|
|
34
|
+
cwd,
|
|
35
|
+
stdin: "ignore",
|
|
36
|
+
stdout: "pipe",
|
|
37
|
+
stderr: "pipe",
|
|
38
|
+
});
|
|
39
|
+
const [stdout, exit] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
|
|
40
|
+
if (exit !== 0) return null;
|
|
41
|
+
const dump = JSON.parse(stdout) as JustDump;
|
|
42
|
+
const tasks: RunnerTask[] = [];
|
|
43
|
+
for (const recipe of Object.values(dump.recipes ?? {})) {
|
|
44
|
+
if (!recipe.name || recipe.private) continue;
|
|
45
|
+
const parameters = (recipe.parameters ?? [])
|
|
46
|
+
.map(parameter => parameter.name)
|
|
47
|
+
.filter((name): name is string => typeof name === "string" && name.length > 0);
|
|
48
|
+
const doc = typeof recipe.doc === "string" && recipe.doc.length > 0 ? recipe.doc : undefined;
|
|
49
|
+
tasks.push({ name: recipe.name, doc, parameters });
|
|
50
|
+
}
|
|
51
|
+
return tasks;
|
|
52
|
+
} catch (err) {
|
|
53
|
+
logger.debug("just task detection failed", { error: err instanceof Error ? err.message : String(err) });
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const justRunner: TaskRunner = {
|
|
59
|
+
id: "just",
|
|
60
|
+
label: "Just",
|
|
61
|
+
async detect(cwd: string): Promise<DetectedRunner | null> {
|
|
62
|
+
try {
|
|
63
|
+
if (!$which("just")) return null;
|
|
64
|
+
if (!(await hasJustfile(cwd))) return null;
|
|
65
|
+
const tasks = await dumpJustTasks(cwd);
|
|
66
|
+
if (!tasks || tasks.length === 0) return null;
|
|
67
|
+
return { id: "just", label: "Just", commandPrefix: "just", tasks };
|
|
68
|
+
} catch (err) {
|
|
69
|
+
logger.debug("just runner probe failed", { error: err instanceof Error ? err.message : String(err) });
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { $which, isEnoent, logger } from "@oh-my-pi/pi-utils";
|
|
4
|
+
import type { DetectedRunner, RunnerTask, TaskRunner } from "../runner";
|
|
5
|
+
|
|
6
|
+
const MAKEFILE_NAMES = ["Makefile", "makefile", "GNUmakefile"] as const;
|
|
7
|
+
const TARGET_PATTERN = /^(?<name>[A-Za-z_][A-Za-z0-9_-]*)\s*:(?!=).*?(?:##\s*(?<doc>.+))?$/u;
|
|
8
|
+
const PHONY_PATTERN = /^\.PHONY\s*:\s*(?<targets>.*)$/u;
|
|
9
|
+
|
|
10
|
+
interface MakeTargetInfo {
|
|
11
|
+
name: string;
|
|
12
|
+
doc?: string;
|
|
13
|
+
order: number;
|
|
14
|
+
phony: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function findMakefile(cwd: string): Promise<string | null> {
|
|
18
|
+
for (const name of MAKEFILE_NAMES) {
|
|
19
|
+
const candidate = path.join(cwd, name);
|
|
20
|
+
try {
|
|
21
|
+
const stat = await fs.stat(candidate);
|
|
22
|
+
if (stat.isFile()) return candidate;
|
|
23
|
+
} catch (err) {
|
|
24
|
+
if (!isEnoent(err)) throw err;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isVariableAssignment(line: string, name: string): boolean {
|
|
31
|
+
return new RegExp(`^\\s*${name}\\s*[:?+]?=`, "u").test(line);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parsePhonyTargets(line: string): string[] {
|
|
35
|
+
const match = PHONY_PATTERN.exec(line);
|
|
36
|
+
if (!match?.groups?.targets) return [];
|
|
37
|
+
return match.groups.targets
|
|
38
|
+
.split(/\s+/u)
|
|
39
|
+
.map(target => target.trim())
|
|
40
|
+
.filter(target => /^[A-Za-z_][A-Za-z0-9_-]*$/u.test(target));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseMakeTargets(text: string): RunnerTask[] {
|
|
44
|
+
const targets = new Map<string, MakeTargetInfo>();
|
|
45
|
+
const phonyTargets: string[] = [];
|
|
46
|
+
let order = 0;
|
|
47
|
+
|
|
48
|
+
for (const line of text.split("\n")) {
|
|
49
|
+
for (const target of parsePhonyTargets(line)) {
|
|
50
|
+
if (!phonyTargets.includes(target)) phonyTargets.push(target);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const match = TARGET_PATTERN.exec(line);
|
|
54
|
+
const name = match?.groups?.name;
|
|
55
|
+
if (!name || name === ".PHONY" || isVariableAssignment(line, name)) continue;
|
|
56
|
+
if (targets.has(name)) continue;
|
|
57
|
+
const rawDoc = match?.groups?.doc?.trim();
|
|
58
|
+
const doc = rawDoc && rawDoc.length > 0 ? rawDoc : undefined;
|
|
59
|
+
targets.set(name, { name, doc, order, phony: false });
|
|
60
|
+
order += 1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const phony of phonyTargets) {
|
|
64
|
+
const existing = targets.get(phony);
|
|
65
|
+
if (existing) {
|
|
66
|
+
existing.phony = true;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
targets.set(phony, { name: phony, order, phony: true });
|
|
70
|
+
order += 1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const hasPhonyTargets = phonyTargets.length > 0;
|
|
74
|
+
return [...targets.values()]
|
|
75
|
+
.sort((left, right) => left.order - right.order)
|
|
76
|
+
.flatMap(target => {
|
|
77
|
+
if (!hasPhonyTargets || target.phony) {
|
|
78
|
+
return [{ name: target.name, doc: target.doc, parameters: [] }];
|
|
79
|
+
}
|
|
80
|
+
if (!target.doc) return [];
|
|
81
|
+
return [{ name: target.name, doc: `${target.doc} (file target)`, parameters: [] }];
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const makeRunner: TaskRunner = {
|
|
86
|
+
id: "make",
|
|
87
|
+
label: "Make",
|
|
88
|
+
async detect(cwd: string): Promise<DetectedRunner | null> {
|
|
89
|
+
try {
|
|
90
|
+
if (!$which("make")) return null;
|
|
91
|
+
const makefile = await findMakefile(cwd);
|
|
92
|
+
if (!makefile) return null;
|
|
93
|
+
const tasks = parseMakeTargets(await Bun.file(makefile).text());
|
|
94
|
+
if (tasks.length === 0) return null;
|
|
95
|
+
return { id: "make", label: "Make", commandPrefix: "make", tasks };
|
|
96
|
+
} catch (err) {
|
|
97
|
+
logger.debug("make runner probe failed", { error: err instanceof Error ? err.message : String(err) });
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
};
|