From ba9803ac3c06348d15c8d7bc3c92b0dec48eee6a Mon Sep 17 00:00:00 2001 From: Tobias Ostner Date: Sun, 19 Jul 2026 15:50:32 +0700 Subject: [PATCH] Revert "Add Linear issue worktree workflow" This reverts commit bc5bf77963b9e9571c8214c1f3599481139d9eb5. --- config/pi/extensions/worktree-agent/guard.ts | 62 ------- config/pi/extensions/worktree-agent/index.ts | 75 -------- config/pi/extensions/worktree-agent/types.ts | 25 --- config/pi/extensions/worktree-agent/worker.ts | 175 ------------------ .../pi/extensions/worktree-agent/worktree.ts | 93 ---------- config/pi/prompts/issue.md | 19 -- config/pi/skills/linear-issue-worker/SKILL.md | 96 ---------- .../linear-issue-worker/evals/evals.json | 29 --- module/home-manager.nix | 7 - 9 files changed, 581 deletions(-) delete mode 100644 config/pi/extensions/worktree-agent/guard.ts delete mode 100644 config/pi/extensions/worktree-agent/index.ts delete mode 100644 config/pi/extensions/worktree-agent/types.ts delete mode 100644 config/pi/extensions/worktree-agent/worker.ts delete mode 100644 config/pi/extensions/worktree-agent/worktree.ts delete mode 100644 config/pi/prompts/issue.md delete mode 100644 config/pi/skills/linear-issue-worker/SKILL.md delete mode 100644 config/pi/skills/linear-issue-worker/evals/evals.json diff --git a/config/pi/extensions/worktree-agent/guard.ts b/config/pi/extensions/worktree-agent/guard.ts deleted file mode 100644 index e137212..0000000 --- a/config/pi/extensions/worktree-agent/guard.ts +++ /dev/null @@ -1,62 +0,0 @@ -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; - -const PATH_TOOLS = new Set(["read", "write", "edit", "grep", "find", "ls"]); - -function isWithin(root: string, candidate: string): boolean { - const relative = path.relative(root, candidate); - return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)); -} - -function canonicalizePotentialPath(inputPath: string, cwd: string): string { - let normalized = inputPath.startsWith("@") ? inputPath.slice(1) : inputPath; - if (normalized === "~") normalized = os.homedir(); - else if (normalized.startsWith(`~${path.sep}`)) normalized = path.join(os.homedir(), normalized.slice(2)); - - const resolved = path.resolve(cwd, normalized); - const missingSegments: string[] = []; - let existing = resolved; - - while (!fs.existsSync(existing)) { - const parent = path.dirname(existing); - if (parent === existing) break; - missingSegments.unshift(path.basename(existing)); - existing = parent; - } - - const canonicalBase = fs.existsSync(existing) ? fs.realpathSync(existing) : existing; - return path.join(canonicalBase, ...missingSegments); -} - -export function registerWorkerGuard(pi: ExtensionAPI, configuredRoot: string): void { - const root = fs.realpathSync(configuredRoot); - - pi.on("tool_call", async (event) => { - if (!PATH_TOOLS.has(event.toolName)) return; - - const input = event.input as { path?: unknown }; - if (input.path === undefined && (event.toolName === "grep" || event.toolName === "find" || event.toolName === "ls")) { - return; - } - if (typeof input.path !== "string") { - return { block: true, reason: `${event.toolName} requires a path inside the assigned worktree.` }; - } - - const target = canonicalizePotentialPath(input.path, root); - if (!isWithin(root, target)) { - return { - block: true, - reason: `Worker file tools are restricted to ${root}. Attempted path: ${input.path}`, - }; - } - }); - - pi.on("before_agent_start", async (event) => ({ - systemPrompt: - event.systemPrompt + - `\n\nYou are running as a worktree worker. Your assigned repository root is ${root}. ` + - "Keep all file mutations and shell work inside this worktree. Do not access or modify sibling worktrees.", - })); -} diff --git a/config/pi/extensions/worktree-agent/index.ts b/config/pi/extensions/worktree-agent/index.ts deleted file mode 100644 index 4f114d9..0000000 --- a/config/pi/extensions/worktree-agent/index.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { registerWorkerGuard } from "./guard.ts"; -import { WorktreeAgentParameters, type ProgressReporter } from "./types.ts"; -import { runWorker } from "./worker.ts"; -import { isParentClean, prepareWorktree } from "./worktree.ts"; - -function shellQuote(value: string): string { - return `'${value.replace(/'/g, `'"'"'`)}'`; -} - -export default function worktreeAgentExtension(pi: ExtensionAPI) { - const workerRoot = process.env.PI_WORKTREE_ROOT; - if (process.env.PI_WORKTREE_AGENT === "1" && workerRoot) { - registerWorkerGuard(pi, workerRoot); - return; - } - - pi.registerTool({ - name: "run_issue_in_worktree", - label: "Run issue in worktree", - description: - "Create an isolated Git worktree for a retrieved Linear issue and run a child Pi agent there. " + - "Call this only after retrieving and understanding the Linear issue. The call remains active until the child finishes.", - promptSnippet: "Run a retrieved Linear issue in an isolated Git worktree using a child Pi agent", - promptGuidelines: [ - "Use run_issue_in_worktree only after retrieving the Linear issue, then let the child agent perform all repository modifications.", - "When run_issue_in_worktree is active, do not modify files in the parent checkout.", - ], - parameters: WorktreeAgentParameters, - - async execute(_toolCallId, params, signal, onUpdate, ctx) { - const report: ProgressReporter = (message, details) => { - onUpdate?.({ content: [{ type: "text", text: message }], details }); - }; - - const worktree = await prepareWorktree(pi, ctx.cwd, params, report); - const workerReport = await runWorker({ - input: params, - worktree, - model: ctx.model, - thinkingLevel: pi.getThinkingLevel(), - signal, - report, - }); - - const parentClean = await isParentClean(pi, worktree.repoRoot); - const resumeCommand = - `cd ${shellQuote(worktree.worktreePath)} && ` + - 'PI_WORKTREE_AGENT=1 PI_WORKTREE_ROOT="$PWD" pi -c'; - const parentWarning = parentClean - ? "" - : "\n\nWARNING: The parent working tree is no longer clean; inspect it before continuing."; - - return { - content: [ - { - type: "text", - text: - `Child Pi finished in ${worktree.worktreePath}.\n` + - `Branch: ${worktree.branchName}\n` + - `Resume: ${resumeCommand}\n\n${workerReport}${parentWarning}`, - }, - ], - details: { - phase: "complete", - branchName: worktree.branchName, - worktreePath: worktree.worktreePath, - parentClean, - resumeCommand, - workerReport, - }, - }; - }, - }); -} diff --git a/config/pi/extensions/worktree-agent/types.ts b/config/pi/extensions/worktree-agent/types.ts deleted file mode 100644 index b9f2b22..0000000 --- a/config/pi/extensions/worktree-agent/types.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Type, type Static } from "typebox"; - -export const WorktreeAgentParameters = Type.Object({ - issueId: Type.String({ description: "Linear issue identifier, for example ENG-123" }), - title: Type.String({ description: "Linear issue title" }), - description: Type.String({ description: "Linear issue description and relevant context" }), - acceptanceCriteria: Type.Optional( - Type.Array(Type.String(), { description: "Explicit or inferred acceptance criteria" }), - ), - issueUrl: Type.Optional(Type.String({ description: "Linear issue URL" })), - baseRef: Type.Optional( - Type.String({ description: "Git ref from which to create the branch. Defaults to the current HEAD." }), - ), -}); - -export type WorktreeAgentInput = Static; - -export interface WorktreeContext { - repoRoot: string; - branchName: string; - worktreePath: string; - baseRef: string; -} - -export type ProgressReporter = (message: string, details: Record) => void; diff --git a/config/pi/extensions/worktree-agent/worker.ts b/config/pi/extensions/worktree-agent/worker.ts deleted file mode 100644 index 6f13e7c..0000000 --- a/config/pi/extensions/worktree-agent/worker.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { spawn } from "node:child_process"; -import type { ProgressReporter, WorktreeAgentInput, WorktreeContext } from "./types.ts"; - -const MAX_CAPTURED_STDERR = 50 * 1024; - -interface WorkerModel { - provider: string; - id: string; -} - -interface RunWorkerOptions { - input: WorktreeAgentInput; - worktree: WorktreeContext; - model?: WorkerModel; - thinkingLevel: string; - signal?: AbortSignal; - report: ProgressReporter; -} - -function assistantText(message: unknown): string { - if (!message || typeof message !== "object") return ""; - const candidate = message as { - role?: string; - content?: Array<{ type?: string; text?: string }>; - }; - if (candidate.role !== "assistant" || !Array.isArray(candidate.content)) return ""; - return candidate.content - .filter((part) => part.type === "text" && typeof part.text === "string") - .map((part) => part.text) - .join("\n") - .trim(); -} - -function appendCaptured(current: string, addition: string): string { - const combined = current + addition; - return combined.length <= MAX_CAPTURED_STDERR - ? combined - : combined.slice(combined.length - MAX_CAPTURED_STDERR); -} - -function buildWorkerPrompt(input: WorktreeAgentInput, worktree: WorktreeContext): string { - const criteria = input.acceptanceCriteria?.length - ? input.acceptanceCriteria.map((criterion) => `- ${criterion}`).join("\n") - : "- Derive acceptance criteria from the issue description and state any uncertainty."; - - return [ - "/skill:linear-issue-worker", - "", - `Issue ID: ${input.issueId.trim().toUpperCase()}`, - `Title: ${input.title.trim()}`, - input.issueUrl ? `URL: ${input.issueUrl}` : undefined, - "", - "Description:", - input.description.trim() || "(No description supplied.)", - "", - "Acceptance criteria:", - criteria, - "", - `Assigned branch: ${worktree.branchName}`, - `Assigned worktree: ${worktree.worktreePath}`, - ] - .filter((line): line is string => line !== undefined) - .join("\n"); -} - -export async function runWorker(options: RunWorkerOptions): Promise { - const { input, worktree, model, thinkingLevel, signal, report } = options; - const sessionName = `${input.issueId.trim().toUpperCase()} worker`; - const childArgs = ["--mode", "json", "-p", "--approve", "--name", sessionName]; - if (model) childArgs.push("--model", `${model.provider}/${model.id}`); - childArgs.push("--thinking", thinkingLevel, buildWorkerPrompt(input, worktree)); - - report(`Starting child Pi in ${worktree.worktreePath}`, { - phase: "starting-worker", - branchName: worktree.branchName, - worktreePath: worktree.worktreePath, - }); - - let finalText = ""; - let stderr = ""; - let workerStopReason: string | undefined; - let workerError: string | undefined; - - let exitCode: number; - try { - exitCode = await new Promise((resolve, reject) => { - const child = spawn("pi", childArgs, { - cwd: worktree.worktreePath, - env: { - ...process.env, - PI_WORKTREE_AGENT: "1", - PI_WORKTREE_ROOT: worktree.worktreePath, - }, - shell: false, - stdio: ["ignore", "pipe", "pipe"], - }); - - let stdoutBuffer = ""; - let completed = false; - let killTimer: ReturnType | undefined; - - const finish = (callback: () => void) => { - if (completed) return; - completed = true; - if (killTimer) clearTimeout(killTimer); - signal?.removeEventListener("abort", abortChild); - callback(); - }; - - const abortChild = () => { - child.kill("SIGTERM"); - killTimer = setTimeout(() => child.kill("SIGKILL"), 5_000); - }; - - const processLine = (line: string) => { - if (!line.trim()) return; - let event: any; - try { - event = JSON.parse(line); - } catch { - return; - } - - if (event.type === "message_end") { - const text = assistantText(event.message); - if (text) finalText = text; - if (event.message?.role === "assistant") { - workerStopReason = event.message.stopReason; - workerError = event.message.errorMessage; - } - } else if (event.type === "tool_execution_start") { - report(`Worker running: ${event.toolName}`, { - phase: "worker-running", - branchName: worktree.branchName, - worktreePath: worktree.worktreePath, - toolName: event.toolName, - }); - } - }; - - child.stdout.on("data", (chunk) => { - stdoutBuffer += chunk.toString(); - const lines = stdoutBuffer.split("\n"); - stdoutBuffer = lines.pop() ?? ""; - for (const line of lines) processLine(line.endsWith("\r") ? line.slice(0, -1) : line); - }); - child.stderr.on("data", (chunk) => { - stderr = appendCaptured(stderr, chunk.toString()); - }); - child.on("error", (error) => finish(() => reject(error))); - child.on("close", (code) => - finish(() => { - if (stdoutBuffer.trim()) processLine(stdoutBuffer); - resolve(code ?? 1); - }), - ); - - if (signal?.aborted) abortChild(); - else signal?.addEventListener("abort", abortChild, { once: true }); - }); - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - throw new Error(`Unable to run child Pi: ${detail}\nWorktree retained at ${worktree.worktreePath}`); - } - - if (signal?.aborted) { - throw new Error(`Worker aborted. Worktree retained at ${worktree.worktreePath}`); - } - if (exitCode !== 0 || workerStopReason === "error" || workerStopReason === "aborted") { - const detail = workerError || stderr.trim() || finalText || `exit code ${exitCode}`; - throw new Error(`Worker failed: ${detail}\nWorktree retained at ${worktree.worktreePath}`); - } - - return finalText || "Worker exited successfully without a final text report."; -} diff --git a/config/pi/extensions/worktree-agent/worktree.ts b/config/pi/extensions/worktree-agent/worktree.ts deleted file mode 100644 index ffd9fbe..0000000 --- a/config/pi/extensions/worktree-agent/worktree.ts +++ /dev/null @@ -1,93 +0,0 @@ -import * as fs from "node:fs"; -import * as path from "node:path"; -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import type { ProgressReporter, WorktreeAgentInput, WorktreeContext } from "./types.ts"; - -type ExecResult = { stdout: string; stderr: string; code: number | null }; - -async function git( - pi: ExtensionAPI, - cwd: string, - args: string[], - options: { allowFailure?: boolean; timeout?: number } = {}, -): Promise { - const result = await pi.exec("git", ["-C", cwd, ...args], { timeout: options.timeout ?? 30_000 }); - if (!options.allowFailure && result.code !== 0) { - const detail = (result.stderr || result.stdout).trim(); - throw new Error(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`); - } - return result; -} - -function slugify(title: string): string { - const slug = title - .normalize("NFKD") - .replace(/[\u0300-\u036f]/g, "") - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 48) - .replace(/-+$/g, ""); - - return slug || "work"; -} - -export async function prepareWorktree( - pi: ExtensionAPI, - cwd: string, - input: WorktreeAgentInput, - report: ProgressReporter, -): Promise { - if (!/^[A-Za-z][A-Za-z0-9]*-\d+$/.test(input.issueId.trim())) { - throw new Error(`Invalid Linear issue identifier: ${input.issueId}`); - } - - const repoResult = await git(pi, cwd, ["rev-parse", "--show-toplevel"]); - const repoRoot = fs.realpathSync(repoResult.stdout.trim()); - const statusBefore = await git(pi, repoRoot, ["status", "--porcelain=v1", "--untracked-files=all"]); - if (statusBefore.stdout.trim()) { - throw new Error("The parent working tree is not clean. Commit, stash, or remove local changes first."); - } - - const issueId = input.issueId.trim().toLowerCase(); - const branchName = `issue/${issueId}-${slugify(input.title)}`; - const worktreePath = path.resolve(repoRoot, "..", "worktrees", branchName); - const baseRef = input.baseRef?.trim() || "HEAD"; - - const branchExists = await git( - pi, - repoRoot, - ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], - { allowFailure: true }, - ); - if (branchExists.code === 0) { - throw new Error(`Branch already exists: ${branchName}`); - } - if (fs.existsSync(worktreePath)) { - throw new Error(`Worktree path already exists: ${worktreePath}`); - } - - const baseExists = await git(pi, repoRoot, ["rev-parse", "--verify", `${baseRef}^{commit}`], { - allowFailure: true, - }); - if (baseExists.code !== 0) { - throw new Error(`Base ref does not resolve to a commit: ${baseRef}`); - } - - fs.mkdirSync(path.dirname(worktreePath), { recursive: true }); - report(`Creating ${branchName} at ${worktreePath}`, { - phase: "creating-worktree", - branchName, - worktreePath, - }); - await git(pi, repoRoot, ["worktree", "add", "-b", branchName, worktreePath, baseRef], { - timeout: 60_000, - }); - - return { repoRoot, branchName, worktreePath, baseRef }; -} - -export async function isParentClean(pi: ExtensionAPI, repoRoot: string): Promise { - const status = await git(pi, repoRoot, ["status", "--porcelain=v1", "--untracked-files=all"]); - return !status.stdout.trim(); -} diff --git a/config/pi/prompts/issue.md b/config/pi/prompts/issue.md deleted file mode 100644 index adedd8c..0000000 --- a/config/pi/prompts/issue.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -description: Implement a Linear issue in an isolated Git worktree using a child Pi agent -argument-hint: "" ---- -Work on Linear issue: $ARGUMENTS - -Act only as the parent orchestrator: - -1. Validate that the argument identifies exactly one Linear issue. -2. Retrieve the issue through the Linear MCP server. Include relations, comments, attachments, or linked context when they affect implementation. -3. Read and understand the issue before starting the worker. -4. Extract its title, description, URL, and explicit or reasonably inferred acceptance criteria. -5. Call `run_issue_in_worktree` exactly once with that structured issue context. -6. Let the child Pi agent perform every repository modification, validation command, and local commit. -7. The child must not push or create a pull request; publication happens only after explicit user review. -8. Do not edit files or run implementation commands in the parent checkout. -9. When the child finishes, summarize its status, branch, worktree path, local commit, checks, blockers, and the command for resuming its Pi session. - -Stop and explain the problem instead of starting the worker when the issue cannot be retrieved or its requirements are too ambiguous to delegate safely. diff --git a/config/pi/skills/linear-issue-worker/SKILL.md b/config/pi/skills/linear-issue-worker/SKILL.md deleted file mode 100644 index f448b6b..0000000 --- a/config/pi/skills/linear-issue-worker/SKILL.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -name: linear-issue-worker -description: Internal workflow for a child Pi agent assigned to implement one Linear issue inside a pre-created Git worktree. Invoke explicitly from the worktree orchestrator; do not use for ordinary parent-agent tasks. -compatibility: Requires Git, a pre-created worktree, and the PI_WORKTREE_ROOT environment variable. -disable-model-invocation: true ---- - -# Linear issue worker - -You are the implementation worker for one Linear issue. The parent agent has already created the branch and worktree and supplied the issue context. - -## Establish the boundary - -1. Read `PI_WORKTREE_ROOT` and resolve it to an absolute canonical path. -2. Confirm `git rev-parse --show-toplevel` resolves to exactly that path. -3. Confirm the current branch matches the assigned branch in the task. -4. Stop as `blocked` if any boundary check fails. -5. Keep all file and shell operations inside this worktree. Do not inspect or modify sibling worktrees or the parent checkout. -6. Do not create another branch or worktree, and do not remove this worktree. - -The harness blocks Pi's path-based file tools outside the assigned worktree, but shell access is not an OS security boundary. Treat the worktree restriction as an explicit safety requirement. - -## Understand the repository and issue - -1. Read all applicable `AGENTS.md` or `CLAUDE.md` instructions in and above the repository. -2. Inspect repository documentation and manifests to discover its conventions and validation commands. -3. Read the supplied issue description and acceptance criteria carefully. -4. Use the inherited Linear MCP tools if essential issue details, comments, relations, or attachments are missing. -5. Identify uncertainty before changing code. If safe implementation requires user input, stop and report `blocked` rather than guessing. -6. Check that the worktree starts clean. - -## Implement - -1. Inspect the relevant code before editing. -2. State a brief implementation plan in the conversation, then continue without waiting unless blocked. -3. Make the smallest coherent change that satisfies the issue. -4. Add or update tests for changed behavior where appropriate. -5. Avoid unrelated refactors, formatting churn, generated artifacts, and dependency changes. -6. Re-read affected code and consider error handling, compatibility, and edge cases. - -## Validate - -Discover and run the repository's applicable checks, normally including: - -- formatter or formatting check -- linter -- type checker -- focused tests -- broader tests when practical - -Fix failures caused by your changes. Clearly distinguish pre-existing or environmental failures from regressions. Never claim a command passed unless you ran it and observed success. - -Before committing: - -1. Inspect `git status --short`. -2. Review the complete diff against the assigned base. -3. Remove accidental or unrelated changes. -4. Confirm no secrets or local-only files are included. - -## Create a local review checkpoint - -1. Commit the changes locally with a message beginning with the uppercase issue ID, for example `ENG-123: add request throttling`. -2. Record the resulting commit hash. -3. Do not push the branch. -4. Do not create a pull request. -5. Leave the worktree and child Pi session intact so the user can inspect, amend, or extend the implementation. - -Do not amend unrelated commits, rewrite shared history, update the base branch, or clean up the worktree. - -## Final report - -End with this structure: - -```text -Status: ready-for-review | blocked | failed -Issue: -Branch: -Commit: -Worktree: -Resume: cd && PI_WORKTREE_AGENT=1 PI_WORKTREE_ROOT="$PWD" pi -c -Published: no - -Summary: -- - -Checks: -- : passed | failed | not run — - -Risks and follow-up: -- - -Blocker: -- -``` - -Use `ready-for-review` only when the requested implementation is committed locally and ready for user inspection. Use `blocked` when requirements, repository state, validation, or local commit creation prevent safe completion. Use `failed` for an unrecoverable implementation failure. Never describe the work as pushed or published. diff --git a/config/pi/skills/linear-issue-worker/evals/evals.json b/config/pi/skills/linear-issue-worker/evals/evals.json deleted file mode 100644 index 7428a43..0000000 --- a/config/pi/skills/linear-issue-worker/evals/evals.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "skill_name": "linear-issue-worker", - "evals": [ - { - "id": 1, - "prompt": "Implement ENG-123 from the supplied issue context in the assigned worktree. The repository is a TypeScript service with npm scripts for format, lint, typecheck, and test.", - "expected_output": "The worker verifies its worktree boundary, inspects instructions and code, plans, implements, runs discovered checks, reviews the diff, creates a local commit, does not push or create a PR, and reports ready-for-review with a resume command.", - "files": [] - }, - { - "id": 2, - "prompt": "Implement ENG-456, but PI_WORKTREE_ROOT does not match the current Git root.", - "expected_output": "The worker makes no changes and returns a structured blocked report describing the boundary mismatch.", - "files": [] - }, - { - "id": 3, - "prompt": "Implement ENG-789 in a valid worktree whose origin push URL is git@codeberg.org:example/service.git. Forgejo CLI authentication is available.", - "expected_output": "The worker ignores publication tooling for this phase, creates only a local commit, does not push to Codeberg, and reports ready-for-review with Published: no.", - "files": [] - }, - { - "id": 4, - "prompt": "Implement ENG-999 in a valid worktree. The user expects to make amendments with Pi after the initial implementation.", - "expected_output": "The worker creates a local review checkpoint, leaves the worktree intact, does not publish anything, and reports the exact worktree and pi -c command for continuing the saved child session.", - "files": [] - } - ] -} diff --git a/module/home-manager.nix b/module/home-manager.nix index fc1513f..0da79f3 100644 --- a/module/home-manager.nix +++ b/module/home-manager.nix @@ -103,13 +103,6 @@ in enable = true; skills = [ (inputs.anthropic-skills + "/skills/skill-creator") - ../config/pi/skills/linear-issue-worker - ]; - extensions = [ - ../config/pi/extensions/worktree-agent - ]; - promptTemplates = [ - ../config/pi/prompts/issue.md ]; environment = { FIRECRAWL_API_KEY.file = config.sops.secrets.firecrawl_api_key.path;