From df81aa8a72b7ae254e5561c6e951b99b50bdbac5 Mon Sep 17 00:00:00 2001 From: douglance <4741454+douglance@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:18:18 -0400 Subject: [PATCH] Add "aperture skills install" to generate agent skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coding agents can load skills from a shared directory, but nothing described this launcher to them. This adds a subcommand that generates skills from the launcher's own state and installs them for the agents present on the machine. Five skills, split by component, so an agent loads only the part it needs: the launcher itself, agents, endpoints, bridges, and troubleshooting. Content is generated rather than checked in — the agent table comes from the client registry, and the endpoint and config paths come from the loaded config, so a skill cannot describe an agent this build does not support. Installing writes to ~/.agents/skills, which other tools also write to, so every destructive step is guarded. Each directory gets an ownership marker, and one is only replaced when it carries our own marker. Directories belonging to another tool, and unmarked ones a user wrote by hand, are reported as conflicts and left alone; -force overrides. Conflicts across all five skills are checked before the first write, so a conflict in the last cannot leave the first four applied. Pruning a renamed skill follows the same rule: only our own marked directories are removed. The subcommand is dispatched before flag.Parse, leaving the TUI path untouched. --- cmd/aperture/main.go | 6 + cmd/aperture/skills.go | 115 +++++++++ internal/skills/content.go | 354 ++++++++++++++++++++++++++ internal/skills/content_test.go | 222 +++++++++++++++++ internal/skills/skills.go | 426 ++++++++++++++++++++++++++++++++ internal/skills/skills_test.go | 308 +++++++++++++++++++++++ 6 files changed, 1431 insertions(+) create mode 100644 cmd/aperture/skills.go create mode 100644 internal/skills/content.go create mode 100644 internal/skills/content_test.go create mode 100644 internal/skills/skills.go create mode 100644 internal/skills/skills_test.go diff --git a/cmd/aperture/main.go b/cmd/aperture/main.go index cdc5327..5854033 100644 --- a/cmd/aperture/main.go +++ b/cmd/aperture/main.go @@ -109,6 +109,12 @@ func gitCommitHeightInDir(dir string) string { } func main() { + // Subcommands are dispatched before flag.Parse so their own flag sets + // own everything after the subcommand name. + if len(os.Args) > 1 && os.Args[1] == "skills" { + os.Exit(runSkills(os.Args[2:])) + } + flag.Parse() if *flagVersion { diff --git a/cmd/aperture/skills.go b/cmd/aperture/skills.go new file mode 100644 index 0000000..e7a3b3f --- /dev/null +++ b/cmd/aperture/skills.go @@ -0,0 +1,115 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "path/filepath" + + "github.com/tailscale/aperture-cli/internal/clients" + "github.com/tailscale/aperture-cli/internal/config" + "github.com/tailscale/aperture-cli/internal/skills" +) + +// runSkills handles "aperture skills " and returns the process exit +// code. +func runSkills(args []string) int { + if len(args) == 0 { + skillsUsage() + return 2 + } + + switch args[0] { + case "install": + return runSkillsInstall(args[1:]) + case "-h", "--help", "help": + skillsUsage() + return 0 + default: + fmt.Fprintf(os.Stderr, "aperture skills: unknown subcommand %q\n\n", args[0]) + skillsUsage() + return 2 + } +} + +func skillsUsage() { + fmt.Fprint(os.Stderr, `Usage: aperture skills + +Subcommands: + install Generate the aperture skill and install it for the agents on this machine + +Run "aperture skills install -h" for the install flags. +`) +} + +// runSkillsInstall generates the skill and installs it, reporting what was +// written. +func runSkillsInstall(args []string) int { + fs := flag.NewFlagSet("aperture skills install", flag.ContinueOnError) + project := fs.Bool("project", false, "install into the current directory instead of the home directory") + force := fs.Bool("force", false, "overwrite skill directories owned by another tool") + if err := fs.Parse(args); err != nil { + return 2 + } + + g, err := config.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "loading launcher config: %v\n", err) + return 1 + } + + var agents []skills.AgentInfo + for _, c := range clients.All(g) { + agents = append(agents, skills.AgentInfo{ + Name: c.Name(), + Binary: c.BinaryName(), + InstallHint: c.Install(g).Hint, + }) + } + + // Best effort: the skill falls back to describing the per-OS locations + // when the config directory cannot be resolved. + var configDir string + if dir, err := os.UserConfigDir(); err == nil { + configDir = filepath.Join(dir, "aperture") + } + + res, err := skills.Install(skills.Options{ + Version: buildVersion, + Skills: skills.Content(skills.ContentParams{ + Version: buildVersion, + Endpoint: g.ApertureHost, + Agents: agents, + ConfigDir: configDir, + }), + Project: *project, + Force: *force, + }) + if err != nil { + // A conflict is the expected refusal, not a crash: print it plainly + // so the user can see which directories are in the way. + var conflict *skills.ConflictError + if errors.As(err, &conflict) { + fmt.Fprintln(os.Stderr, conflict.Error()) + return 1 + } + fmt.Fprintf(os.Stderr, "installing skill: %v\n", err) + return 1 + } + + fmt.Printf("Installed %d skills:\n", len(res.Skills)) + for _, s := range res.Skills { + fmt.Printf(" %s\n %s\n", s.Name, s.Canonical) + for _, l := range s.Linked { + fmt.Printf(" linked into %s: %s\n", l.Agent, l.Path) + } + } + for _, path := range res.Pruned { + fmt.Printf(" pruned %s\n", path) + } + for _, c := range res.Overwrote { + fmt.Printf(" overwrote %s (was owned by %s)\n", c.Path, c.Owner) + } + return 0 +} diff --git a/internal/skills/content.go b/internal/skills/content.go new file mode 100644 index 0000000..933fcde --- /dev/null +++ b/internal/skills/content.go @@ -0,0 +1,354 @@ +package skills + +import ( + "fmt" + "strings" +) + +// AgentInfo describes one coding agent the launcher supports. Values come from +// the client registry so the skill lists what this build actually supports +// rather than a hand-maintained copy. +type AgentInfo struct { + // Name is the user-visible agent name. + Name string + + // Binary is the executable looked up on $PATH. Empty for agents that are + // not a CLI binary, such as desktop applications. + Binary string + + // InstallHint is the command the launcher suggests to install the agent. + InstallHint string +} + +// ContentParams describes the launcher to an agent reading the skills. +type ContentParams struct { + // Version is the launcher build version. + Version string + + // Endpoint is the active Aperture endpoint URL. + Endpoint string + + // Agents are the coding agents this launcher can start, in display order. + Agents []AgentInfo + + // ConfigDir is the directory holding settings.json and launcher.json on + // this platform. + ConfigDir string +} + +// Skill is one generated skill: a directory name and the SKILL.md that goes +// in it. +type Skill struct { + // Name is the skill directory name. + Name string + + // Body is the full SKILL.md content, frontmatter included. + Body string +} + +// Skill names. Each covers one component of the launcher, so an agent loads +// only the part it needs rather than the whole manual. +const ( + skillCore = "aperture" + skillAgents = "aperture-agents" + skillEndpoint = "aperture-endpoints" + skillBridges = "aperture-bridges" + skillTrouble = "aperture-troubleshooting" +) + +// allSkillNames is every skill this build generates. Install uses it to +// recognize its own earlier output when pruning. +func allSkillNames() []string { + return []string{skillCore, skillAgents, skillEndpoint, skillBridges, skillTrouble} +} + +// cell escapes a value for use inside a Markdown table, where an unescaped +// pipe would end the cell early. Install hints routinely contain one, as in +// "curl ... | bash". +func cell(v string) string { + return strings.ReplaceAll(v, "|", "\\|") +} + +// frontmatter writes the YAML header that makes a skill discoverable. +func frontmatter(b *strings.Builder, name, description string) { + b.WriteString("---\n") + fmt.Fprintf(b, "name: %s\n", name) + fmt.Fprintf(b, "description: %s\n", description) + b.WriteString("---\n\n") +} + +// footer records which build produced a skill. +func footer(b *strings.Builder, p ContentParams) { + if p.Version != "" { + fmt.Fprintf(b, "\nGenerated by aperture %s.\n", p.Version) + } +} + +// Content renders every skill. +func Content(p ContentParams) []Skill { + return []Skill{ + {Name: skillCore, Body: coreSkill(p)}, + {Name: skillAgents, Body: agentsSkill(p)}, + {Name: skillEndpoint, Body: endpointsSkill(p)}, + {Name: skillBridges, Body: bridgesSkill(p)}, + {Name: skillTrouble, Body: troubleshootingSkill(p)}, + } +} + +// coreSkill covers what the launcher is and how to drive it. +func coreSkill(p ContentParams) string { + var b strings.Builder + + frontmatter(&b, skillCore, + "Launch a coding agent whose model traffic routes through an Aperture AI gateway instead of the agent's own API key. Use when starting an agent through the aperture launcher, or to learn its commands and keys.") + + b.WriteString("# aperture\n\n") + b.WriteString("`aperture` is an interactive launcher. It picks a coding agent, a provider type, and\n") + b.WriteString("a model, sets the environment variables that point the agent at an\n") + b.WriteString("[Aperture](https://aperture.tailscale.com) gateway, and hands the terminal over to\n") + b.WriteString("it. The gateway holds the provider API keys and records usage per user, so the agent\n") + b.WriteString("does not need a key of its own.\n\n") + + if p.Endpoint != "" { + fmt.Fprintf(&b, "Configured endpoint: `%s`\n\n", p.Endpoint) + } + + b.WriteString("## Commands\n\n") + b.WriteString("```sh\n") + b.WriteString("aperture # interactive launcher\n") + b.WriteString("aperture -debug # print env vars before launching an agent\n") + b.WriteString("aperture -version # print the build version\n") + b.WriteString("aperture skills install # regenerate and reinstall these skills\n") + b.WriteString("aperture skills install -project # install into the current directory\n") + b.WriteString("aperture skills install -force # overwrite a skill directory owned by another tool\n") + b.WriteString("```\n\n") + b.WriteString("Install the launcher with `go install github.com/tailscale/aperture-cli/cmd/aperture@latest`\n") + b.WriteString("(needs Go 1.26+, and Go's bin directory on `$PATH`).\n\n") + + b.WriteString("## Keys\n\n") + b.WriteString("| Key | Action |\n") + b.WriteString("| --- | --- |\n") + b.WriteString("| `Up`/`Down` or `j`/`k` | Move the cursor |\n") + b.WriteString("| `Left`/`Right` or `h`/`l` | Move between columns |\n") + b.WriteString("| `Enter` or a number key | Select |\n") + b.WriteString("| `s` | Settings |\n") + b.WriteString("| `i` | Install agents |\n") + b.WriteString("| `q` | Quit |\n") + b.WriteString("| `Ctrl+C` | Quit from any screen |\n\n") + + b.WriteString("`[0]` on the main menu repeats the last launch, skipping the provider and model\n") + b.WriteString("menus. It appears only when that selection is still valid: the agent is installed,\n") + b.WriteString("the provider is still configured, and the model is still listed. A menu with a\n") + b.WriteString("single option is auto-selected.\n\n") + + b.WriteString("## Launch sequence\n\n") + b.WriteString("Choosing an agent, provider type, and model makes the launcher set that provider's\n") + b.WriteString("environment variables, append any path suffix the backend needs (`/v1`, `/bedrock`),\n") + b.WriteString("append the skip-permissions flag when YOLO mode is on, save the combination as\n") + b.WriteString("last-used, and exec the agent. Only one agent runs per session; use another\n") + b.WriteString("terminal for a second.\n\n") + + b.WriteString("## Related skills\n\n") + b.WriteString("- `aperture-agents` — which agents are supported, and what each launch sets\n") + b.WriteString("- `aperture-endpoints` — managing and switching gateway endpoints\n") + b.WriteString("- `aperture-bridges` — reaching a gateway from outside the tailnet\n") + b.WriteString("- `aperture-troubleshooting` — failures, state files, and limits\n") + + footer(&b, p) + return b.String() +} + +// agentsSkill covers the supported agents and what launching one changes. +func agentsSkill(p ContentParams) string { + var b strings.Builder + + frontmatter(&b, skillAgents, + "Which coding agents the aperture launcher supports, the binary and install command for each, the provider types and environment variables a launch sets, YOLO mode, and why a Claude or ChatGPT subscription needs different handling.") + + b.WriteString("# aperture agents\n\n") + + if len(p.Agents) > 0 { + b.WriteString("| Agent | Binary | Install |\n") + b.WriteString("| --- | --- | --- |\n") + for _, a := range p.Agents { + binary := "—" + if a.Binary != "" { + binary = "`" + a.Binary + "`" + } + hint := "—" + if a.InstallHint != "" { + hint = "`" + a.InstallHint + "`" + } + fmt.Fprintf(&b, "| %s | %s | %s |\n", cell(a.Name), cell(binary), cell(hint)) + } + b.WriteString("\n") + } + + b.WriteString("Claude Cowork is a desktop application rather than a CLI, configured on macOS and\n") + b.WriteString("Windows only. It requires an HTTPS endpoint; the launcher rewrites `http://` to\n") + b.WriteString("`https://` for it.\n\n") + + b.WriteString("## Provider types\n\n") + b.WriteString("Which provider types an agent offers depends on what the gateway has configured —\n") + b.WriteString("Anthropic API, Bedrock, Vertex, OpenAI Responses, OpenAI-compatible, Gemini. The\n") + b.WriteString("launcher sets that provider's variables and strips the provider prefix from the\n") + b.WriteString("model id, so `anthropic/claude-sonnet-5` is passed as `claude-sonnet-5`. Run\n") + b.WriteString("`aperture -debug` to see the exact variables for a given selection.\n\n") + + b.WriteString("## Installing and removing agents\n\n") + b.WriteString("Press `i` from the main menu, or choose Uninstall under settings. The launcher\n") + b.WriteString("prints the install command to run in another terminal. Uninstall removes the\n") + b.WriteString("agent's binary and its configuration directory.\n\n") + + b.WriteString("## YOLO mode\n\n") + b.WriteString("Toggled under settings, and persists across sessions. It appends each agent's\n") + b.WriteString("skip-permissions flag: Claude Code `--dangerously-skip-permissions`, Gemini CLI\n") + b.WriteString("`--yolo`, Codex `--dangerously-bypass-approvals-and-sandbox`. Copilot and OpenCode\n") + b.WriteString("have no such flag.\n\n") + + b.WriteString("## Subscription accounts\n\n") + b.WriteString("A Claude or ChatGPT subscription authenticates with an OAuth token rather than an\n") + b.WriteString("API key, so the gateway must run that provider in passthrough mode and forward the\n") + b.WriteString("agent's own token upstream.\n\n") + b.WriteString("**Passthrough does not work through this launcher for Claude Code.** For the\n") + b.WriteString("Anthropic backend the launcher sets `ANTHROPIC_AUTH_TOKEN=-`, and that placeholder\n") + b.WriteString("takes precedence over the subscription's OAuth token, so the gateway receives the\n") + b.WriteString("placeholder instead of a real credential. To use a Claude subscription through\n") + b.WriteString("Aperture, skip the launcher: set `ANTHROPIC_BASE_URL` to the gateway in\n") + b.WriteString("`~/.claude/settings.json` and set neither `ANTHROPIC_API_KEY` nor\n") + b.WriteString("`ANTHROPIC_AUTH_TOKEN`. Use the launcher for gateway-held API keys, where the\n") + b.WriteString("placeholder is the intended behavior.\n") + + footer(&b, p) + return b.String() +} + +// endpointsSkill covers managing gateway endpoints. +func endpointsSkill(p ContentParams) string { + var b strings.Builder + + frontmatter(&b, skillEndpoint, + "Add, switch, and delete Aperture gateway endpoints in the aperture launcher, including choosing between a direct URL and a bridged connection.") + + b.WriteString("# aperture endpoints\n\n") + b.WriteString("An endpoint is the URL of an Aperture gateway. The launcher keeps a list and uses\n") + b.WriteString("the active one on every launch.\n\n") + + if p.Endpoint != "" { + fmt.Fprintf(&b, "Currently active: `%s`\n\n", p.Endpoint) + } + + b.WriteString("Press `s` for settings, then **Aperture endpoints**.\n\n") + b.WriteString("| Key | Action |\n") + b.WriteString("| --- | --- |\n") + b.WriteString("| `a` | Add an endpoint |\n") + b.WriteString("| `Enter` | Activate the highlighted endpoint |\n") + b.WriteString("| `d` | Delete the highlighted endpoint |\n\n") + + b.WriteString("Adding one asks for a connection type. **Direct** takes a URL and reaches the\n") + b.WriteString("gateway over the machine's own Tailscale connection. **Bridge** routes through a\n") + b.WriteString("bridge you created first — see the `aperture-bridges` skill.\n\n") + + b.WriteString("The active endpoint persists in `settings.json`, so the next launch reuses it\n") + b.WriteString("without asking. On first run with no endpoint saved, the launcher prompts for one.\n") + b.WriteString("It then queries the gateway to check which providers are configured, and reports\n") + b.WriteString("what is blocking the connection when the endpoint is unreachable: Tailscale not\n") + b.WriteString("installed, not running, not connected, or Aperture itself down.\n\n") + + b.WriteString("A URL must be one the agent can reach directly. Gemini CLI additionally rejects\n") + b.WriteString("anything that is not an HTTPS fully-qualified domain name, and Claude Cowork\n") + b.WriteString("requires HTTPS.\n") + + footer(&b, p) + return b.String() +} + +// bridgesSkill covers reaching a gateway without the system daemon. +func bridgesSkill(p ContentParams) string { + var b strings.Builder + + frontmatter(&b, skillBridges, + "Reach an Aperture gateway from a machine outside the tailnet, using an aperture CLI bridge for launcher-started agents or ts-unplug for scripts, CI jobs, and other tools.") + + b.WriteString("# aperture bridges\n\n") + b.WriteString("A bridge is an embedded Tailscale node inside the launcher. It joins the tailnet,\n") + b.WriteString("opens a local proxy, and points the agent at it — no system Tailscale daemon\n") + b.WriteString("needed. Use one when the machine has no Tailscale, in CI and containers, or when a\n") + b.WriteString("separate tailnet identity is wanted.\n\n") + + b.WriteString("## Setting one up\n\n") + b.WriteString("1. Press `s` for settings, choose **Bridges**, press `a`, and name it.\n") + b.WriteString("2. Go to **Aperture endpoints**, press `a`, choose **Bridge**, pick the bridge, and\n") + b.WriteString(" enter the gateway URL.\n") + b.WriteString("3. Activate that endpoint. The first connection prints a Tailscale URL — open it\n") + b.WriteString(" in a browser to authenticate, and approve the device in the admin console if\n") + b.WriteString(" your tailnet requires approval. Later connections reuse it.\n\n") + + b.WriteString("`d` deletes a bridge, but only once every endpoint using it is gone. Each bridge\n") + b.WriteString("keeps its Tailscale state in its own directory under the launcher's config\n") + b.WriteString("directory.\n\n") + + b.WriteString("## ts-unplug\n\n") + b.WriteString("A bridge only serves agents the launcher starts, on a random local port. For\n") + b.WriteString("anything else — scripts, CI jobs, custom API clients, or several tools at once —\n") + b.WriteString("use `ts-unplug` from the `tailscale/ts-plug` repository, which listens on a port\n") + b.WriteString("you choose:\n\n") + b.WriteString("```sh\n") + b.WriteString("git clone https://github.com/tailscale/ts-plug.git\n") + b.WriteString("cd ts-plug && make ts-unplug\n") + b.WriteString("./build/ts-unplug -dir ./state -port 8080 ..ts.net\n") + b.WriteString("```\n\n") + b.WriteString("`-dir` holds its Tailscale state and `-port` is the local listening port. Approve\n") + b.WriteString("the device in the admin console, then point tools at `http://localhost:8080`.\n") + + footer(&b, p) + return b.String() +} + +// troubleshootingSkill covers failures, on-disk state, and limits. +func troubleshootingSkill(p ContentParams) string { + var b strings.Builder + + frontmatter(&b, skillTrouble, + "Diagnose aperture launcher failures: an agent binary not found, no compatible provider types, an agent exiting immediately, a gateway being ignored, or a bridge that will not connect. Also covers where the launcher stores its state.") + + b.WriteString("# aperture troubleshooting\n\n") + + b.WriteString("- **Agent binary not found.** Check `which `. Beyond `$PATH`, the launcher\n") + b.WriteString(" also looks in `~/.local/bin`, `~/bin`, `~/.npm-global/bin`, and `~/.opencode/bin`.\n") + b.WriteString("- **No compatible provider types.** The gateway has no provider configured with the\n") + b.WriteString(" API type that agent needs. Check the providers on the Aperture dashboard.\n") + b.WriteString("- **Agent exits immediately.** Run `aperture -debug` to see the variables set\n") + b.WriteString(" before launch.\n") + b.WriteString("- **Claude Code ignores the gateway.** An `env` block in `~/.claude/settings.json`\n") + b.WriteString(" overrides what the launcher sets. Remove the managed variables from it.\n") + b.WriteString("- **Gemini CLI rejects the host.** It needs an HTTPS FQDN such as\n") + b.WriteString(" `https://aperture.example.ts.net`, not `http://` or a short hostname.\n") + b.WriteString("- **Bridge will not connect.** Look for the first-run authentication URL in the\n") + b.WriteString(" logs, approve the device in the Tailscale admin console, and confirm the machine\n") + b.WriteString(" has outbound internet access.\n") + b.WriteString("- **Endpoint unreachable at startup.** The launcher names what is blocking it:\n") + b.WriteString(" Tailscale not installed, not running, not connected, or Aperture itself down.\n\n") + + b.WriteString("## State\n\n") + if p.ConfigDir != "" { + fmt.Fprintf(&b, "Configuration lives in `%s`:\n\n", p.ConfigDir) + } else { + b.WriteString("Configuration lives under `aperture/` in the OS configuration directory —\n") + b.WriteString("`~/Library/Application Support/` on macOS, `~/.config/` on Linux,\n") + b.WriteString("`%LOCALAPPDATA%\\` on Windows:\n\n") + } + b.WriteString("- `settings.json` — endpoints, bridges, YOLO mode\n") + b.WriteString("- `launcher.json` — last used agent, provider, and model\n") + b.WriteString("- `bridges//` — Tailscale state for each bridge\n\n") + + b.WriteString("## Limits\n\n") + b.WriteString("- One agent per session; run more in separate terminals.\n") + b.WriteString("- Terminal only — there is no GUI.\n") + b.WriteString("- Connectors and MCP gateway settings are managed in the Aperture dashboard, not\n") + b.WriteString(" here.\n") + b.WriteString("- Claude Cowork is unavailable on Linux.\n") + + footer(&b, p) + return b.String() +} diff --git a/internal/skills/content_test.go b/internal/skills/content_test.go new file mode 100644 index 0000000..d653138 --- /dev/null +++ b/internal/skills/content_test.go @@ -0,0 +1,222 @@ +package skills + +import ( + "strings" + "testing" +) + +// params is a representative launcher state. +func params() ContentParams { + return ContentParams{ + Version: "B18", + Endpoint: "https://aperture.example.ts.net", + ConfigDir: "/home/u/.config/aperture", + Agents: []AgentInfo{ + {Name: "Claude Code", Binary: "claude", InstallHint: "curl -fsSL https://claude.ai/install.sh | bash"}, + {Name: "OpenCode", Binary: "opencode", InstallHint: "curl -fsSL https://opencode.ai/install | bash"}, + {Name: "Claude Cowork", Binary: "", InstallHint: ""}, + }, + } +} + +// sample renders every skill joined together, for topic coverage checks that +// do not care which skill carries a given fact. +func sample() string { + var b strings.Builder + for _, s := range Content(params()) { + b.WriteString(s.Body) + b.WriteString("\n") + } + return b.String() +} + +// bySkill renders the skills keyed by name. +func bySkill(p ContentParams) map[string]string { + out := map[string]string{} + for _, s := range Content(p) { + out[s.Name] = s.Body + } + return out +} + +// TestContentCoversDocumentedTopics pins the skill against the published +// Aperture CLI documentation. Each entry is a topic the docs cover that an +// agent reading this skill would otherwise have to look up. Dropping one is a +// coverage regression, so this test names the topic rather than the wording. +func TestContentCoversDocumentedTopics(t *testing.T) { + got := sample() + + topics := map[string][]string{ + "install command": {"go install github.com/tailscale/aperture-cli/cmd/aperture@latest"}, + "go version": {"Go 1.26+"}, + "version flag": {"-version"}, + "debug flag": {"-debug"}, + "skills subcommand": {"aperture skills install"}, + "navigation keys": {"`j`/`k`", "`h`/`l`"}, + "settings key": {"| `s` | Settings |"}, + "install agents key": {"| `i` | Install agents |"}, + "quit keys": {"| `q` | Quit |", "Ctrl+C"}, + "last used slot": {"`[0]`"}, + "single option menus": {"auto-selected"}, + "endpoint management": {"Aperture endpoints", "Direct", "Bridge"}, + "bridges": {"embedded Tailscale node", "admin console"}, + "ts-unplug": {"ts-unplug", "-port", "-dir"}, + "yolo mode": {"--dangerously-skip-permissions", "--yolo", "--dangerously-bypass-approvals-and-sandbox"}, + "agent install": {"Installing and removing agents"}, + "provider types": {"Bedrock", "Vertex", "OpenAI Responses"}, + "path suffixes": {"/bedrock"}, + "model prefix strip": {"anthropic/claude-sonnet-5"}, + "cowork platform": {"macOS and\nWindows only"}, + "cowork https": {"rewrites `http://` to"}, + "passthrough caveat": {"ANTHROPIC_AUTH_TOKEN=-"}, + "state files": {"settings.json", "launcher.json", "bridges//"}, + "binary lookup": {"~/.local/bin", "~/.npm-global/bin"}, + "claude settings": {"~/.claude/settings.json"}, + "gemini fqdn": {"HTTPS FQDN"}, + "one agent limit": {"One agent per session"}, + "connectors excluded": {"Aperture dashboard"}, + } + + for topic, needles := range topics { + for _, needle := range needles { + if !strings.Contains(got, needle) { + t.Errorf("skill is missing coverage of %s: no %q", topic, needle) + } + } + } +} + +func TestContentListsAgentsFromTheRegistry(t *testing.T) { + got := sample() + + for _, want := range []string{"Claude Code", "`claude`", "OpenCode", "`opencode`"} { + if !strings.Contains(got, want) { + t.Errorf("missing %q from the agent table", want) + } + } + + // An agent with no binary (a desktop app) must still render a row rather + // than an empty cell pair. + if !strings.Contains(got, "| Claude Cowork | — | — |") { + t.Error("an agent without a binary should render em dashes") + } +} + +func TestContentEscapesPipesInTableCells(t *testing.T) { + got := sample() + + // "curl ... | bash" must not end the table cell early. + if !strings.Contains(got, `curl -fsSL https://claude.ai/install.sh \| bash`) { + t.Error("a pipe inside an install hint must be escaped for the table") + } + + // Every row of a table must have the same number of unescaped pipes as + // that table's header, or a cell has leaked into the next column. + columns := 0 + for _, line := range strings.Split(got, "\n") { + if !strings.HasPrefix(line, "|") { + columns = 0 + continue + } + n := strings.Count(line, "|") - strings.Count(line, `\|`) + if columns == 0 { + columns = n + continue + } + if n != columns { + t.Errorf("row has %d unescaped pipes, header had %d: %s", n, columns, line) + } + } +} + +func TestContentUsesResolvedConfigDir(t *testing.T) { + got := sample() + if !strings.Contains(got, "/home/u/.config/aperture") { + t.Error("a resolved config directory should be named directly") + } + + // Without one, the skill should fall back to describing each platform. + fallback := bySkill(ContentParams{})["aperture-troubleshooting"] + for _, want := range []string{"Library/Application Support", ".config", "LOCALAPPDATA"} { + if !strings.Contains(fallback, want) { + t.Errorf("fallback should mention %q", want) + } + } +} + +func TestContentOmitsEmptyOptionalFields(t *testing.T) { + var b strings.Builder + for _, s := range Content(ContentParams{}) { + b.WriteString(s.Body) + } + got := b.String() + + if strings.Contains(got, "Configured endpoint") { + t.Error("no endpoint configured should not render an endpoint line") + } + if strings.Contains(got, "Generated by aperture") { + t.Error("no version should not render a generated-by line") + } + if strings.Contains(got, "## Agents") { + t.Error("no agents should not render an empty agent table") + } +} + +func TestEverySkillHasFrontmatter(t *testing.T) { + for _, s := range Content(params()) { + if !strings.HasPrefix(s.Body, "---\nname: "+s.Name+"\n") { + t.Errorf("%s: frontmatter must open with its own name", s.Name) + } + if strings.Count(s.Body, "---\n") < 2 { + t.Errorf("%s: frontmatter must be closed", s.Name) + } + if !strings.Contains(s.Body, "description: ") { + t.Errorf("%s: needs a description for skill discovery", s.Name) + } + } +} + +// TestSkillsSplitByComponent pins which component each skill owns, so a fact +// stays where an agent would look for it. +func TestSkillsSplitByComponent(t *testing.T) { + skills := bySkill(params()) + + want := map[string][]string{ + "aperture": {"## Commands", "## Keys", "aperture -debug"}, + "aperture-agents": {"YOLO mode", "ANTHROPIC_AUTH_TOKEN=-", "Provider types"}, + "aperture-endpoints": {"Aperture endpoints", "Direct", "Bridge"}, + "aperture-bridges": {"ts-unplug", "-port", "admin console"}, + "aperture-troubleshooting": {"settings.json", "HTTPS FQDN", "## Limits"}, + } + + for name, needles := range want { + body, ok := skills[name] + if !ok { + t.Errorf("missing skill %q", name) + continue + } + for _, needle := range needles { + if !strings.Contains(body, needle) { + t.Errorf("%s should cover %q", name, needle) + } + } + } + + if len(skills) != len(allSkillNames()) { + t.Errorf("generated %d skills, allSkillNames lists %d", len(skills), len(allSkillNames())) + } +} + +// TestCoreSkillLinksTheOthers keeps the set discoverable: an agent that loads +// the core skill should learn the rest exist. +func TestCoreSkillLinksTheOthers(t *testing.T) { + core := bySkill(params())["aperture"] + for _, name := range allSkillNames() { + if name == "aperture" { + continue + } + if !strings.Contains(core, name) { + t.Errorf("core skill should mention %q", name) + } + } +} diff --git a/internal/skills/skills.go b/internal/skills/skills.go new file mode 100644 index 0000000..f5e67ba --- /dev/null +++ b/internal/skills/skills.go @@ -0,0 +1,426 @@ +// Package skills generates a set of agent skills describing this launcher and +// installs them into the skill directories of the coding agents on this +// machine. One skill covers each component — the launcher itself, agents, +// endpoints, bridges, and troubleshooting — so an agent loads only the part it +// needs. +// +// Agents share a single canonical skill root (~/.agents/skills) and each keep +// their own directory of links into it. Installing therefore writes to paths +// that other tools also write to, so every destructive step here is guarded: a +// skill directory is only replaced when it carries our own ownership marker, or +// when the caller explicitly forces it. A directory belonging to another tool — +// including one a user wrote by hand, which has no marker at all — is reported +// as a conflict and left untouched. +package skills + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "slices" + "strings" +) + +// markerName is the file written inside an installed skill directory recording +// which tool created it. +const markerName = ".installed-by.json" + +// toolName is the value written to, and expected in, the ownership marker. +const toolName = "aperture" + +// marker is the on-disk ownership record. +type marker struct { + Tool string `json:"tool"` + Version string `json:"version"` +} + +// Options controls an install. +type Options struct { + // Version is recorded in the ownership marker. + Version string + + // Skills are the skills to install. Any skill this tool installed + // previously that is absent here is pruned. + Skills []Skill + + // Project installs into the working directory instead of the home + // directory. + Project bool + + // Force replaces skill directories owned by another tool. Without it, + // such directories are reported as conflicts and nothing is written. + Force bool + + // Home overrides the home directory. Empty means os.UserHomeDir. + Home string + + // Cwd overrides the working directory for project installs. Empty means + // os.Getwd. + Cwd string +} + +// Conflict is a destination holding a skill directory owned by another tool. +type Conflict struct { + // Path is the directory that would have been replaced. + Path string + + // Owner is the tool named in the directory's marker, or "unknown" when + // the directory has no marker. + Owner string +} + +// Link is an installed link from one agent's skill directory to a canonical +// skill directory. +type Link struct { + // Agent is the user-visible agent name. Empty for the canonical + // directory itself. + Agent string + + // Path is the directory linking to the canonical skill. + Path string +} + +// Installed is one skill written by an install. +type Installed struct { + // Name is the skill directory name. + Name string + + // Canonical is the shared directory holding the content. + Canonical string + + // Linked lists the per-agent directories linking to Canonical. + Linked []Link +} + +// Result reports what an install wrote. +type Result struct { + // Skills are the skills installed, in generation order. + Skills []Installed + + // Pruned lists directories removed because they hold a skill this tool + // installed previously but no longer generates. + Pruned []string + + // Overwrote lists directories that belonged to another tool and were + // replaced anyway. Only populated when Force is set. + Overwrote []Conflict +} + +// agent is one coding agent's skill directory layout. +type agent struct { + // name is the user-visible agent name. + name string + + // globalDir is the agent's skill directory under the home directory. + globalDir string + + // projectDir is the agent's skill directory relative to a project root. + projectDir string +} + +// agents lists the agents whose skill directories we install into. The +// canonical root is handled separately; these are the per-agent link targets. +func agents() []agent { + return []agent{ + {name: "Claude Code", globalDir: ".claude/skills", projectDir: ".claude/skills"}, + {name: "Codex", globalDir: ".codex/skills", projectDir: ".codex/skills"}, + {name: "Gemini CLI", globalDir: ".gemini/skills", projectDir: ".gemini/skills"}, + {name: "GitHub Copilot CLI", globalDir: ".copilot/skills", projectDir: ".copilot/skills"}, + {name: "OpenCode", globalDir: ".config/opencode/skills", projectDir: ".opencode/skills"}, + } +} + +// ErrConflict is returned when a destination is owned by another tool and Force +// was not set. +var ErrConflict = errors.New("skill directory owned by another tool") + +// ConflictError carries the destinations that blocked an install. +type ConflictError struct { + Conflicts []Conflict +} + +func (e *ConflictError) Error() string { + var b strings.Builder + fmt.Fprintf(&b, "%d skill ", len(e.Conflicts)) + if len(e.Conflicts) == 1 { + b.WriteString("directory is owned by another tool; installing would delete it:\n") + } else { + b.WriteString("directories are owned by another tool; installing would delete them:\n") + } + for _, c := range e.Conflicts { + fmt.Fprintf(&b, " %s (owner: %s)\n", c.Path, c.Owner) + } + b.WriteString("Remove them first, or pass -force to overwrite.") + return b.String() +} + +func (e *ConflictError) Unwrap() error { return ErrConflict } + +// Install writes every skill to the canonical root and links each into the +// agent directories present on this machine. +// +// It returns a *ConflictError without writing anything when any destination is +// owned by another tool and opts.Force is false. +func Install(opts Options) (Result, error) { + root, err := installRoot(opts) + if err != nil { + return Result{}, err + } + canonicalRoot := filepath.Join(root, ".agents", "skills") + + // Check every destination of every skill before writing any of them, so + // a conflict in the last skill cannot leave the first ones half-applied. + var conflicts []Conflict + for _, s := range opts.Skills { + for _, dest := range destinationsFor(root, canonicalRoot, s.Name, opts.Project) { + if c, ok := conflictAt(dest.Path); ok { + conflicts = append(conflicts, c) + } + } + } + if len(conflicts) > 0 && !opts.Force { + return Result{}, &ConflictError{Conflicts: conflicts} + } + + res := Result{Overwrote: conflicts} + + for _, s := range opts.Skills { + canonical := filepath.Join(canonicalRoot, s.Name) + if err := writeSkill(canonical, s.Body, opts.Version); err != nil { + return res, err + } + installed := Installed{Name: s.Name, Canonical: canonical} + + for _, dest := range destinationsFor(root, canonicalRoot, s.Name, opts.Project) { + if dest.Path == canonical { + continue + } + if err := link(canonical, dest.Path); err != nil { + return res, fmt.Errorf("linking %s: %w", dest.Path, err) + } + installed.Linked = append(installed.Linked, dest) + } + res.Skills = append(res.Skills, installed) + } + + pruned, err := prune(root, canonicalRoot, opts) + if err != nil { + return res, err + } + res.Pruned = pruned + + return res, nil +} + +// prune removes skill directories this tool installed previously but no longer +// generates, such as one renamed between releases. Only directories carrying +// our own marker are removed, so a name we no longer use can never take another +// tool's directory with it. +func prune(root, canonicalRoot string, opts Options) ([]string, error) { + current := make([]string, 0, len(opts.Skills)) + for _, s := range opts.Skills { + current = append(current, s.Name) + } + + entries, err := os.ReadDir(canonicalRoot) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + var pruned []string + for _, entry := range entries { + name := entry.Name() + if slices.Contains(current, name) { + continue + } + canonical := filepath.Join(canonicalRoot, name) + if ownerOf(canonical) != toolName { + continue + } + if err := remove(canonical); err != nil { + return pruned, err + } + pruned = append(pruned, canonical) + + // Remove the links that pointed at it, but only while they are + // still symlinks — a real directory there belongs to someone else. + for _, dest := range destinationsFor(root, canonicalRoot, name, opts.Project) { + if dest.Path == canonical { + continue + } + info, err := os.Lstat(dest.Path) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + continue + } + if err := remove(dest.Path); err != nil { + return pruned, err + } + pruned = append(pruned, dest.Path) + } + } + return pruned, nil +} + +// installRoot resolves the base directory an install writes under. +func installRoot(opts Options) (string, error) { + if opts.Project { + if opts.Cwd != "" { + return opts.Cwd, nil + } + return os.Getwd() + } + if opts.Home != "" { + return opts.Home, nil + } + return os.UserHomeDir() +} + +// destinationsFor lists the canonical directory for a skill plus one directory +// per agent whose skill directory already exists, meaning the agent is present. +func destinationsFor(root, canonicalRoot, skillName string, project bool) []Link { + canonical := filepath.Join(canonicalRoot, skillName) + dests := []Link{{Path: canonical}} + + for _, a := range agents() { + rel := a.globalDir + if project { + rel = a.projectDir + } + parent := filepath.Join(root, rel) + if _, err := os.Stat(parent); err != nil { + continue + } + dest := filepath.Join(parent, skillName) + if dest != canonical { + dests = append(dests, Link{Agent: a.name, Path: dest}) + } + } + return dests +} + +// conflictAt reports whether a destination holds a skill directory that another +// tool owns. A missing path is free, and a symlink is one of our own links (or +// a link the agent manages), neither of which is another tool's content. +func conflictAt(path string) (Conflict, bool) { + info, err := os.Lstat(path) + if err != nil { + return Conflict{}, false + } + if info.Mode()&os.ModeSymlink != 0 { + return Conflict{}, false + } + switch owner := ownerOf(path); owner { + case toolName: + return Conflict{}, false + case "": + return Conflict{Path: path, Owner: "unknown"}, true + default: + return Conflict{Path: path, Owner: owner}, true + } +} + +// ownerOf returns the tool named in a directory's marker, or "" when the +// directory has no readable marker. +func ownerOf(dir string) string { + raw, err := os.ReadFile(filepath.Join(dir, markerName)) + if err != nil { + return "" + } + var m marker + if err := json.Unmarshal(raw, &m); err != nil { + return "" + } + return m.Tool +} + +// writeSkill replaces a canonical directory with fresh content and claims it. +func writeSkill(canonical, body, version string) error { + if err := remove(canonical); err != nil { + return err + } + if err := os.MkdirAll(canonical, 0o755); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(canonical, "SKILL.md"), []byte(body), 0o644); err != nil { + return err + } + raw, err := json.MarshalIndent(marker{Tool: toolName, Version: version}, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(canonical, markerName), append(raw, '\n'), 0o644) +} + +// link points dest at canonical, preferring a symlink and falling back to a +// copy on platforms or filesystems that refuse one. +func link(canonical, dest string) error { + if err := remove(dest); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + + target := canonical + if rel, err := filepath.Rel(filepath.Dir(dest), canonical); err == nil { + target = rel + } + if err := os.Symlink(target, dest); err == nil { + return nil + } else if runtime.GOOS != "windows" && !errors.Is(err, os.ErrPermission) { + return err + } + return copyDir(canonical, dest) +} + +// remove deletes a file, symlink, or directory, treating a missing path as +// success. Symlinks are unlinked rather than followed, so removing a link never +// touches the directory it points at. +func remove(path string) error { + info, err := os.Lstat(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if info.IsDir() { + return os.RemoveAll(path) + } + return os.Remove(path) +} + +// copyDir recursively copies src to dst. +func copyDir(src, dst string) error { + entries, err := os.ReadDir(src) + if err != nil { + return err + } + if err := os.MkdirAll(dst, 0o755); err != nil { + return err + } + for _, entry := range entries { + s := filepath.Join(src, entry.Name()) + d := filepath.Join(dst, entry.Name()) + if entry.IsDir() { + if err := copyDir(s, d); err != nil { + return err + } + continue + } + raw, err := os.ReadFile(s) + if err != nil { + return err + } + if err := os.WriteFile(d, raw, 0o644); err != nil { + return err + } + } + return nil +} diff --git a/internal/skills/skills_test.go b/internal/skills/skills_test.go new file mode 100644 index 0000000..fdab64b --- /dev/null +++ b/internal/skills/skills_test.go @@ -0,0 +1,308 @@ +package skills + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" +) + +// writeMarker plants an ownership marker naming the given tool. +func writeMarker(t *testing.T, dir, tool string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(marker{Tool: tool, Version: "1.0.0"}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, markerName), raw, 0o644); err != nil { + t.Fatal(err) + } +} + +// testSkills is a small stand-in for the generated set. +func testSkills() []Skill { + return []Skill{ + {Name: "aperture", Body: "core"}, + {Name: "aperture-agents", Body: "agents"}, + } +} + +func TestConflictAtUnmarkedDirectory(t *testing.T) { + dir := filepath.Join(t.TempDir(), "aperture") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("someone else's"), 0o644); err != nil { + t.Fatal(err) + } + + c, ok := conflictAt(dir) + if !ok { + t.Fatal("an unmarked directory must be treated as another tool's") + } + if c.Owner != "unknown" { + t.Errorf("owner = %q, want %q", c.Owner, "unknown") + } +} + +func TestConflictAtOwnDirectory(t *testing.T) { + dir := filepath.Join(t.TempDir(), "aperture") + writeMarker(t, dir, toolName) + + if _, ok := conflictAt(dir); ok { + t.Fatal("a directory we installed must be replaceable") + } +} + +func TestConflictAtForeignDirectory(t *testing.T) { + dir := filepath.Join(t.TempDir(), "aperture") + writeMarker(t, dir, "some-other-tool") + + c, ok := conflictAt(dir) + if !ok { + t.Fatal("another tool's directory must be a conflict") + } + if c.Owner != "some-other-tool" { + t.Errorf("owner = %q, want %q", c.Owner, "some-other-tool") + } +} + +func TestConflictAtMissingDirectory(t *testing.T) { + if _, ok := conflictAt(filepath.Join(t.TempDir(), "absent")); ok { + t.Fatal("a missing directory must not be a conflict") + } +} + +func TestInstallWritesEverySkill(t *testing.T) { + home := t.TempDir() + + res, err := Install(Options{Home: home, Skills: testSkills(), Version: "test"}) + if err != nil { + t.Fatal(err) + } + if len(res.Skills) != 2 { + t.Fatalf("installed %d skills, want 2", len(res.Skills)) + } + + for _, s := range testSkills() { + dir := filepath.Join(home, ".agents", "skills", s.Name) + raw, err := os.ReadFile(filepath.Join(dir, "SKILL.md")) + if err != nil { + t.Fatalf("%s: %v", s.Name, err) + } + if string(raw) != s.Body { + t.Errorf("%s content = %q, want %q", s.Name, raw, s.Body) + } + if owner := ownerOf(dir); owner != toolName { + t.Errorf("%s owner = %q, want %q", s.Name, owner, toolName) + } + } +} + +func TestInstallRefusesForeignDirectory(t *testing.T) { + home := t.TempDir() + // The conflict is on the second skill, so the first would already have + // been written if the check were not done up front. + foreign := filepath.Join(home, ".agents", "skills", "aperture-agents") + writeMarker(t, foreign, "some-other-tool") + if err := os.WriteFile(filepath.Join(foreign, "SKILL.md"), []byte("keep me"), 0o644); err != nil { + t.Fatal(err) + } + + _, err := Install(Options{Home: home, Skills: testSkills(), Version: "test"}) + if !errors.Is(err, ErrConflict) { + t.Fatalf("err = %v, want ErrConflict", err) + } + + raw, readErr := os.ReadFile(filepath.Join(foreign, "SKILL.md")) + if readErr != nil { + t.Fatal(readErr) + } + if string(raw) != "keep me" { + t.Errorf("content = %q, want %q — a refused install must not write", raw, "keep me") + } + + // Nothing at all should have been written, including the first skill. + if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "aperture")); !os.IsNotExist(err) { + t.Error("a conflict on a later skill must stop the whole install") + } +} + +func TestInstallForceOverwritesAndReports(t *testing.T) { + home := t.TempDir() + foreign := filepath.Join(home, ".agents", "skills", "aperture") + writeMarker(t, foreign, "some-other-tool") + + res, err := Install(Options{Home: home, Skills: testSkills(), Version: "test", Force: true}) + if err != nil { + t.Fatal(err) + } + if ownerOf(foreign) != toolName { + t.Error("a forced install should claim the directory") + } + if len(res.Overwrote) != 1 || res.Overwrote[0].Owner != "some-other-tool" { + t.Errorf("overwrote = %+v, want one entry owned by some-other-tool", res.Overwrote) + } +} + +func TestInstallLinksDetectedAgentsOnly(t *testing.T) { + home := t.TempDir() + // Only Claude Code is "installed" — its skills directory exists. + claude := filepath.Join(home, ".claude", "skills") + if err := os.MkdirAll(claude, 0o755); err != nil { + t.Fatal(err) + } + + res, err := Install(Options{Home: home, Skills: testSkills(), Version: "test"}) + if err != nil { + t.Fatal(err) + } + + for _, installed := range res.Skills { + want := filepath.Join(claude, installed.Name) + if len(installed.Linked) != 1 || installed.Linked[0].Path != want { + t.Fatalf("%s linked = %v, want exactly [%s]", installed.Name, installed.Linked, want) + } + if installed.Linked[0].Agent != "Claude Code" { + t.Errorf("agent = %q, want %q", installed.Linked[0].Agent, "Claude Code") + } + // The link must resolve to the canonical content. + if _, err := os.ReadFile(filepath.Join(want, "SKILL.md")); err != nil { + t.Errorf("%s: link does not resolve: %v", installed.Name, err) + } + } +} + +func TestInstallPrunesOurOwnStaleSkills(t *testing.T) { + home := t.TempDir() + claude := filepath.Join(home, ".claude", "skills") + if err := os.MkdirAll(claude, 0o755); err != nil { + t.Fatal(err) + } + + // A skill from a previous release that we no longer generate. + stale := filepath.Join(home, ".agents", "skills", "aperture-old") + writeMarker(t, stale, toolName) + staleLink := filepath.Join(claude, "aperture-old") + if err := os.Symlink(stale, staleLink); err != nil { + t.Fatal(err) + } + + res, err := Install(Options{Home: home, Skills: testSkills(), Version: "test"}) + if err != nil { + t.Fatal(err) + } + + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Error("a stale skill of ours should be pruned") + } + if _, err := os.Lstat(staleLink); !os.IsNotExist(err) { + t.Error("the stale skill's link should be pruned too") + } + if len(res.Pruned) != 2 { + t.Errorf("pruned = %v, want the directory and its link", res.Pruned) + } +} + +func TestInstallNeverPrunesAnotherToolsSkill(t *testing.T) { + home := t.TempDir() + + // A directory belonging to someone else, which we never generated and + // must not touch even though it is not in our current set. + foreign := filepath.Join(home, ".agents", "skills", "someone-elses-skill") + writeMarker(t, foreign, "some-other-tool") + if err := os.WriteFile(filepath.Join(foreign, "SKILL.md"), []byte("keep me"), 0o644); err != nil { + t.Fatal(err) + } + + res, err := Install(Options{Home: home, Skills: testSkills(), Version: "test"}) + if err != nil { + t.Fatal(err) + } + if len(res.Pruned) != 0 { + t.Errorf("pruned = %v, want nothing", res.Pruned) + } + + raw, err := os.ReadFile(filepath.Join(foreign, "SKILL.md")) + if err != nil { + t.Fatal(err) + } + if string(raw) != "keep me" { + t.Errorf("content = %q — pruning must not reach another tool's skill", raw) + } +} + +func TestInstallDoesNotPruneUnmarkedDirectories(t *testing.T) { + home := t.TempDir() + + // A hand-written skill: no marker at all. + handmade := filepath.Join(home, ".agents", "skills", "my-notes") + if err := os.MkdirAll(handmade, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(handmade, "SKILL.md"), []byte("mine"), 0o644); err != nil { + t.Fatal(err) + } + + if _, err := Install(Options{Home: home, Skills: testSkills(), Version: "test"}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(handmade, "SKILL.md")); err != nil { + t.Errorf("an unmarked directory must survive pruning: %v", err) + } +} + +func TestInstallIsIdempotent(t *testing.T) { + home := t.TempDir() + claude := filepath.Join(home, ".claude", "skills") + if err := os.MkdirAll(claude, 0o755); err != nil { + t.Fatal(err) + } + + first, err := Install(Options{Home: home, Skills: testSkills(), Version: "test"}) + if err != nil { + t.Fatal(err) + } + second, err := Install(Options{Home: home, Skills: testSkills(), Version: "test"}) + if err != nil { + t.Fatalf("a second install over our own output must succeed: %v", err) + } + + if len(second.Skills) != len(first.Skills) { + t.Errorf("second install wrote %d skills, first wrote %d", len(second.Skills), len(first.Skills)) + } + if len(second.Pruned) != 0 { + t.Errorf("pruned = %v, want nothing on an unchanged reinstall", second.Pruned) + } +} + +func TestRemoveUnlinksSymlinkWithoutTouchingTarget(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatal(err) + } + keep := filepath.Join(target, "SKILL.md") + if err := os.WriteFile(keep, []byte("keep"), 0o644); err != nil { + t.Fatal(err) + } + + link := filepath.Join(dir, "link") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + if err := remove(link); err != nil { + t.Fatal(err) + } + + if _, err := os.Lstat(link); !os.IsNotExist(err) { + t.Error("the symlink should be gone") + } + if _, err := os.Stat(keep); err != nil { + t.Errorf("removing a symlink must not touch its target: %v", err) + } +}