@oh-my-pi/pi-coding-agent 14.5.7 → 14.5.9

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/package.json +7 -7
  3. package/src/config/model-registry.ts +23 -1
  4. package/src/config/settings-schema.ts +23 -0
  5. package/src/edit/modes/atom.lark +7 -5
  6. package/src/edit/modes/atom.ts +462 -56
  7. package/src/edit/modes/hashline.ts +21 -1
  8. package/src/lsp/index.ts +2 -4
  9. package/src/lsp/render.ts +0 -3
  10. package/src/lsp/types.ts +1 -4
  11. package/src/lsp/utils.ts +18 -14
  12. package/src/modes/components/settings-defs.ts +10 -0
  13. package/src/modes/controllers/command-controller.ts +17 -0
  14. package/src/modes/controllers/event-controller.ts +14 -9
  15. package/src/modes/controllers/input-controller.ts +13 -1
  16. package/src/modes/interactive-mode.ts +44 -23
  17. package/src/modes/types.ts +5 -2
  18. package/src/modes/utils/context-usage.ts +294 -0
  19. package/src/prompts/tools/atom.md +99 -44
  20. package/src/prompts/tools/exit-plan-mode.md +5 -39
  21. package/src/prompts/tools/lsp.md +2 -3
  22. package/src/prompts/tools/recipe.md +16 -0
  23. package/src/prompts/tools/task.md +34 -147
  24. package/src/prompts/tools/todo-write.md +22 -64
  25. package/src/session/compaction/compaction.ts +35 -22
  26. package/src/session/session-dump-format.ts +1 -0
  27. package/src/slash-commands/builtin-registry.ts +12 -5
  28. package/src/tools/bash.ts +149 -115
  29. package/src/tools/debug.ts +57 -70
  30. package/src/tools/index.ts +11 -0
  31. package/src/tools/recipe/index.ts +80 -0
  32. package/src/tools/recipe/render.ts +19 -0
  33. package/src/tools/recipe/runner.ts +219 -0
  34. package/src/tools/recipe/runners/cargo.ts +131 -0
  35. package/src/tools/recipe/runners/index.ts +8 -0
  36. package/src/tools/recipe/runners/just.ts +73 -0
  37. package/src/tools/recipe/runners/make.ts +101 -0
  38. package/src/tools/recipe/runners/pkg.ts +165 -0
  39. package/src/tools/recipe/runners/task.ts +72 -0
  40. package/src/tools/renderers.ts +2 -0
@@ -0,0 +1,219 @@
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
+ /** Working directory for the task, relative to the session cwd; absent means the runner's root cwd. */
13
+ cwd?: string;
14
+ }
15
+
16
+ export interface DetectedRunner {
17
+ id: string;
18
+ label: string;
19
+ /** Resolved shell prefix, e.g. "just" or "bun run" or "make". */
20
+ commandPrefix: string;
21
+ tasks: RunnerTask[];
22
+ }
23
+
24
+ export interface TaskRunner {
25
+ id: string;
26
+ label: string;
27
+ /**
28
+ * Probe `cwd` for the manifest, the binary, and the task list.
29
+ * Returns null when this runner does not apply.
30
+ */
31
+ detect(cwd: string): Promise<DetectedRunner | null>;
32
+ }
33
+
34
+ interface ParsedOp {
35
+ head: string;
36
+ tail: string;
37
+ }
38
+
39
+ interface PromptTaskModel {
40
+ name: string;
41
+ paramSig?: string;
42
+ command?: string;
43
+ doc?: string;
44
+ cwd?: string;
45
+ }
46
+
47
+ const PROMPT_TASK_LIMIT = 20;
48
+
49
+ interface PromptRunnerModel {
50
+ id: string;
51
+ label: string;
52
+ commandPrefix: string;
53
+ tasks: PromptTaskModel[];
54
+ hiddenTaskCount?: number;
55
+ }
56
+
57
+ export interface RecipePromptModel {
58
+ [key: string]: unknown;
59
+ hasMultipleRunners: boolean;
60
+ ambiguityExampleRunner?: string;
61
+ ambiguityExampleTask?: string;
62
+ runners: PromptRunnerModel[];
63
+ }
64
+
65
+ function parseOp(op: string): ParsedOp {
66
+ const trimmedStart = op.trimStart();
67
+ if (trimmedStart.length === 0) return { head: "", tail: "" };
68
+ const match = /^(\S+)(?:\s+([\s\S]*))?$/u.exec(trimmedStart);
69
+ return { head: match?.[1] ?? "", tail: match?.[2] ?? "" };
70
+ }
71
+
72
+ function findRunnerById(id: string, runners: DetectedRunner[]): DetectedRunner | undefined {
73
+ return runners.find(runner => runner.id === id);
74
+ }
75
+
76
+ function hasTask(runner: DetectedRunner, taskName: string): boolean {
77
+ return runner.tasks.some(task => task.name === taskName);
78
+ }
79
+
80
+ function findMatchingRunners(taskName: string, runners: DetectedRunner[]): DetectedRunner[] {
81
+ return runners.filter(runner => hasTask(runner, taskName));
82
+ }
83
+
84
+ function formatAvailableTasks(runners: DetectedRunner[]): string {
85
+ return runners
86
+ .map(runner => {
87
+ const names = runner.tasks.map(task => task.name).join(", ");
88
+ return `- ${runner.id}: ${names || "(no tasks)"}`;
89
+ })
90
+ .join("\n");
91
+ }
92
+
93
+ function formatRunnerIds(runners: DetectedRunner[]): string {
94
+ return runners.map(runner => runner.id).join(", ");
95
+ }
96
+
97
+ function buildCommand(commandPrefix: string, taskName: string, tail: string): string {
98
+ return [commandPrefix, taskName, tail]
99
+ .filter(part => part.trim().length > 0)
100
+ .join(" ")
101
+ .trim();
102
+ }
103
+
104
+ function resolveRunnerAndTask(
105
+ op: string,
106
+ runners: DetectedRunner[],
107
+ ): { runner: DetectedRunner; task: RunnerTask; tail: string } {
108
+ const { head, tail } = parseOp(op);
109
+ if (!head) {
110
+ throw new ToolError(`recipe op is empty. Available tasks:\n${formatAvailableTasks(runners)}`);
111
+ }
112
+
113
+ const colonIndex = head.indexOf(":");
114
+ if (colonIndex > 0) {
115
+ const maybeRunnerId = head.slice(0, colonIndex);
116
+ const explicitRunner = findRunnerById(maybeRunnerId, runners);
117
+ if (explicitRunner) {
118
+ const taskName = head.slice(colonIndex + 1);
119
+ const explicitTask = explicitRunner.tasks.find(task => task.name === taskName);
120
+ if (!taskName || !explicitTask) {
121
+ throw new ToolError(
122
+ `Task \`${taskName || "(empty)"}\` not found in runner \`${explicitRunner.id}\`. Available tasks:\n${formatAvailableTasks(runners)}`,
123
+ );
124
+ }
125
+ return { runner: explicitRunner, task: explicitTask, tail };
126
+ }
127
+ }
128
+
129
+ const matches = findMatchingRunners(head, runners);
130
+ if (matches.length === 1) {
131
+ return { runner: matches[0]!, task: matches[0]!.tasks.find(task => task.name === head)!, tail };
132
+ }
133
+ if (matches.length > 1) {
134
+ const ids = matches.map(runner => runner.id).join(", ");
135
+ throw new ToolError(
136
+ `Task \`${head}\` exists in multiple runners (${ids}). Use \`<runner-id>:<task>\`, for example \`${matches[0]!.id}:${head}\`. Available tasks:\n${formatAvailableTasks(runners)}`,
137
+ );
138
+ }
139
+
140
+ throw new ToolError(
141
+ `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)}`,
142
+ );
143
+ }
144
+
145
+ export interface ResolvedTask {
146
+ command: string;
147
+ cwd?: string;
148
+ }
149
+
150
+ export function resolveCommand(op: string, runners: DetectedRunner[]): ResolvedTask {
151
+ const { runner, task, tail } = resolveRunnerAndTask(op, runners);
152
+ const command = buildCommand(task.commandPrefix ?? runner.commandPrefix, task.commandName ?? task.name, tail);
153
+ return task.cwd ? { command, cwd: task.cwd } : { command };
154
+ }
155
+
156
+ export function resolveTaskFromOp(op: string | undefined, runners: DetectedRunner[]): ResolvedTask | undefined {
157
+ if (!op) return undefined;
158
+ try {
159
+ return resolveCommand(op, runners);
160
+ } catch {
161
+ return undefined;
162
+ }
163
+ }
164
+
165
+ export function commandFromOp(op: string | undefined, runners: DetectedRunner[]): string | undefined {
166
+ return resolveTaskFromOp(op, runners)?.command;
167
+ }
168
+
169
+ export function cwdFromOp(op: string | undefined, runners: DetectedRunner[]): string | undefined {
170
+ return resolveTaskFromOp(op, runners)?.cwd;
171
+ }
172
+
173
+ export function titleFromOp(op: string | undefined, runners: DetectedRunner[]): string {
174
+ if (!op) return "Run";
175
+ const { head } = parseOp(op);
176
+ if (!head) return "Run";
177
+ const colonIndex = head.indexOf(":");
178
+ if (colonIndex > 0) {
179
+ const runner = findRunnerById(head.slice(0, colonIndex), runners);
180
+ return runner?.label ?? "Run";
181
+ }
182
+ const matches = findMatchingRunners(head, runners);
183
+ return matches.length === 1 ? matches[0]!.label : "Run";
184
+ }
185
+
186
+ function findAmbiguityExample(runners: DetectedRunner[]): { runner: string; task: string } | undefined {
187
+ const seen = new Map<string, string>();
188
+ for (const runner of runners) {
189
+ for (const task of runner.tasks) {
190
+ const previousRunner = seen.get(task.name);
191
+ if (previousRunner) return { runner: previousRunner, task: task.name };
192
+ seen.set(task.name, runner.id);
193
+ }
194
+ }
195
+ const firstRunner = runners[0];
196
+ const firstTask = firstRunner?.tasks[0];
197
+ return firstRunner && firstTask ? { runner: firstRunner.id, task: firstTask.name } : undefined;
198
+ }
199
+
200
+ export function buildPromptModel(runners: DetectedRunner[]): RecipePromptModel {
201
+ const ambiguityExample = findAmbiguityExample(runners);
202
+ return {
203
+ hasMultipleRunners: runners.length > 1,
204
+ ambiguityExampleRunner: ambiguityExample?.runner,
205
+ ambiguityExampleTask: ambiguityExample?.task,
206
+ runners: runners.map(runner => ({
207
+ id: runner.id,
208
+ label: runner.label,
209
+ commandPrefix: runner.commandPrefix,
210
+ tasks: runner.tasks.slice(0, PROMPT_TASK_LIMIT).map(task => ({
211
+ name: task.name,
212
+ paramSig: task.parameters.length > 0 ? task.parameters.join(" ") : undefined,
213
+ command: buildCommand(task.commandPrefix ?? runner.commandPrefix, task.commandName ?? task.name, ""),
214
+ doc: task.doc,
215
+ cwd: task.cwd,
216
+ })),
217
+ })),
218
+ };
219
+ }
@@ -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
+ };
@@ -0,0 +1,165 @@
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 PackageJsonInfo {
7
+ name?: string;
8
+ scripts: string[];
9
+ workspaces: string[];
10
+ }
11
+
12
+ async function resolvePackageRunner(cwd: string): Promise<string> {
13
+ if ((await isFile(path.join(cwd, "bun.lock"))) || (await isFile(path.join(cwd, "bun.lockb")))) {
14
+ return "bun run";
15
+ }
16
+ if (await isFile(path.join(cwd, "pnpm-lock.yaml"))) {
17
+ return "pnpm run";
18
+ }
19
+ if (await isFile(path.join(cwd, "yarn.lock"))) {
20
+ return "yarn";
21
+ }
22
+ if ((await isFile(path.join(cwd, "package-lock.json"))) || (await isFile(path.join(cwd, "npm-shrinkwrap.json")))) {
23
+ return "npm run";
24
+ }
25
+ if ($which("bun")) {
26
+ return "bun run";
27
+ }
28
+ return "npm run";
29
+ }
30
+
31
+ function isRecord(value: unknown): value is Record<string, unknown> {
32
+ return typeof value === "object" && value !== null && !Array.isArray(value);
33
+ }
34
+
35
+ function shellQuote(value: string): string {
36
+ return `'${value.replaceAll("'", `'\\''`)}'`;
37
+ }
38
+
39
+ async function isFile(filePath: string): Promise<boolean> {
40
+ try {
41
+ const stat = await fs.stat(filePath);
42
+ return stat.isFile();
43
+ } catch (err) {
44
+ if (isEnoent(err)) return false;
45
+ throw err;
46
+ }
47
+ }
48
+
49
+ function parseWorkspacePatterns(pkg: Record<string, unknown>): string[] {
50
+ const { workspaces } = pkg;
51
+ if (Array.isArray(workspaces)) return workspaces.filter((entry): entry is string => typeof entry === "string");
52
+ if (isRecord(workspaces) && Array.isArray(workspaces.packages)) {
53
+ return workspaces.packages.filter((entry): entry is string => typeof entry === "string");
54
+ }
55
+ return [];
56
+ }
57
+
58
+ function normalizeWorkspacePattern(pattern: string): string {
59
+ const negated = pattern.startsWith("!");
60
+ const body = negated ? pattern.slice(1) : pattern;
61
+ const normalizedBody = body.endsWith("package.json") ? body : `${body.replace(/\/+$/u, "")}/package.json`;
62
+ return negated ? `!${normalizedBody}` : normalizedBody;
63
+ }
64
+
65
+ async function readPackageJson(filePath: string): Promise<PackageJsonInfo | null> {
66
+ try {
67
+ const pkg = (await Bun.file(filePath).json()) as unknown;
68
+ if (!isRecord(pkg)) return null;
69
+ const scripts = isRecord(pkg.scripts)
70
+ ? Object.entries(pkg.scripts)
71
+ .filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[0].length > 0)
72
+ .map(([name]) => name)
73
+ : [];
74
+ const name = typeof pkg.name === "string" && pkg.name.length > 0 ? pkg.name : undefined;
75
+ return { name, scripts, workspaces: parseWorkspacePatterns(pkg) };
76
+ } catch (err) {
77
+ if (!isEnoent(err)) {
78
+ logger.debug("package.json script detection failed", {
79
+ error: err instanceof Error ? err.message : String(err),
80
+ });
81
+ }
82
+ return null;
83
+ }
84
+ }
85
+
86
+ async function findWorkspacePackageJsons(cwd: string, patterns: string[]): Promise<string[]> {
87
+ const includePatterns = patterns.filter(pattern => !pattern.startsWith("!")).map(normalizeWorkspacePattern);
88
+ const excludePatterns = patterns.filter(pattern => pattern.startsWith("!")).map(normalizeWorkspacePattern);
89
+ const excluded = new Set<string>();
90
+ for (const pattern of excludePatterns) {
91
+ for await (const entry of new Bun.Glob(pattern.slice(1)).scan({ cwd, onlyFiles: true })) {
92
+ excluded.add(path.normalize(String(entry)));
93
+ }
94
+ }
95
+ const files = new Set<string>();
96
+ for (const pattern of includePatterns) {
97
+ for await (const entry of new Bun.Glob(pattern).scan({ cwd, onlyFiles: true })) {
98
+ const normalized = path.normalize(String(entry));
99
+ if (normalized !== "package.json" && !excluded.has(normalized)) files.add(normalized);
100
+ }
101
+ }
102
+ return [...files].sort((left, right) => left.localeCompare(right));
103
+ }
104
+
105
+ function packageTaskName(packageName: string | undefined, packageDir: string, scriptName: string): string {
106
+ return `${packageName ?? packageDir}/${scriptName}`;
107
+ }
108
+
109
+ function tasksForPackage(options: { pkg: PackageJsonInfo; packageDir: string; namespaced: boolean }): RunnerTask[] {
110
+ return options.pkg.scripts.map(scriptName => ({
111
+ name: options.namespaced ? packageTaskName(options.pkg.name, options.packageDir, scriptName) : scriptName,
112
+ doc: options.namespaced ? options.packageDir : undefined,
113
+ parameters: [],
114
+ cwd: options.namespaced ? options.packageDir : undefined,
115
+ commandName: shellQuote(scriptName),
116
+ }));
117
+ }
118
+
119
+ async function readPackageTasks(cwd: string): Promise<RunnerTask[] | null> {
120
+ const rootPkg = await readPackageJson(path.join(cwd, "package.json"));
121
+ if (!rootPkg) return null;
122
+ const workspacePackageJsons = await findWorkspacePackageJsons(cwd, rootPkg.workspaces);
123
+ const tasks: RunnerTask[] = [];
124
+
125
+ if (rootPkg.scripts.length > 0) {
126
+ tasks.push(
127
+ ...tasksForPackage({
128
+ pkg: rootPkg,
129
+ packageDir: ".",
130
+ namespaced: false,
131
+ }),
132
+ );
133
+ }
134
+
135
+ for (const packageJsonPath of workspacePackageJsons) {
136
+ const pkg = await readPackageJson(path.join(cwd, packageJsonPath));
137
+ if (!pkg || pkg.scripts.length === 0) continue;
138
+ const packageDir = path.dirname(packageJsonPath);
139
+ tasks.push(
140
+ ...tasksForPackage({
141
+ pkg,
142
+ packageDir,
143
+ namespaced: true,
144
+ }),
145
+ );
146
+ }
147
+
148
+ return tasks.length > 0 ? tasks : null;
149
+ }
150
+
151
+ export const pkgRunner: TaskRunner = {
152
+ id: "pkg",
153
+ label: "Pkg",
154
+ async detect(cwd: string): Promise<DetectedRunner | null> {
155
+ try {
156
+ const commandPrefix = await resolvePackageRunner(cwd);
157
+ const tasks = await readPackageTasks(cwd);
158
+ if (!tasks || tasks.length === 0) return null;
159
+ return { id: "pkg", label: "Pkg", commandPrefix, tasks };
160
+ } catch (err) {
161
+ logger.debug("package runner probe failed", { error: err instanceof Error ? err.message : String(err) });
162
+ return null;
163
+ }
164
+ },
165
+ };