UNPKG

@tanstack/ai-sandbox

Version:

Provider-agnostic sandbox layer for TanStack AI — run harness adapters inside isolated sandboxes (defineSandbox, defineWorkspace, withSandbox) with a uniform SandboxHandle, workspace bootstrap, policy, and resumable lifecycle.

140 lines (129 loc) 5.26 kB
/** * Universal AGENTS.md writer with per-CLI symlink projection, plus the * canonical helper for locating cloned gitSkill repositories inside a sandbox. * * The known-names set below lists the canonical instruction-file names for * each AI coding assistant CLI. Keep the list in one place so it is easy to * extend. The copy fallback ensures correctness on platforms without symlink * support (e.g. Windows). * * External per-CLI convention: each assistant looks for its own instruction * file by name (CLAUDE.md for Claude Code, GEMINI.md for Gemini CLI, …). * We write a single authoritative AGENTS.md and point each name at it. */ import { walkSkillDirs } from '@tanstack/ai-skills' import type { SandboxHandle } from './contracts' import type { WorkspaceSkill } from './workspace' /** CLI instruction-file names that should resolve to AGENTS.md. */ const SYMLINK_NAMES: ReadonlyArray<string> = ['CLAUDE.md', 'GEMINI.md'] /** * Resolve the directory a `gitSkill` repo is cloned into when no explicit * `into` override is provided. The convention is: * * `<root>/.tanstack-skills/<basename>` * * where `basename` is derived from the `repo` field by taking the last * path segment and stripping a trailing `.git` suffix. * * Per-harness projectors (e.g. the Claude Code adapter) import this helper * so they can locate cloned skill repos consistently. * * @param root - Workspace root inside the sandbox (e.g. `/workspace`). * @param skill - A `WorkspaceSkill` of `kind === 'git'`. */ export function resolveGitSkillDir( root: string, skill: Extract<WorkspaceSkill, { kind: 'git' }>, ): string { const rawBasename = skill.repo.split('/').pop() ?? skill.repo const basename = rawBasename.endsWith('.git') ? rawBasename.slice(0, -4) : rawBasename return `${root}/.tanstack-skills/${basename}` } /** A folder that contains `SKILL.md`, ready to project under a harness skills dir. */ export interface DiscoveredSkillDir { name: string dir: string } function basenameOf(path: string): string { const segments = path.split('/').filter((segment) => segment !== '') return segments[segments.length - 1] ?? path } /** * Find every skill folder under a cloned `gitSkill` repo. * * A skill folder is a directory that contains `SKILL.md`. Nested packs * (`skills/foo/SKILL.md`) are returned as `{ name: 'foo', dir: '…/skills/foo' }`. * A flat clone with `SKILL.md` at the root is returned as one entry named * after the clone. If no `SKILL.md` is found, the clone itself is returned * so existing basename projection still works. * * The tree walk itself is the shared `walkSkillDirs` from `@tanstack/ai-skills` * (parameterized over an injected lister — here `handle.fs.list`). The * empty→clone-dir fallback is kept here because it is correct for harness * projection but wrong for a skills catalog, so it must not live in the shared * helper. */ export async function discoverSkillDirs( handle: SandboxHandle, cloneDir: string, ): Promise<Array<DiscoveredSkillDir>> { const found = await walkSkillDirs((dir) => handle.fs.list(dir), cloneDir) if (found.length === 0) { return [{ name: basenameOf(cloneDir), dir: cloneDir }] } return found } /** Format workspace scripts as a `## Workspace scripts` markdown section. */ export function formatWorkspaceScriptsSection( scripts: Record<string, string>, ): string { const names = Object.keys(scripts).sort() if (names.length === 0) return '' const lines = names.map((name) => `- ${name}${scripts[name]}`) return `## Workspace scripts\n\n${lines.join('\n')}` } /** * Merge base AGENTS.md content with an optional workspace scripts section. * Returns `undefined` when there is nothing to write. */ export function mergeAgentsContent( base: string | undefined, scripts: Record<string, string> | undefined, ): string | undefined { const scriptsSection = scripts !== undefined ? formatWorkspaceScriptsSection(scripts) : '' if (base === undefined && scriptsSection.length === 0) return undefined if (base === undefined) return scriptsSection if (scriptsSection.length === 0) return base return `${base.trimEnd()}\n\n${scriptsSection}` } /** Escape a string for safe use as a single-quoted shell argument. */ function sqEscape(value: string): string { return value.replace(/'/g, `'\\''`) } /** * Write `AGENTS.md` under `root` and create per-CLI symlinks (or copies as a * fallback when `ln -s` is unavailable). * * @param handle - The sandbox handle providing `fs` and `process`. * @param root - Absolute path inside the sandbox under which to write. * @param content - Markdown content for the instruction file. */ export async function writeAgentsFile( handle: SandboxHandle, root: string, content: string, ): Promise<void> { const agentsPath = `${root}/AGENTS.md` await handle.fs.write(agentsPath, content) for (const name of SYMLINK_NAMES) { const lnCmd = `ln -s '${sqEscape('AGENTS.md')}' '${sqEscape(name)}'` const result = await handle.process.exec(lnCmd, { cwd: root }) if (result.exitCode !== 0) { // Symlinks are not supported on this platform — fall back to a copy. await handle.fs.write(`${root}/${name}`, content) } } }