diff --git a/README.md b/README.md index d20463c..9048b38 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,20 @@ npm run check The repository is an npm workspace. The root package is private; each package under [`packages/`](packages) is independently publishable. +### Isolated Pi test + +Validate or drive the complete monorepo without loading or changing live Pi +configuration: + +```bash +scripts/isolated-pi.sh --check +scripts/isolated-pi.sh +``` + +The launcher creates a temporary HOME, copies the current Pi auth and optional +keybindings files with mode `600`, loads only this local package, and deletes +the sandbox on exit. + ## License The root [MIT license](LICENSE) covers Iury-owned packages. `pi-ext` and diff --git a/scripts/check-isolated-pi.mjs b/scripts/check-isolated-pi.mjs new file mode 100755 index 0000000..5f4db1c --- /dev/null +++ b/scripts/check-isolated-pi.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { DefaultResourceLoader, SettingsManager } from "@earendil-works/pi-coding-agent"; + +const repoRoot = fs.realpathSync(process.argv[2] || "."); +const cwd = fs.realpathSync(process.argv[3] || process.cwd()); +const agentDir = process.env.PI_CODING_AGENT_DIR; + +if (!agentDir) throw new Error("PI_CODING_AGENT_DIR is required"); + +const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")); +const settingsManager = SettingsManager.create(cwd, agentDir); +const loader = new DefaultResourceLoader({ + cwd, + agentDir, + settingsManager, + noContextFiles: true, +}); + +await loader.reload(); + +const loaded = loader.getExtensions(); +const skills = loader.getSkills(); +const themes = loader.getThemes(); +const diagnostics = [...skills.diagnostics, ...loader.getPrompts().diagnostics, ...themes.diagnostics] + .filter((item) => item.type === "error" || item.type === "collision"); +const failures = []; + +for (const error of loaded.errors) failures.push(`${error.path}: ${error.error}`); +for (const diagnostic of diagnostics) failures.push(`${diagnostic.path || "resource"}: ${diagnostic.message}`); + +if (loaded.extensions.length !== manifest.pi.extensions.length) { + failures.push(`expected ${manifest.pi.extensions.length} extensions, loaded ${loaded.extensions.length}`); +} + +for (const extension of loaded.extensions) { + const relative = path.relative(repoRoot, fs.realpathSync(extension.resolvedPath)); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + failures.push(`extension loaded outside monorepo: ${extension.resolvedPath}`); + } +} + +if (skills.skills.length !== manifest.pi.skills.length) { + failures.push(`expected ${manifest.pi.skills.length} skills, loaded ${skills.skills.length}`); +} + +if (failures.length > 0) { + throw new Error(`Isolated Pi check failed:\n- ${failures.join("\n- ")}`); +} + +console.log("Isolated Pi check passed."); +console.log(`Extensions: ${loaded.extensions.length}`); +console.log(`Skills: ${skills.skills.map((skill) => skill.name).sort().join(", ")}`); +console.log(`Themes: ${themes.themes.length}`); diff --git a/scripts/isolated-pi.sh b/scripts/isolated-pi.sh new file mode 100755 index 0000000..7c025a8 --- /dev/null +++ b/scripts/isolated-pi.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +real_home="${HOME:?HOME is required}" +mode="tui" + +case "${1:-}" in + --check) + mode="check" + shift + ;; + --help|-h) + cat <<'EOF' +Usage: scripts/isolated-pi.sh [--check] [pi arguments...] + +Without --check, starts an interactive Pi TUI with only this monorepo loaded. +The temporary HOME, copied auth, settings, sessions, and workspace are deleted +when the command exits. +EOF + exit 0 + ;; +esac + +for command in jq node pi; do + if ! command -v "$command" >/dev/null 2>&1; then + printf 'Missing required command: %s\n' "$command" >&2 + exit 1 + fi +done + +live_agent_dir="$real_home/.pi/agent" +live_settings="$live_agent_dir/settings.json" +live_auth="$live_agent_dir/auth.json" +live_keybindings="$live_agent_dir/keybindings.json" + +if [[ ! -f "$live_settings" ]]; then + printf 'Missing live Pi settings: %s\n' "$live_settings" >&2 + exit 1 +fi +if [[ ! -f "$live_auth" ]]; then + printf 'Missing live Pi auth: %s\n' "$live_auth" >&2 + exit 1 +fi + +sandbox="$(mktemp -d "${TMPDIR:-/tmp}/pi-extensions-isolated.XXXXXX")" +cleanup() { + chmod -R u+w "$sandbox" 2>/dev/null || true + rm -rf "$sandbox" +} +trap cleanup EXIT + +sandbox_home="$sandbox/home" +agent_dir="$sandbox_home/.pi/agent" +workspace="$sandbox/workspace" +mkdir -p "$agent_dir" "$workspace" \ + "$sandbox_home/.cache" \ + "$sandbox_home/.config" \ + "$sandbox_home/.local/share" \ + "$sandbox_home/.local/state" +install -m 600 "$live_auth" "$agent_dir/auth.json" +if [[ -f "$live_keybindings" ]]; then + install -m 600 "$live_keybindings" "$agent_dir/keybindings.json" +fi + +jq --arg source "$root" ' + { + packages: [{ source: $source }], + defaultProvider, + defaultModel, + defaultThinkingLevel, + theme, + hideThinkingBlock, + quietStartup, + transport, + lastChangelogVersion + } + | with_entries(select(.value != null)) +' "$live_settings" > "$agent_dir/settings.json" +chmod 600 "$agent_dir/settings.json" + +sandbox_env=( + "HOME=$sandbox_home" + "PI_CODING_AGENT_DIR=$agent_dir" + "XDG_CACHE_HOME=$sandbox_home/.cache" + "XDG_CONFIG_HOME=$sandbox_home/.config" + "XDG_DATA_HOME=$sandbox_home/.local/share" + "XDG_STATE_HOME=$sandbox_home/.local/state" +) + +printf 'Isolated package: %s\n' "$root" +printf 'Disposable HOME: %s\n' "$sandbox_home" + +if [[ "$mode" == "check" ]]; then + env "${sandbox_env[@]}" node "$root/scripts/check-isolated-pi.mjs" "$root" "$workspace" +else + cd "$workspace" + env "${sandbox_env[@]}" pi --no-context-files "$@" +fi