import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { formatSafeCommandResult, type GhSafeParams, type GitSafeParams, type RunBiomeParams, type RunCargoTestParams, type RunPytestParams, type RunTypecheckParams, type RunVitestParams, runSafeCommand, type SafeCommandExecutionResult, type SafeRunnerName, } from "../shared/safe-command-runner.js"; import { countRulesBreakdown, loadRules } from "../shared/damage-prevention-rules.js"; interface PackageMetadata { name: string; version: string; packageRoot: string; sourcePath: string; } const sourcePath = fileURLToPath(import.meta.url); const packageRoot = path.resolve(path.dirname(sourcePath), "../.."); let cachedPackageMetadata: PackageMetadata | null = null; function getPackageMetadata(): PackageMetadata { if (cachedPackageMetadata) return cachedPackageMetadata; let name = "@davehardy20/pi-safe-tools"; let version = "0.1.0"; try { const packageJson = JSON.parse( fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"), ) as { name?: string; version?: string }; name = packageJson.name ?? name; version = packageJson.version ?? version; } catch { // best-effort metadata only } cachedPackageMetadata = { name, version, packageRoot, sourcePath }; return cachedPackageMetadata; } export interface SafeCommandToolDeps { runSafeCommand: typeof runSafeCommand; } const DEFAULT_DEPS: SafeCommandToolDeps = { runSafeCommand, }; const SHARED_PROMPT_GUIDELINES = [ "Prefer these safe runner tools over bash for validation commands.", "Use run_vitest instead of npm test, npx vitest, or node-based test invocations.", "Use run_biome instead of invoking biome through bash.", "Use run_typecheck instead of invoking tsc through bash.", "These tools run fixed executables with validated arguments only; they do not allow arbitrary shell composition.", "Prefer safe git tool operations over bash when you need to stage, commit, or push changes.", "Use git_safe init and gh_safe repo_create instead of bash for repo bootstrap workflows under ~/tools.", ]; function getPlanningErrorHints( toolName: SafeRunnerName, params: object, message: string, ): string[] { const normalized = message.toLowerCase(); const hints: string[] = []; if (toolName === "git_safe") { const action = (params as Partial).action; if (action === "diff") { hints.push( "Tip: use action='diff' before add/commit/push; set cached=true for staged changes and pass paths to narrow the comparison.", ); } if (action === "commit" && normalized.includes("message is required")) { hints.push( "Next step: pass a concise commit message with action='commit'.", ); } if (action === "push") { hints.push( "Guardrail: action='push' publishes changes. Use it only when the user approved publication or the repo workflow clearly expects it.", ); } if ( action === "init" && (normalized.includes("either name or sourcepath is required") || normalized.includes("does not match the expected target") || normalized.includes("must be inside ~/tools")) ) { hints.push( "Next step: provide name, sourcePath, or both (they must match). Keep sourcePath inside ~/tools.", ); } } if (toolName === "gh_safe") { const ghParams = params as Partial; if (ghParams.action === "repo_create") { if ( normalized.includes("does not exist") || normalized.includes("must be inside") || normalized.includes("must be a directory") ) { hints.push( "Next step: ensure the local repo directory exists inside ~/tools; pass sourcePath explicitly if it is not ~/tools/.", ); } if (ghParams.push) { hints.push( "Guardrail: push=true publishes the local repo immediately. Use it only when immediate publication is intended and approved.", ); } } if ( ghParams.action === "pr_create" && normalized.includes("title is required") ) { hints.push( "Next step: provide a title for the pull request with action='pr_create'.", ); } if ( (ghParams.action === "pr_edit" || ghParams.action === "pr_merge") && normalized.includes("name (pr number) is required") ) { hints.push( "Next step: provide the PR number as name for pr_edit or pr_merge.", ); } } return hints; } function formatPlanningErrorText( label: string, toolName: SafeRunnerName, params: object, message: string, ): string { const hints = getPlanningErrorHints(toolName, params, message); const lines = [`Command: ${label}`, "Status: planning error", "", message]; if (hints.length > 0) { lines.push("", "Hints:", ...hints.map((hint) => `- ${hint}`)); } return lines.join("\n"); } function registerSafeCommandTool(args: { pi: ExtensionAPI; deps: SafeCommandToolDeps; name: SafeRunnerName; label: string; description: string; promptSnippet: string; promptGuidelines?: string[]; parameters: ReturnType; }) { args.pi.registerTool({ name: args.name, label: args.label, description: args.description, promptSnippet: args.promptSnippet, promptGuidelines: [ ...SHARED_PROMPT_GUIDELINES, ...(args.promptGuidelines ?? []), ], parameters: args.parameters, async execute(_toolCallId, params, signal, _onUpdate, ctx) { let result: SafeCommandExecutionResult; try { result = await args.deps.runSafeCommand( args.name, params as TParams, ctx.cwd, signal, ); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); const hints = getPlanningErrorHints( args.name, params as object, message, ); return { content: [ { type: "text" as const, text: formatPlanningErrorText( args.label, args.name, params as object, message, ), }, ], details: { toolName: args.name, error: message, hints, success: false, }, }; } return { content: [{ type: "text", text: formatSafeCommandResult(result) }], details: { toolName: result.toolName, command: result.displayCommand, cwd: result.cwd, exitCode: result.exitCode, timedOut: result.timedOut, aborted: result.aborted, spawnError: result.spawnError, stdout: result.stdout, stderr: result.stderr, success: !result.timedOut && !result.aborted && !result.spawnError && (result.exitCode ?? 1) === 0, }, }; }, }); } export default function safeCommandToolsExtension( pi: ExtensionAPI, deps: SafeCommandToolDeps = DEFAULT_DEPS, ) { registerSafeCommandTool({ pi, deps, name: "run_biome", label: "Run Biome", description: "Run Biome through a trusted direct executable path without bash or npm scripts. Safe for targeted lint/format validation.", promptSnippet: "Use run_biome for trusted Biome validation instead of bash.", promptGuidelines: [ "Prefer targeted file paths when possible instead of checking the whole repo.", ], parameters: Type.Object({ paths: Type.Optional( Type.Array(Type.String(), { description: "Workspace-relative file or directory paths to check. Defaults to the current workspace.", }), ), timeoutMs: Type.Optional( Type.Number({ description: "Optional timeout in milliseconds (capped internally).", }), ), }), }); registerSafeCommandTool({ pi, deps, name: "run_vitest", label: "Run Vitest", description: "Run Vitest through a trusted direct executable path without bash, npm, npx, or package scripts.", promptSnippet: "Use run_vitest for trusted Vitest execution instead of npm test or npx vitest.", promptGuidelines: [ "Prefer explicit test file paths for narrower validation.", "Use testNamePattern only for filtering specific tests; arbitrary extra flags are not supported.", ], parameters: Type.Object({ paths: Type.Optional( Type.Array(Type.String(), { description: "Optional workspace-relative test file or directory paths. Defaults to the full Vitest suite.", }), ), testNamePattern: Type.Optional( Type.String({ description: "Optional Vitest test-name filter.", }), ), timeoutMs: Type.Optional( Type.Number({ description: "Optional timeout in milliseconds (capped internally).", }), ), }), }); registerSafeCommandTool({ pi, deps, name: "run_typecheck", label: "Run Typecheck", description: "Run TypeScript type-checking through a trusted direct tsc invocation without bash or npm scripts.", promptSnippet: "Use run_typecheck for trusted TypeScript validation instead of tsc via bash.", parameters: Type.Object({ project: Type.Optional( Type.String({ description: "Optional workspace-relative tsconfig file path to pass with -p.", }), ), timeoutMs: Type.Optional( Type.Number({ description: "Optional timeout in milliseconds (capped internally).", }), ), }), }); registerSafeCommandTool({ pi, deps, name: "run_pytest", label: "Run Pytest", description: "Run pytest through a trusted direct executable path without bash or shell wrappers.", promptSnippet: "Use run_pytest for trusted pytest execution instead of bash.", promptGuidelines: [ "Prefer explicit test file paths for narrower validation.", "Use keyword only for pytest -k style filtering; arbitrary flags are not supported.", ], parameters: Type.Object({ paths: Type.Optional( Type.Array(Type.String(), { description: "Optional workspace-relative test file or directory paths. Defaults to the full pytest suite.", }), ), keyword: Type.Optional( Type.String({ description: "Optional pytest -k expression.", }), ), timeoutMs: Type.Optional( Type.Number({ description: "Optional timeout in milliseconds (capped internally).", }), ), }), }); registerSafeCommandTool({ pi, deps, name: "run_cargo_test", label: "Run Cargo Test", description: "Run cargo test through a trusted direct executable path without bash or shell wrappers.", promptSnippet: "Use run_cargo_test for trusted Rust test execution instead of bash.", promptGuidelines: [ "Use package to narrow execution to a workspace crate when needed.", "Use testNamePattern only for the positional cargo test filter.", ], parameters: Type.Object({ package: Type.Optional( Type.String({ description: "Optional cargo package name for -p.", }), ), testNamePattern: Type.Optional( Type.String({ description: "Optional cargo test name filter.", }), ), timeoutMs: Type.Optional( Type.Number({ description: "Optional timeout in milliseconds (capped internally).", }), ), }), }); registerSafeCommandTool({ pi, deps, name: "git_safe", label: "Git Safe", description: "Run a restricted git operation through a trusted direct git executable path with argument validation and secret-scan preflight.", promptSnippet: "Use git_safe for trusted git status/add/commit/push/init operations instead of bash.", promptGuidelines: [ "Use action='status' to inspect the working tree without bash.", "Use action='diff' to inspect unstaged or staged changes before add/commit/push; set cached=true for staged changes.", "Use action='add' with explicit workspace-relative paths when staging changes.", "Use action='commit' with a concise message; staged content is secret-scanned before commit.", "Use action='push' only with simple remote/branch names; inline remote URLs and arbitrary git flags are not supported.", "Use action='push' only when the user approved publication or the repo workflow clearly expects it.", "Use action='init' with name to initialize a new git repo under ~/tools/; use sourcePath for an explicit validated directory instead.", "For action='init', the target directory is created automatically if it does not exist.", ], parameters: Type.Object({ action: Type.Union([ Type.Literal("status"), Type.Literal("diff"), Type.Literal("add"), Type.Literal("commit"), Type.Literal("push"), Type.Literal("init"), ]), paths: Type.Optional( Type.Array(Type.String(), { description: "Optional workspace-relative paths for diff/add. Defaults to the whole workspace for add when omitted.", }), ), cached: Type.Optional( Type.Boolean({ description: "Use the staged diff for action='diff'.", }), ), message: Type.Optional( Type.String({ description: "Commit message for action='commit'.", }), ), remote: Type.Optional( Type.String({ description: "Optional remote name for action='push' (for example: origin).", }), ), branch: Type.Optional( Type.String({ description: "Optional branch name for action='push'.", }), ), name: Type.Optional( Type.String({ description: "Repo name for action='init'; resolves to ~/tools/. Must be a simple slug (lowercase letters, digits, hyphens).", }), ), sourcePath: Type.Optional( Type.String({ description: "Explicit directory path for action='init'. Must be inside ~/tools. Takes precedence over name if both are provided.", }), ), timeoutMs: Type.Optional( Type.Number({ description: "Optional timeout in milliseconds (capped internally).", }), ), }), }); registerSafeCommandTool({ pi, deps, name: "gh_safe", label: "GitHub CLI Safe", description: "Run a restricted GitHub CLI operation through a trusted direct gh executable path with argument validation. Uses the authenticated gh owner context.", promptSnippet: "Use gh_safe for trusted GitHub repo and PR operations instead of bash gh commands.", promptGuidelines: [ "Use action='repo_create' to create a GitHub repository for a local package.", "name is required for repo_create and must be a simple slug (lowercase letters, digits, hyphens).", "The authenticated GitHub owner from gh is used automatically; do not pass an explicit owner.", "sourcePath points to the local repo directory; defaults to ~/tools/ if omitted.", "Set push to true only when immediate publication is intended and approved.", "Use action='pr_create' to open a pull request from the current branch.", "Use action='pr_edit' to update a PR title or body. Pass the PR number as name.", "Use action='pr_merge' to merge a PR. Pass the PR number as name. Use squash=true for squash merge.", "Use action='pr_view' to inspect a PR. Pass the PR number as name, or omit it for the current branch's PR.", ], parameters: Type.Object({ action: Type.Union([ Type.Literal("repo_create"), Type.Literal("pr_create"), Type.Literal("pr_edit"), Type.Literal("pr_merge"), Type.Literal("pr_view"), ]), name: Type.Optional( Type.String({ description: "Repository name for repo_create, or PR number for pr_edit/pr_merge/pr_view.", }), ), visibility: Type.Optional( Type.Union([Type.Literal("public"), Type.Literal("private")], { description: "Repository visibility. Defaults to 'private' if omitted.", }), ), sourcePath: Type.Optional( Type.String({ description: "Local repo directory path. Must be inside ~/tools. Defaults to ~/tools/.", }), ), remote: Type.Optional( Type.String({ description: "Remote name to set up. Defaults to 'origin'.", }), ), push: Type.Optional( Type.Boolean({ description: "If true, push the local repo to the newly created GitHub remote.", }), ), title: Type.Optional( Type.String({ description: "PR title for pr_create or pr_edit.", }), ), body: Type.Optional( Type.String({ description: "PR body for pr_create or pr_edit.", }), ), base: Type.Optional( Type.String({ description: "Base branch for pr_create. Defaults to the repo default branch.", }), ), draft: Type.Optional( Type.Boolean({ description: "If true, create the PR as a draft.", }), ), squash: Type.Optional( Type.Boolean({ description: "If true, use squash merge for pr_merge.", }), ), deleteBranch: Type.Optional( Type.Boolean({ description: "If true, delete the branch after pr_merge.", }), ), timeoutMs: Type.Optional( Type.Number({ description: "Optional timeout in milliseconds (capped internally).", }), ), }), }); // Package status/debug command pi.registerCommand("safe-tools-status", { description: "Show safe-tools package status and loaded rule counts", handler: async (_args, ctx) => { const metadata = getPackageMetadata(); const loadResult = loadRules(ctx.cwd); const total = Object.values(countRulesBreakdown(loadResult.rules)).reduce( (sum, count) => sum + count, 0, ); const breakdown = countRulesBreakdown(loadResult.rules); pi.sendMessage({ customType: "safe-tools-status", display: true, content: [ `${metadata.name} v${metadata.version}`, `source: ${metadata.sourcePath}`, `rules source: ${loadResult.source} (${total} rules)`, `breakdown: allow=${breakdown.allowPatterns} blocked=${breakdown.blockedPatterns} confirm=${breakdown.confirmPatterns}`, ].join("\n"), details: { packageName: metadata.name, version: metadata.version, sourcePath: metadata.sourcePath, packageRoot: metadata.packageRoot, rulesSource: loadResult.source, totalRules: total, breakdown, }, }); }, }); }