import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { isSubagentSessionKey } from "../routing/session-key.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { resolveUserPath } from "../utils.js"; export function resolveDefaultAgentWorkspaceDir( env: NodeJS.ProcessEnv = process.env, homedir: () => string = os.homedir, ): string { const profile = env.CLAWDBOT_PROFILE?.trim(); if (profile && profile.toLowerCase() !== "default") { return path.join(homedir(), `clawd-${profile}`); } return path.join(homedir(), "clawd"); } export const DEFAULT_AGENT_WORKSPACE_DIR = resolveDefaultAgentWorkspaceDir(); export const DEFAULT_AGENTS_FILENAME = "AGENTS.md"; export const DEFAULT_SOUL_FILENAME = "SOUL.md"; export const DEFAULT_TOOLS_FILENAME = "TOOLS.md"; export const DEFAULT_IDENTITY_FILENAME = "IDENTITY.md"; export const DEFAULT_USER_FILENAME = "USER.md"; export const DEFAULT_HEARTBEAT_FILENAME = "HEARTBEAT.md"; export const DEFAULT_BOOTSTRAP_FILENAME = "BOOTSTRAP.md"; const TEMPLATE_DIR = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "../../docs/reference/templates", ); function stripFrontMatter(content: string): string { if (!content.startsWith("---")) return content; const endIndex = content.indexOf("\n---", 3); if (endIndex === -1) return content; const start = endIndex + "\n---".length; let trimmed = content.slice(start); trimmed = trimmed.replace(/^\s+/, ""); return trimmed; } async function loadTemplate(name: string): Promise { const templatePath = path.join(TEMPLATE_DIR, name); try { const content = await fs.readFile(templatePath, "utf-8"); return stripFrontMatter(content); } catch { throw new Error( `Missing workspace template: ${name} (${templatePath}). Ensure docs/reference/templates are packaged.`, ); } } export type WorkspaceBootstrapFileName = | typeof DEFAULT_AGENTS_FILENAME | typeof DEFAULT_SOUL_FILENAME | typeof DEFAULT_TOOLS_FILENAME | typeof DEFAULT_IDENTITY_FILENAME | typeof DEFAULT_USER_FILENAME | typeof DEFAULT_HEARTBEAT_FILENAME | typeof DEFAULT_BOOTSTRAP_FILENAME; export type WorkspaceBootstrapFile = { name: WorkspaceBootstrapFileName; path: string; content?: string; missing: boolean; }; async function writeFileIfMissing(filePath: string, content: string) { try { await fs.writeFile(filePath, content, { encoding: "utf-8", flag: "wx", }); } catch (err) { const anyErr = err as { code?: string }; if (anyErr.code !== "EEXIST") throw err; } } async function hasGitRepo(dir: string): Promise { try { await fs.stat(path.join(dir, ".git")); return true; } catch { return false; } } async function isGitAvailable(): Promise { try { const result = await runCommandWithTimeout(["git", "--version"], { timeoutMs: 2_000 }); return result.code === 0; } catch { return false; } } async function ensureGitRepo(dir: string, isBrandNewWorkspace: boolean) { if (!isBrandNewWorkspace) return; if (await hasGitRepo(dir)) return; if (!(await isGitAvailable())) return; try { await runCommandWithTimeout(["git", "init"], { cwd: dir, timeoutMs: 10_000 }); } catch { // Ignore git init failures; workspace creation should still succeed. } } export async function ensureAgentWorkspace(params?: { dir?: string; ensureBootstrapFiles?: boolean; }): Promise<{ dir: string; agentsPath?: string; soulPath?: string; toolsPath?: string; identityPath?: string; userPath?: string; heartbeatPath?: string; bootstrapPath?: string; }> { const rawDir = params?.dir?.trim() ? params.dir.trim() : DEFAULT_AGENT_WORKSPACE_DIR; const dir = resolveUserPath(rawDir); await fs.mkdir(dir, { recursive: true }); if (!params?.ensureBootstrapFiles) return { dir }; const agentsPath = path.join(dir, DEFAULT_AGENTS_FILENAME); const soulPath = path.join(dir, DEFAULT_SOUL_FILENAME); const toolsPath = path.join(dir, DEFAULT_TOOLS_FILENAME); const identityPath = path.join(dir, DEFAULT_IDENTITY_FILENAME); const userPath = path.join(dir, DEFAULT_USER_FILENAME); const heartbeatPath = path.join(dir, DEFAULT_HEARTBEAT_FILENAME); const bootstrapPath = path.join(dir, DEFAULT_BOOTSTRAP_FILENAME); const isBrandNewWorkspace = await (async () => { const paths = [agentsPath, soulPath, toolsPath, identityPath, userPath, heartbeatPath]; const existing = await Promise.all( paths.map(async (p) => { try { await fs.access(p); return true; } catch { return false; } }), ); return existing.every((v) => !v); })(); const agentsTemplate = await loadTemplate(DEFAULT_AGENTS_FILENAME); const soulTemplate = await loadTemplate(DEFAULT_SOUL_FILENAME); const toolsTemplate = await loadTemplate(DEFAULT_TOOLS_FILENAME); const identityTemplate = await loadTemplate(DEFAULT_IDENTITY_FILENAME); const userTemplate = await loadTemplate(DEFAULT_USER_FILENAME); const heartbeatTemplate = await loadTemplate(DEFAULT_HEARTBEAT_FILENAME); const bootstrapTemplate = await loadTemplate(DEFAULT_BOOTSTRAP_FILENAME); await writeFileIfMissing(agentsPath, agentsTemplate); await writeFileIfMissing(soulPath, soulTemplate); await writeFileIfMissing(toolsPath, toolsTemplate); await writeFileIfMissing(identityPath, identityTemplate); await writeFileIfMissing(userPath, userTemplate); await writeFileIfMissing(heartbeatPath, heartbeatTemplate); if (isBrandNewWorkspace) { await writeFileIfMissing(bootstrapPath, bootstrapTemplate); } await ensureGitRepo(dir, isBrandNewWorkspace); return { dir, agentsPath, soulPath, toolsPath, identityPath, userPath, heartbeatPath, bootstrapPath, }; } export async function loadWorkspaceBootstrapFiles(dir: string): Promise { const resolvedDir = resolveUserPath(dir); const entries: Array<{ name: WorkspaceBootstrapFileName; filePath: string; }> = [ { name: DEFAULT_AGENTS_FILENAME, filePath: path.join(resolvedDir, DEFAULT_AGENTS_FILENAME), }, { name: DEFAULT_SOUL_FILENAME, filePath: path.join(resolvedDir, DEFAULT_SOUL_FILENAME), }, { name: DEFAULT_TOOLS_FILENAME, filePath: path.join(resolvedDir, DEFAULT_TOOLS_FILENAME), }, { name: DEFAULT_IDENTITY_FILENAME, filePath: path.join(resolvedDir, DEFAULT_IDENTITY_FILENAME), }, { name: DEFAULT_USER_FILENAME, filePath: path.join(resolvedDir, DEFAULT_USER_FILENAME), }, { name: DEFAULT_HEARTBEAT_FILENAME, filePath: path.join(resolvedDir, DEFAULT_HEARTBEAT_FILENAME), }, { name: DEFAULT_BOOTSTRAP_FILENAME, filePath: path.join(resolvedDir, DEFAULT_BOOTSTRAP_FILENAME), }, ]; const result: WorkspaceBootstrapFile[] = []; for (const entry of entries) { try { const content = await fs.readFile(entry.filePath, "utf-8"); result.push({ name: entry.name, path: entry.filePath, content, missing: false, }); } catch { result.push({ name: entry.name, path: entry.filePath, missing: true }); } } return result; } const SUBAGENT_BOOTSTRAP_ALLOWLIST = new Set([DEFAULT_AGENTS_FILENAME, DEFAULT_TOOLS_FILENAME]); export function filterBootstrapFilesForSession( files: WorkspaceBootstrapFile[], sessionKey?: string, ): WorkspaceBootstrapFile[] { if (!sessionKey || !isSubagentSessionKey(sessionKey)) return files; return files.filter((file) => SUBAGENT_BOOTSTRAP_ALLOWLIST.has(file.name)); } export type RepoContext = { owner: string; name: string; branch: string; }; /** * Ensures a GitHub repository is cloned and checked out to the specified branch. * Uses `gh` CLI for authentication-aware cloning. * Returns the working directory path. */ export async function ensureRepoWorkspace(params: { repoContext: RepoContext; baseDir?: string; }): Promise<{ dir: string; cloned: boolean; checkedOut: boolean }> { const { repoContext, baseDir } = params; const { owner, name, branch } = repoContext; const workspacesBase = baseDir || path.join(resolveDefaultAgentWorkspaceDir(), ".workspaces"); await fs.mkdir(workspacesBase, { recursive: true }); const repoDir = path.join(workspacesBase, owner, name); let cloned = false; let checkedOut = false; // Check if repo already exists const repoExists = await hasGitRepo(repoDir); if (!repoExists) { // Clone the repo using gh CLI await fs.mkdir(path.dirname(repoDir), { recursive: true }); const cloneUrl = `https://github.com/${owner}/${name}.git`; try { // Try gh clone first (has auth) const ghResult = await runCommandWithTimeout(["gh", "repo", "clone", `${owner}/${name}`, repoDir], { timeoutMs: 120_000, }); if (ghResult.code !== 0) { // Fallback to git clone (for public repos) const gitResult = await runCommandWithTimeout(["git", "clone", cloneUrl, repoDir], { timeoutMs: 120_000, }); if (gitResult.code !== 0) { throw new Error(`Failed to clone ${owner}/${name}: ${gitResult.stderr || "unknown error"}`); } } cloned = true; } catch (err) { throw new Error(`Failed to clone repository ${owner}/${name}: ${String(err)}`); } } // Fetch latest changes try { await runCommandWithTimeout(["git", "fetch", "--all"], { cwd: repoDir, timeoutMs: 60_000, }); } catch { // Fetch failures are non-fatal; we can work with local state } // Check current branch const currentBranchResult = await runCommandWithTimeout(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd: repoDir, timeoutMs: 5_000, }); const currentBranch = (currentBranchResult.stdout || "").trim(); if (currentBranch !== branch) { // Try to checkout the branch const checkoutResult = await runCommandWithTimeout(["git", "checkout", branch], { cwd: repoDir, timeoutMs: 30_000, }); if (checkoutResult.code !== 0) { // Branch might not exist locally, try to create from remote const checkoutRemoteResult = await runCommandWithTimeout( ["git", "checkout", "-b", branch, `origin/${branch}`], { cwd: repoDir, timeoutMs: 30_000 }, ); if (checkoutRemoteResult.code !== 0) { throw new Error(`Failed to checkout branch ${branch}: branch not found locally or on remote`); } } checkedOut = true; } return { dir: repoDir, cloned, checkedOut }; }