src / projects.ts
src / projects.ts
// The commands a project declares for itself, and running them.
//
// The model never writes a command. It picks one by name from what the project's own files
// declare: package.json scripts, pytest for a Python project, Makefile targets, and, for an LM
// Studio plugin, installing it and reading its log. Checks (tests, lint, type checks) run
// straight away in folders the user has trusted with /files trust; the first time in any other
// folder, the model is told to ask. Anything that changes something (a build, a deploy, an
// install) is a proposal the user approves with /files apply, every time.
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { codeHash, findNode, pluginAt, PluginInfo, pluginLog, recordTestRun, runCommandLine, summarize } from "./dev";
import { folderInText, isInside, realPath, samePath, Scope } from "./scope";
import { appendLog, dataDir, readJson, writeJson } from "./store";
const CHECK_TIMEOUT_MS = 5 * 60 * 1000;
const CHANGE_TIMEOUT_MS = 10 * 60 * 1000;
// LM Studio gives up on a tool call after about a minute, so a long check continues in the
// background and the next call collects the result.
const WAIT_MS = 45 * 1000;
export type CommandKind = "check" | "change" | "read";
export interface ProjectCommand {
name: string;
// What runs, as shown to the user. Empty for the built-in LM Studio entries.
command: string;
kind: CommandKind;
from: string;
pathDirs?: string[];
}
export interface Project {
root: string;
commands: ProjectCommand[];
plugin?: PluginInfo;
notes: string[];
}
const CHECK_NAME = /^(test|tests|check|lint|typecheck|type-check|types|tsc|format:check|prettier:check|pytest|(test|tests|lint|check|typecheck)[:_-].+)$/i;
const MARKERS = ["package.json", "pyproject.toml", "setup.py", "setup.cfg", "pytest.ini", "Makefile", "makefile", "manifest.json"];
const exists = (p: string) => {
try {
fs.statSync(p);
return true;
} catch {
return false;
}
};
function onPath(names: string[]): string | null {
const dirs = (process.env.PATH ?? process.env.Path ?? "").split(path.delimiter).filter((d) => d && !/WindowsApps/i.test(d));
for (const d of dirs) for (const n of names) if (exists(path.join(d, n))) return path.join(d, n);
return null;
}
function findPython(): string | null {
if (process.platform !== "win32") return onPath(["python3", "python"]);
const found = onPath(["py.exe", "python.exe"]);
if (found) return found;
const local = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
for (const base of [path.join(local, "Programs", "Python"), `${process.env.SystemDrive || "C:"}\\Windows`]) {
try {
if (exists(path.join(base, "py.exe"))) return path.join(base, "py.exe");
for (const d of fs.readdirSync(base).sort().reverse()) if (exists(path.join(base, d, "python.exe"))) return path.join(base, d, "python.exe");
} catch {
// not there
}
}
return null;
}
const quote = (p: string) => (/\s/.test(p) ? `"${p}"` : p);
export function hasMarker(dir: string): boolean {
return MARKERS.some((m) => exists(path.join(dir, m)));
}
// True when a granted folder, or a folder up to two levels inside one, is a project.
export function anyProject(scope: Scope): boolean {
const look = (dir: string, depth: number): boolean => {
if (hasMarker(dir)) return true;
if (depth === 0) return false;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return false;
}
return entries.some((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules" && look(path.join(dir, e.name), depth - 1));
};
return scope.grants.some((g) => look(g.folder, 2));
}
// The project in a folder inside the granted folders, with the commands it declares.
export function projectAt(scope: Scope, input: string): Project | { error: string } {
const r = scope.resolve(input, "read");
if ("error" in r) return { error: r.error };
let root = r.path;
try {
if (fs.statSync(root).isFile()) root = path.dirname(root);
} catch {
return { error: `${root} does not exist.` };
}
if (!hasMarker(root)) {
let below: string[] = [];
try {
below = fs.readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory() && hasMarker(path.join(root, e.name))).map((e) => path.join(root, e.name));
} catch {
// unreadable
}
return { error: `${root} is not a project folder (no package.json, pyproject.toml, Makefile or plugin manifest).${below.length ? ` Projects inside it: ${below.join(", ")}.` : ""}` };
}
const commands: ProjectCommand[] = [];
const notes: string[] = [];
const pkg = readJson<{ scripts?: Record<string, unknown> } | null>(path.join(root, "package.json"), null);
if (pkg?.scripts) {
const node = findNode();
if (!node) notes.push("Node.js was not found on this computer, so package.json scripts cannot run.");
for (const [name, cmd] of Object.entries(pkg.scripts)) {
if (typeof cmd !== "string") continue;
commands.push({ name, command: cmd, kind: CHECK_NAME.test(name) ? "check" : "change", from: "package.json", pathDirs: [path.join(root, "node_modules", ".bin"), ...(node ? [path.dirname(node)] : [])] });
}
}
const pyproject = (() => {
try {
return fs.readFileSync(path.join(root, "pyproject.toml"), "utf8");
} catch {
return "";
}
})();
const pytestUsed = /pytest/i.test(pyproject) || exists(path.join(root, "pytest.ini")) || exists(path.join(root, "conftest.py")) || exists(path.join(root, "tests"));
if (pytestUsed && (pyproject || exists(path.join(root, "setup.py")) || exists(path.join(root, "setup.cfg")) || exists(path.join(root, "pytest.ini")))) {
const py = findPython();
if (py) commands.push({ name: "pytest", command: `${quote(py)}${/py\.exe$/i.test(py) ? " -3" : ""} -m pytest -q`, kind: "check", from: "Python project" });
else notes.push("Python was not found on this computer, so pytest cannot run.");
}
const makefile = ["Makefile", "makefile"].map((m) => path.join(root, m)).find(exists);
if (makefile) {
const make = onPath(process.platform === "win32" ? ["make.exe", "mingw32-make.exe"] : ["make"]);
if (!make) notes.push("This project has a Makefile, but make was not found on this computer.");
else {
const text = fs.readFileSync(makefile, "utf8");
for (const m of text.matchAll(/^([A-Za-z0-9][\w.-]*)\s*:(?!=)/gm)) {
const target = m[1];
if (commands.some((c) => c.name === `make ${target}`)) continue;
commands.push({ name: `make ${target}`, command: `${quote(make)} ${target}`, kind: CHECK_NAME.test(target) ? "check" : "change", from: "Makefile" });
}
}
}
let plugin: PluginInfo | undefined;
if (exists(path.join(root, "manifest.json"))) {
const info = pluginAt(scope, root);
if (!("error" in info)) {
plugin = info;
commands.push({ name: "lmstudio-install", command: "", kind: "change", from: "LM Studio plugin" });
commands.push({ name: "lmstudio-log", command: "", kind: "read", from: "LM Studio plugin" });
}
}
return { root, commands, plugin, notes };
}
// ---------- Trust ----------
const trustFile = () => path.join(dataDir(), "trusted.json");
export const trustedFolders = () => readJson<string[]>(trustFile(), []);
export const isTrusted = (root: string) => trustedFolders().some((t) => isInside(realPath(root), realPath(t)));
export function trust(scope: Scope, input: string): string {
const named = folderInText(input);
if (!named) return `${input.trim()} is not a folder. Send /files trust followed by the project folder's full path.`;
const r = scope.resolve(named.folder, "read");
if ("error" in r) return r.error;
const folder = r.path;
const ignored = named.extra ? [`(The words after the folder, "${named.extra}", are not part of the command.)`] : [];
const list = trustedFolders();
if (!list.some((t) => samePath(realPath(t), folder))) writeJson(trustFile(), [...list, folder]);
const p = projectAt(scope, folder);
const checks = "error" in p ? [] : p.commands.filter((c) => c.kind === "check");
appendLog(`trusted ${folder} for checks`);
return [
`Trusted: ${folder}`,
"Checks there (tests, lint, type checks) now run without asking. Builds, installs and other commands still need /files apply each time.",
...(checks.length ? ["Checks in this project:", ...checks.map((c) => ` ${c.name}: ${c.command}`)] : ["This applies to every project inside this folder."]),
...ignored,
].join("\n");
}
export function untrust(input: string): string {
const folder = folderInText(input)?.folder ?? realPath(input.trim().replace(/^["']|["']$/g, ""));
const list = trustedFolders();
const left = list.filter((t) => !samePath(realPath(t), folder));
if (left.length === list.length) return `${folder} was not trusted.`;
writeJson(trustFile(), left);
appendLog(`stopped trusting ${folder}`);
return `No longer trusted: ${folder}. Its checks will ask first again.`;
}
// ---------- Running ----------
interface Result {
passed: boolean;
summary: string;
}
const running = new Map<string, Promise<Result>>();
const jobKey = (root: string, name: string) => `${root.toLowerCase()}\n${name}`;
function run(project: Project, cmd: ProjectCommand, timeoutMs: number): Promise<Result> {
const code = cmd.name === "test" ? codeHash(project.root) : "";
return runCommandLine(project.root, cmd.command, cmd.pathDirs ?? [], timeoutMs).then(({ code: exit, output, timedOut }) => {
const passed = exit === 0 && !timedOut;
const summary = (timedOut ? `It took longer than ${Math.round(timeoutMs / 60000)} minutes and was stopped.\n` : "") + summarize(output, passed);
if (cmd.name === "test") recordTestRun(project.root, passed, summary, code);
return { passed, summary };
});
}
function listing(project: Project): string {
const trusted = isTrusted(project.root);
const line = (c: ProjectCommand) => {
const how = c.kind === "read" ? "runs now" : c.kind === "check" ? (trusted ? "runs now" : "asks first") : "needs /files apply";
const what = c.name === "lmstudio-install" ? "install this plugin's current code into LM Studio" : c.name === "lmstudio-log" ? "this plugin's entries in LM Studio's log" : c.command;
return ` ${c.name} (${c.from}, ${how}): ${what}`;
};
return [
"status: ok",
`project: ${project.root}${trusted ? " (trusted for checks)" : ""}`,
...(project.commands.length ? ["commands:", ...project.commands.map(line)] : ["commands: none declared"]),
...project.notes.map((n) => `note: ${n}`),
"next: Call run_script again with one of these names as script.",
].join("\n");
}
export interface RunHooks {
proposeRun: (project: Project, cmd: ProjectCommand, reason: string) => string;
proposeInstall: (folder: string, reason: string) => string;
}
// What the run_script tool does: list the project's commands, run a check, read the plugin's
// log, or turn anything else into a proposal.
export async function runScript(scope: Scope, folder: string, name: string | undefined, reason: string | undefined, hooks: RunHooks): Promise<string> {
const project = projectAt(scope, folder);
if ("error" in project) return `status: error\nreason: ${project.error}`;
if (!name?.trim()) return listing(project);
const cmd = project.commands.find((c) => c.name.toLowerCase() === name.trim().toLowerCase());
if (!cmd) return `status: error\nreason: ${project.root} has no command called ${name}.\n${listing(project).split("\n").slice(2).join("\n")}`;
if (cmd.name === "lmstudio-log") return pluginLog(project.plugin!.id, 40);
if (cmd.name === "lmstudio-install") return hooks.proposeInstall(project.root, reason ?? "");
if (cmd.kind === "change") return hooks.proposeRun(project, cmd, reason ?? "");
if (!isTrusted(project.root)) {
return [
"status: needs_trust",
`not run: ${project.root} is not trusted for checks yet.`,
`command: ${cmd.command}`,
`next: Tell the user you want to run "${cmd.name}" (${cmd.command}) in ${project.root}, and that sending /files trust ${project.root} lets checks there run from now on without asking. Then wait for them.`,
].join("\n");
}
const key = jobKey(project.root, cmd.name);
let job = running.get(key);
if (!job) {
job = run(project, cmd, CHECK_TIMEOUT_MS);
running.set(key, job);
job.finally(() => running.delete(key));
}
const result = await Promise.race([job, new Promise<null>((r) => setTimeout(() => r(null), WAIT_MS))]);
if (!result) return `status: running\ncommand: ${cmd.name}\nnext: It is still running. Call run_script again with the same folder and script in a moment to get the result.`;
return [
result.passed ? "status: passed" : "status: failed",
`command: ${cmd.name} (${cmd.command}) in ${project.root}`,
result.summary,
result.passed ? "" : "next: Fix the failures with propose_edit, ask the user to apply the fixes, then run it again.",
].filter(Boolean).join("\n");
}
// Runs an approved command (from /files apply) to the end.
export async function runApproved(scope: Scope, root: string, name: string, command: string): Promise<{ passed: boolean; summary: string } | { error: string }> {
const project = projectAt(scope, root);
if ("error" in project) return { error: project.error };
const cmd = project.commands.find((c) => c.name === name);
if (!cmd) return { error: `${root} no longer has a command called ${name}.` };
if (cmd.command !== command) return { error: `the command "${name}" changed since it was proposed (now: ${cmd.command}). Propose it again.` };
const result = await run(project, cmd, CHANGE_TIMEOUT_MS);
appendLog(`ran ${name} in ${root}: ${result.passed ? "succeeded" : "failed"}`);
return result;
}
// The commands a project declares for itself, and running them.
//
// The model never writes a command. It picks one by name from what the project's own files
// declare: package.json scripts, pytest for a Python project, Makefile targets, and, for an LM
// Studio plugin, installing it and reading its log. Checks (tests, lint, type checks) run
// straight away in folders the user has trusted with /files trust; the first time in any other
// folder, the model is told to ask. Anything that changes something (a build, a deploy, an
// install) is a proposal the user approves with /files apply, every time.
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { codeHash, findNode, pluginAt, PluginInfo, pluginLog, recordTestRun, runCommandLine, summarize } from "./dev";
import { folderInText, isInside, realPath, samePath, Scope } from "./scope";
import { appendLog, dataDir, readJson, writeJson } from "./store";
const CHECK_TIMEOUT_MS = 5 * 60 * 1000;
const CHANGE_TIMEOUT_MS = 10 * 60 * 1000;
// LM Studio gives up on a tool call after about a minute, so a long check continues in the
// background and the next call collects the result.
const WAIT_MS = 45 * 1000;
export type CommandKind = "check" | "change" | "read";
export interface ProjectCommand {
name: string;
// What runs, as shown to the user. Empty for the built-in LM Studio entries.
command: string;
kind: CommandKind;
from: string;
pathDirs?: string[];
}
export interface Project {
root: string;
commands: ProjectCommand[];
plugin?: PluginInfo;
notes: string[];
}
const CHECK_NAME = /^(test|tests|check|lint|typecheck|type-check|types|tsc|format:check|prettier:check|pytest|(test|tests|lint|check|typecheck)[:_-].+)$/i;
const MARKERS = ["package.json", "pyproject.toml", "setup.py", "setup.cfg", "pytest.ini", "Makefile", "makefile", "manifest.json"];
const exists = (p: string) => {
try {
fs.statSync(p);
return true;
} catch {
return false;
}
};
function onPath(names: string[]): string | null {
const dirs = (process.env.PATH ?? process.env.Path ?? "").split(path.delimiter).filter((d) => d && !/WindowsApps/i.test(d));
for (const d of dirs) for (const n of names) if (exists(path.join(d, n))) return path.join(d, n);
return null;
}
function findPython(): string | null {
if (process.platform !== "win32") return onPath(["python3", "python"]);
const found = onPath(["py.exe", "python.exe"]);
if (found) return found;
const local = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
for (const base of [path.join(local, "Programs", "Python"), `${process.env.SystemDrive || "C:"}\\Windows`]) {
try {
if (exists(path.join(base, "py.exe"))) return path.join(base, "py.exe");
for (const d of fs.readdirSync(base).sort().reverse()) if (exists(path.join(base, d, "python.exe"))) return path.join(base, d, "python.exe");
} catch {
// not there
}
}
return null;
}
const quote = (p: string) => (/\s/.test(p) ? `"${p}"` : p);
export function hasMarker(dir: string): boolean {
return MARKERS.some((m) => exists(path.join(dir, m)));
}
// True when a granted folder, or a folder up to two levels inside one, is a project.
export function anyProject(scope: Scope): boolean {
const look = (dir: string, depth: number): boolean => {
if (hasMarker(dir)) return true;
if (depth === 0) return false;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return false;
}
return entries.some((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules" && look(path.join(dir, e.name), depth - 1));
};
return scope.grants.some((g) => look(g.folder, 2));
}
// The project in a folder inside the granted folders, with the commands it declares.
export function projectAt(scope: Scope, input: string): Project | { error: string } {
const r = scope.resolve(input, "read");
if ("error" in r) return { error: r.error };
let root = r.path;
try {
if (fs.statSync(root).isFile()) root = path.dirname(root);
} catch {
return { error: `${root} does not exist.` };
}
if (!hasMarker(root)) {
let below: string[] = [];
try {
below = fs.readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory() && hasMarker(path.join(root, e.name))).map((e) => path.join(root, e.name));
} catch {
// unreadable
}
return { error: `${root} is not a project folder (no package.json, pyproject.toml, Makefile or plugin manifest).${below.length ? ` Projects inside it: ${below.join(", ")}.` : ""}` };
}
const commands: ProjectCommand[] = [];
const notes: string[] = [];
const pkg = readJson<{ scripts?: Record<string, unknown> } | null>(path.join(root, "package.json"), null);
if (pkg?.scripts) {
const node = findNode();
if (!node) notes.push("Node.js was not found on this computer, so package.json scripts cannot run.");
for (const [name, cmd] of Object.entries(pkg.scripts)) {
if (typeof cmd !== "string") continue;
commands.push({ name, command: cmd, kind: CHECK_NAME.test(name) ? "check" : "change", from: "package.json", pathDirs: [path.join(root, "node_modules", ".bin"), ...(node ? [path.dirname(node)] : [])] });
}
}
const pyproject = (() => {
try {
return fs.readFileSync(path.join(root, "pyproject.toml"), "utf8");
} catch {
return "";
}
})();
const pytestUsed = /pytest/i.test(pyproject) || exists(path.join(root, "pytest.ini")) || exists(path.join(root, "conftest.py")) || exists(path.join(root, "tests"));
if (pytestUsed && (pyproject || exists(path.join(root, "setup.py")) || exists(path.join(root, "setup.cfg")) || exists(path.join(root, "pytest.ini")))) {
const py = findPython();
if (py) commands.push({ name: "pytest", command: `${quote(py)}${/py\.exe$/i.test(py) ? " -3" : ""} -m pytest -q`, kind: "check", from: "Python project" });
else notes.push("Python was not found on this computer, so pytest cannot run.");
}
const makefile = ["Makefile", "makefile"].map((m) => path.join(root, m)).find(exists);
if (makefile) {
const make = onPath(process.platform === "win32" ? ["make.exe", "mingw32-make.exe"] : ["make"]);
if (!make) notes.push("This project has a Makefile, but make was not found on this computer.");
else {
const text = fs.readFileSync(makefile, "utf8");
for (const m of text.matchAll(/^([A-Za-z0-9][\w.-]*)\s*:(?!=)/gm)) {
const target = m[1];
if (commands.some((c) => c.name === `make ${target}`)) continue;
commands.push({ name: `make ${target}`, command: `${quote(make)} ${target}`, kind: CHECK_NAME.test(target) ? "check" : "change", from: "Makefile" });
}
}
}
let plugin: PluginInfo | undefined;
if (exists(path.join(root, "manifest.json"))) {
const info = pluginAt(scope, root);
if (!("error" in info)) {
plugin = info;
commands.push({ name: "lmstudio-install", command: "", kind: "change", from: "LM Studio plugin" });
commands.push({ name: "lmstudio-log", command: "", kind: "read", from: "LM Studio plugin" });
}
}
return { root, commands, plugin, notes };
}
// ---------- Trust ----------
const trustFile = () => path.join(dataDir(), "trusted.json");
export const trustedFolders = () => readJson<string[]>(trustFile(), []);
export const isTrusted = (root: string) => trustedFolders().some((t) => isInside(realPath(root), realPath(t)));
export function trust(scope: Scope, input: string): string {
const named = folderInText(input);
if (!named) return `${input.trim()} is not a folder. Send /files trust followed by the project folder's full path.`;
const r = scope.resolve(named.folder, "read");
if ("error" in r) return r.error;
const folder = r.path;
const ignored = named.extra ? [`(The words after the folder, "${named.extra}", are not part of the command.)`] : [];
const list = trustedFolders();
if (!list.some((t) => samePath(realPath(t), folder))) writeJson(trustFile(), [...list, folder]);
const p = projectAt(scope, folder);
const checks = "error" in p ? [] : p.commands.filter((c) => c.kind === "check");
appendLog(`trusted ${folder} for checks`);
return [
`Trusted: ${folder}`,
"Checks there (tests, lint, type checks) now run without asking. Builds, installs and other commands still need /files apply each time.",
...(checks.length ? ["Checks in this project:", ...checks.map((c) => ` ${c.name}: ${c.command}`)] : ["This applies to every project inside this folder."]),
...ignored,
].join("\n");
}
export function untrust(input: string): string {
const folder = folderInText(input)?.folder ?? realPath(input.trim().replace(/^["']|["']$/g, ""));
const list = trustedFolders();
const left = list.filter((t) => !samePath(realPath(t), folder));
if (left.length === list.length) return `${folder} was not trusted.`;
writeJson(trustFile(), left);
appendLog(`stopped trusting ${folder}`);
return `No longer trusted: ${folder}. Its checks will ask first again.`;
}
// ---------- Running ----------
interface Result {
passed: boolean;
summary: string;
}
const running = new Map<string, Promise<Result>>();
const jobKey = (root: string, name: string) => `${root.toLowerCase()}\n${name}`;
function run(project: Project, cmd: ProjectCommand, timeoutMs: number): Promise<Result> {
const code = cmd.name === "test" ? codeHash(project.root) : "";
return runCommandLine(project.root, cmd.command, cmd.pathDirs ?? [], timeoutMs).then(({ code: exit, output, timedOut }) => {
const passed = exit === 0 && !timedOut;
const summary = (timedOut ? `It took longer than ${Math.round(timeoutMs / 60000)} minutes and was stopped.\n` : "") + summarize(output, passed);
if (cmd.name === "test") recordTestRun(project.root, passed, summary, code);
return { passed, summary };
});
}
function listing(project: Project): string {
const trusted = isTrusted(project.root);
const line = (c: ProjectCommand) => {
const how = c.kind === "read" ? "runs now" : c.kind === "check" ? (trusted ? "runs now" : "asks first") : "needs /files apply";
const what = c.name === "lmstudio-install" ? "install this plugin's current code into LM Studio" : c.name === "lmstudio-log" ? "this plugin's entries in LM Studio's log" : c.command;
return ` ${c.name} (${c.from}, ${how}): ${what}`;
};
return [
"status: ok",
`project: ${project.root}${trusted ? " (trusted for checks)" : ""}`,
...(project.commands.length ? ["commands:", ...project.commands.map(line)] : ["commands: none declared"]),
...project.notes.map((n) => `note: ${n}`),
"next: Call run_script again with one of these names as script.",
].join("\n");
}
export interface RunHooks {
proposeRun: (project: Project, cmd: ProjectCommand, reason: string) => string;
proposeInstall: (folder: string, reason: string) => string;
}
// What the run_script tool does: list the project's commands, run a check, read the plugin's
// log, or turn anything else into a proposal.
export async function runScript(scope: Scope, folder: string, name: string | undefined, reason: string | undefined, hooks: RunHooks): Promise<string> {
const project = projectAt(scope, folder);
if ("error" in project) return `status: error\nreason: ${project.error}`;
if (!name?.trim()) return listing(project);
const cmd = project.commands.find((c) => c.name.toLowerCase() === name.trim().toLowerCase());
if (!cmd) return `status: error\nreason: ${project.root} has no command called ${name}.\n${listing(project).split("\n").slice(2).join("\n")}`;
if (cmd.name === "lmstudio-log") return pluginLog(project.plugin!.id, 40);
if (cmd.name === "lmstudio-install") return hooks.proposeInstall(project.root, reason ?? "");
if (cmd.kind === "change") return hooks.proposeRun(project, cmd, reason ?? "");
if (!isTrusted(project.root)) {
return [
"status: needs_trust",
`not run: ${project.root} is not trusted for checks yet.`,
`command: ${cmd.command}`,
`next: Tell the user you want to run "${cmd.name}" (${cmd.command}) in ${project.root}, and that sending /files trust ${project.root} lets checks there run from now on without asking. Then wait for them.`,
].join("\n");
}
const key = jobKey(project.root, cmd.name);
let job = running.get(key);
if (!job) {
job = run(project, cmd, CHECK_TIMEOUT_MS);
running.set(key, job);
job.finally(() => running.delete(key));
}
const result = await Promise.race([job, new Promise<null>((r) => setTimeout(() => r(null), WAIT_MS))]);
if (!result) return `status: running\ncommand: ${cmd.name}\nnext: It is still running. Call run_script again with the same folder and script in a moment to get the result.`;
return [
result.passed ? "status: passed" : "status: failed",
`command: ${cmd.name} (${cmd.command}) in ${project.root}`,
result.summary,
result.passed ? "" : "next: Fix the failures with propose_edit, ask the user to apply the fixes, then run it again.",
].filter(Boolean).join("\n");
}
// Runs an approved command (from /files apply) to the end.
export async function runApproved(scope: Scope, root: string, name: string, command: string): Promise<{ passed: boolean; summary: string } | { error: string }> {
const project = projectAt(scope, root);
if ("error" in project) return { error: project.error };
const cmd = project.commands.find((c) => c.name === name);
if (!cmd) return { error: `${root} no longer has a command called ${name}.` };
if (cmd.command !== command) return { error: `the command "${name}" changed since it was proposed (now: ${cmd.command}). Propose it again.` };
const result = await run(project, cmd, CHANGE_TIMEOUT_MS);
appendLog(`ran ${name} in ${root}: ${result.passed ? "succeeded" : "failed"}`);
return result;
}