diff --git a/CLAUDE.md b/CLAUDE.md index cbca983d..633244b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ Use the official product names in all user-facing text (documentation, code comm - **`cmd/tiger/`** - The main CLI binary. Sets up signal handling (SIGINT/SIGTERM cancel the command context), delegates to `cmd.Execute()`, and maps a `common.ExitCodeError` found anywhere in the returned error chain to the process exit code. - **`cmd/generate-docs/`** - Renders the CLI reference in `docs/cli/` from the command tree. Runs under `go generate` (see [CLI Reference Docs](#cli-reference-docs)). - **`internal/`** - All core application logic (non-public Go packages). - - **`internal/cmd/`** - Cobra command implementations for all CLI commands (auth, project, service, db, config, mcp, feedback, version, upgrade, completion). Each command lives in its own file, named to match the command in snake_case (see [Command Architecture](#command-architecture)). `root.go` holds the root command, global flags, and command tree construction; files ending in `_helper.go` contain cross-group helpers rather than commands. + - **`internal/cmd/`** - Cobra command implementations for all CLI commands (auth, project, service, db, config, mcp, skills, feedback, version, upgrade, completion). Each command lives in its own file, named to match the command in snake_case (see [Command Architecture](#command-architecture)). `root.go` holds the root command, global flags, and command tree construction; files ending in `_helper.go` contain cross-group helpers rather than commands. - **`internal/api/`** - API client layer. Includes the OpenAPI-generated REST client (`client.go`, `types.go`), the shared HTTP client (`client_util.go`), and a generated mock of the client interface for testing (`mocks/`). **Never edit the generated files by hand** — see [Code Generation](#code-generation). - **`internal/config/`** - Configuration management. Handles config file loading (via Viper), the `Config` struct and its write helpers, and credential storage (keyring with a file fallback in the config directory). - **`internal/common/`** - Shared business logic used by both CLI commands and MCP tools: the per-invocation `App` (config + API client), database password generation and storage (keyring, pgpass), wait/polling operations and the spinner, connection detail helpers (including read replicas), service readiness checks, query execution, log fetching, and error handling with exit codes. @@ -158,7 +158,7 @@ Helper functions inside `internal/cmd` take the `*cobra.Command` and print throu - Commands with structured output take `-o`/`--output` with `json`, `yaml`, and `table` (the default); some commands support extras (`env`, `bare`). Register the flag with the validating flag types in `flag_helper.go` (`new(outputFlag)` and friends, with no bound variable) and read the value from `cfg.Output` — `output` is a config value. - Serialize with `util.SerializeToJSON` and `util.SerializeToYAML`. **Don't add `yaml:` struct tags to output types** — `SerializeToYAML` encodes to JSON first and converts, so only `json:` tags matter and the two formats stay consistent (including for generated types that only carry `json:` tags). -- Colored output uses lipgloss styles, restricted to the base ANSI colors (`lipgloss.Red`, `lipgloss.Cyan`, …) rather than 256-color or true-color values, which can clash with some terminal themes. Render styles unconditionally: the output writers strip colors when the stream isn't a terminal, and the lifecycle disables color on them when `cfg.Color` is off, so commands need no per-command wiring. +- Colored output uses lipgloss styles, restricted to the eight base ANSI colors (`lipgloss.Blue`, `lipgloss.Green`, …) rather than their bright variants or 256-color and true-color values, and without bold; de-emphasize text with `Faint` rather than a grey. That way output follows the user's terminal theme (some themes render bright colors and bold as greys). Render styles unconditionally: the output writers strip colors when the stream isn't a terminal, and the lifecycle disables color on them when `cfg.Color` is off, so commands need no per-command wiring. The `tiger skills install` picker is the model. ### Tone @@ -170,7 +170,7 @@ Warnings and errors follow the same standard. A non-fatal failure prints to stde Read user input through the helpers in `internal/util/read.go` — `util.ReadLine` (one trimmed line), `util.ReadPassword` (no echo, saves and restores terminal state), and `util.ReadAll` (everything, for piped input) — never by driving `bufio`/`term` directly. All three select on `ctx.Done()`, so Ctrl-C unblocks a waiting prompt instead of hanging. -**Every read that prompts the user in real time must be gated on `util.IsTerminal(cmd.InOrStdin()) && util.IsTerminal(cmd.ErrOrStderr())`** — both streams, so the user can both see the question and answer it — and must return an error naming that command's non-interactive alternative when either check fails (e.g. "use --confirm to skip the prompt", or "use --new-password, --auto-generate, or TIGER_NEW_PASSWORD"). Put the gate immediately before the prompt in the command body, not inside a shared helper, so the error can name the right flag or env var. A prompt that merely *offers* something skips silently instead of erroring, since nothing is being refused — `offerProdProtection` in `auth_login.go` is the one example. The gate is about *prompts*, not reading stdin as such — a read whose whole point is piped input (`util.ReadAll` on a here-doc or a `|`) must not be gated. +**Every read that prompts the user in real time must be gated on `util.IsTerminal(cmd.InOrStdin()) && util.IsTerminal(cmd.ErrOrStderr())`** — both streams, so the user can both see the question and answer it — and must return an error naming that command's non-interactive alternative when either check fails (e.g. "use --confirm to skip the prompt", or "use --new-password, --auto-generate, or TIGER_NEW_PASSWORD"). Put the gate just before the prompt, in the command body or a helper used only by that command — never in a helper shared across commands — so the error can name the right flag or env var. A prompt that merely *offers* something skips silently instead of erroring, since nothing is being refused — `offerProdProtection` in `auth_login.go` is the one example. The gate is about *prompts*, not reading stdin as such — a read whose whole point is piped input (`util.ReadAll` on a here-doc or a `|`) must not be gated. `util.IsTerminal` and `util.ReadPassword` are `var`s so tests can replace them (the `withIsTerminal` and `withReadPassword` options in `main_test.go`). `ReadPassword` needs stdin to be a real `*os.File`, so password prompts can't be driven with `cmd.SetIn`; `ReadLine`/`ReadAll` take any `io.Reader` and work fine with it. @@ -190,6 +190,7 @@ Interactive menus and spinners use BubbleTea v2 (`charm.land/bubbletea/v2`). Alw - Error strings start with a **lowercase** letter, so they read correctly when a caller wraps them (`fmt.Errorf("failed to get service: %w", err)`). A leading proper noun or initialism keeps its capitalization (`API key validation failed`); if that reads awkwardly, reword so the identifier isn't first (`missing required option: ClientName`). Log messages are the opposite — they start with a capital letter. - Use `errors.New` for static error strings; use `fmt.Errorf` only when you need format verbs. - Handle every error — never ignore one or assign it to `_` (at minimum, print a warning). +- Cleanup that can fail (closing a reader, removing a temporary directory) is called explicitly on the success path, with its error returned, and also deferred as best-effort cleanup for the error paths, where a more important error is already being returned. This relies on the cleanup being safe to call twice, so use it only for idempotent operations. `writeSkill` in `skills_install.go` is the model. - Return errors from `RunE`; never call `os.Exit` in a command. For specific exit codes, wrap with `common.ExitWithCode`, and map API errors to exit codes with `common.ExitWithErrorFromStatusCode`. The exit codes are defined in `internal/common/errors.go`, and `main.go` honors a `common.ExitCodeError` anywhere in the error chain. - After checking an API response's status code, nil-check the parsed body (`resp.JSON200`, etc.) before using it — a nil body indicates a bug and should return `errors.New("empty response from API")`. - Operations that connect to a service should call `common.CheckServiceReady` first and translate `common.ErrPaused`/`common.ErrNotReady` into actionable messages. diff --git a/README.md b/README.md index d63d2ec7..9cad8e1f 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,9 @@ tiger db query -c "SELECT now()" # Install the MCP server tiger mcp install + +# Install agent skills for your coding agent +tiger skills install ``` ## Usage @@ -135,6 +138,8 @@ Tiger CLI provides the following commands: - `start` - Start the MCP server - `list` - List available MCP tools, prompts, and resources (alias: `ls`) - `get` - Get detailed information about a specific MCP capability (aliases: `describe`, `show`) +- `tiger skills` - Agent skills for AI coding agents + - `install` - Install PostgreSQL and TimescaleDB skills for AI coding agents (alias: `add`) - `tiger feedback` - Submit feedback or a bug report to the Tiger Data team (pass the message as an argument or on stdin) - `tiger version` - Show version information - `tiger upgrade` - Upgrade the Tiger CLI to the latest version (alias: `update`) @@ -247,6 +252,33 @@ To disable the documentation proxy: tiger config set docs_mcp false ``` +## Agent Skills + +Tiger CLI can install curated PostgreSQL and TimescaleDB skills directly into +your coding agents: + +```bash +# Interactive installation (prompts for install locations) +tiger skills install + +# Or name the locations directly +tiger skills install universal claude-code +``` + +Skills are installed for the current user. The `universal` location, +`~/.agents/skills`, is read by most coding agents (Cursor, Codex, Gemini CLI, +VS Code, GitHub Copilot CLI, Devin). Claude Code, Kiro CLI, and Google +Antigravity read their own skills directories, so they're separate locations +(`claude-code`, `kiro-cli`, `antigravity`). Naming a client that reads +`~/.agents/skills`, such as `cursor`, selects `universal`. Use `--skills-dir` +to install into a directory of your choice instead. + +Re-run the command to update the skills to the latest version; skills that are +no longer available are removed. With no arguments, the locations you've +installed to before are selected in the picker to start with. Existing skills +that weren't installed by Tiger CLI are never replaced unless you pass +`--force`. + ## Configuration The CLI stores configuration in `~/.config/tiger/config.yaml` by default, and supports hierarchical configuration through environment variables and command-line flags. diff --git a/docs/cli/tiger.md b/docs/cli/tiger.md index d79573f3..474b6276 100644 --- a/docs/cli/tiger.md +++ b/docs/cli/tiger.md @@ -34,5 +34,6 @@ To get started, run: * [tiger mcp](tiger_mcp.md) - Tiger Model Context Protocol (MCP) server * [tiger project](tiger_project.md) - Manage Tiger Cloud projects * [tiger service](tiger_service.md) - Manage database services +* [tiger skills](tiger_skills.md) - Manage agent skills for AI coding agents * [tiger upgrade](tiger_upgrade.md) - Upgrade the Tiger CLI to the latest version * [tiger version](tiger_version.md) - Show version information diff --git a/docs/cli/tiger_skills.md b/docs/cli/tiger_skills.md new file mode 100644 index 00000000..31fbfeb7 --- /dev/null +++ b/docs/cli/tiger_skills.md @@ -0,0 +1,32 @@ +## tiger skills + +Manage agent skills for AI coding agents + +### Synopsis + +Manage agent skills for AI coding agents. + +Skills give AI coding agents curated knowledge and best practices for working +with PostgreSQL, TimescaleDB, and Tiger Cloud. + +### Options + +``` + -h, --help help for skills +``` + +### Options inherited from parent commands + +``` + --analytics enable/disable usage analytics (default true) + --color enable colored output (default true) + --config-dir string config directory (default "~/.config/tiger") + --password-storage string password storage method (keyring, pgpass, none) (default "keyring") + --service-id string service ID + --version-check check for updates on startup (default true) +``` + +### SEE ALSO + +* [tiger](tiger.md) - Tiger CLI - Tiger Cloud Platform command-line interface +* [tiger skills install](tiger_skills_install.md) - Install agent skills for AI coding agents diff --git a/docs/cli/tiger_skills_install.md b/docs/cli/tiger_skills_install.md new file mode 100644 index 00000000..d3067edd --- /dev/null +++ b/docs/cli/tiger_skills_install.md @@ -0,0 +1,65 @@ +## tiger skills install + +Install agent skills for AI coding agents + +### Synopsis + +Install agent skills for AI coding agents. + +Skills are installed for the current user into one or more install locations: +the universal ~/.agents/skills directory, which most coding agents read, and +the skills directories of clients that don't read it. Each argument selects a +location, either by name or by the name of a client that reads it. + +Install locations: + universal ~/.agents/skills (Cursor, Devin, Codex, Gemini CLI, VS Code, GitHub Copilot CLI) + claude-code ~/.claude/skills (Claude Code) + antigravity ~/.gemini/config/skills (Google Antigravity) + kiro-cli ~/.kiro/skills (Kiro CLI) + +With no arguments, you're prompted to select locations interactively, starting +from the locations Tiger CLI has installed skills to before. + +Re-running the command updates the installed skills to the latest version and +removes any that are no longer available. Existing skills that weren't +installed by Tiger CLI are never replaced unless --force is given. + +``` +tiger skills install [client...] [flags] +``` + +### Examples + +``` + # Interactive selection + tiger skills install + + # Install to ~/.agents/skills and for Claude Code + tiger skills install universal claude-code + + # Install into a custom skills directory + tiger skills install --skills-dir ~/my-skills +``` + +### Options + +``` + --force Replace existing skills that weren't installed by Tiger CLI + -h, --help help for install + --skills-dir string Install into this skills directory only (not remembered for later installs) +``` + +### Options inherited from parent commands + +``` + --analytics enable/disable usage analytics (default true) + --color enable colored output (default true) + --config-dir string config directory (default "~/.config/tiger") + --password-storage string password storage method (keyring, pgpass, none) (default "keyring") + --service-id string service ID + --version-check check for updates on startup (default true) +``` + +### SEE ALSO + +* [tiger skills](tiger_skills.md) - Manage agent skills for AI coding agents diff --git a/docs/development.md b/docs/development.md index 16dd2470..f1ee7a13 100644 --- a/docs/development.md +++ b/docs/development.md @@ -136,7 +136,7 @@ Tiger CLI is a Go-based command-line interface for managing Tiger resources. The - **Entry Point**: `cmd/tiger/main.go` - Simple main that delegates to cmd.Execute() - **Command Structure**: `internal/cmd/` - Cobra-based command definitions for all - CLI commands (auth, project, service, db, config, mcp, version, upgrade). Each command + CLI commands (auth, project, service, db, config, mcp, skills, version, upgrade). Each command lives in its own file, named to match the command in snake_case (`tiger service create` → `service_create.go`). `root.go` holds the root command, global flags, and `wrapCommands`, which gives every command the same diff --git a/go.mod b/go.mod index de330920..ce4834be 100644 --- a/go.mod +++ b/go.mod @@ -11,10 +11,11 @@ tool ( ) require ( - charm.land/bubbletea/v2 v2.0.9 + charm.land/bubbletea/v2 v2.1.0 charm.land/lipgloss/v2 v2.0.6 github.com/Masterminds/semver/v3 v3.5.0 github.com/charmbracelet/colorprofile v0.4.3 + github.com/charmbracelet/x/ansi v0.11.9 github.com/cli/safeexec v1.0.1 github.com/docker/go-units v0.5.0 github.com/google/go-cmp v0.7.0 @@ -40,8 +41,7 @@ require ( github.com/BurntSushi/toml v1.6.0 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20260903151058-ae99b731b8c5 // indirect - github.com/charmbracelet/x/ansi v0.11.8 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect @@ -65,7 +65,7 @@ require ( github.com/lucasb-eyer/go-colorful v1.4.1 // indirect github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.24 // indirect - github.com/mattn/go-runewidth v0.0.29 // indirect + github.com/mattn/go-runewidth v0.0.30 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 // indirect github.com/oasdiff/yaml v0.1.1 // indirect diff --git a/go.sum b/go.sum index 816fbe74..4cb04628 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8= -charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss= +charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw= +charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU= charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= @@ -16,10 +16,10 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/ultraviolet v0.0.0-20260903151058-ae99b731b8c5 h1:omidyaLW3GwtN5N35bPZWCxygynfhMRO2dTJ+OCcjLg= -github.com/charmbracelet/ultraviolet v0.0.0-20260903151058-ae99b731b8c5/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro= -github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= -github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= +github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 h1:hISLzUbwcvJbGoNR6A3L/TpguRRnACBmZOee8LcHLJw= +github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio= +github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM= +github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= @@ -125,8 +125,8 @@ github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= -github.com/mattn/go-runewidth v0.0.29 h1:3oGF3R/S2N9DQ3ptftzVIvg2eicmojCzlwBEmqEPDfQ= -github.com/mattn/go-runewidth v0.0.29/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk= +github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4= github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= diff --git a/internal/cmd/client_helper.go b/internal/cmd/client_helper.go new file mode 100644 index 00000000..fb8ecc58 --- /dev/null +++ b/internal/cmd/client_helper.go @@ -0,0 +1,169 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "strings" +) + +// MCPClient represents our internal client types +type MCPClient string + +const ( + ClaudeCode MCPClient = "claude-code" + Cursor MCPClient = "cursor" // Both the IDE and the CLI + Devin MCPClient = "devin" + Codex MCPClient = "codex" + Gemini MCPClient = "gemini" + VSCode MCPClient = "vscode" + Antigravity MCPClient = "antigravity" + KiroCLI MCPClient = "kiro-cli" + Copilot MCPClient = "copilot" // Both the IDE and the CLI +) + +// clientConfig represents a supported client, for both MCP server and skills +// installation +type clientConfig struct { + ClientType MCPClient // Our internal client type + Name string + EditorNames []string // Supported client names for this client + MCPServersPathPrefix string // JSON path prefix for MCP servers config (only for JSON config manipulation clients like Cursor) + MCPConfigPaths []string // Config file locations - used for backup on all clients, and for JSON manipulation on JSON-config clients + // buildMCPInstallCommand builds the CLI install command for CLI-based clients + // Parameters: serverName (name to register), command (binary path), args (arguments to binary) + buildMCPInstallCommand func(serverName, command string, args []string) ([]string, error) + // SkillsDir is the user-level skills directory of a client that doesn't + // read the universal ~/.agents/skills. Empty for clients that do. + SkillsDir string + // SkillsDirEnv overrides SkillsDir for clients whose skills directory can + // be relocated by an env var. It's a path containing env var references + // (e.g. "${CLAUDE_CONFIG_DIR}/skills"), used only when every variable it + // references is set and non-empty, and it expands to an absolute path. + SkillsDirEnv string +} + +// BuildMCPInstallCommand constructs the install command with the given parameters +func (c *clientConfig) BuildMCPInstallCommand(serverName, command string, args []string) ([]string, error) { + if c.buildMCPInstallCommand == nil { + return nil, nil + } + return c.buildMCPInstallCommand(serverName, command, args) +} + +// supportedClients defines the clients we support for Tiger MCP installation +// Note: A good place to find the json config location for MCPServersPathPrefix +// is in the supportedClientIntegrations map found in: +// https://github.com/stacklok/toolhive/blob/main/pkg/client/config.go +var supportedClients = []clientConfig{ + { + ClientType: ClaudeCode, + Name: "Claude Code", + EditorNames: []string{"claude-code"}, + MCPConfigPaths: []string{ + "~/.claude.json", + }, + buildMCPInstallCommand: func(serverName, command string, args []string) ([]string, error) { + return append([]string{"claude", "mcp", "add", "-s", "user", serverName, command}, args...), nil + }, + SkillsDir: "~/.claude/skills", + SkillsDirEnv: "${CLAUDE_CONFIG_DIR}/skills", + }, + { + ClientType: Cursor, + Name: "Cursor", + EditorNames: []string{"cursor"}, + MCPServersPathPrefix: "/mcpServers", + MCPConfigPaths: []string{ + "~/.cursor/mcp.json", + }, + }, + { + ClientType: Devin, + Name: "Devin", + EditorNames: []string{"devin"}, + MCPConfigPaths: []string{ + "~/.config/devin/mcp_config.json", + }, + buildMCPInstallCommand: func(serverName, command string, args []string) ([]string, error) { + return append([]string{"devin", "mcp", "add", "-s", "user", serverName, "--", command}, args...), nil + }, + }, + { + ClientType: Codex, + Name: "Codex", + EditorNames: []string{"codex"}, + MCPConfigPaths: []string{ + "~/.codex/config.toml", + "$CODEX_HOME/config.toml", + }, + buildMCPInstallCommand: func(serverName, command string, args []string) ([]string, error) { + return append([]string{"codex", "mcp", "add", serverName, command}, args...), nil + }, + }, + { + ClientType: Gemini, + Name: "Gemini CLI", + EditorNames: []string{"gemini", "gemini-cli"}, + MCPConfigPaths: []string{ + "~/.gemini/settings.json", + }, + buildMCPInstallCommand: func(serverName, command string, args []string) ([]string, error) { + return append([]string{"gemini", "mcp", "add", "-s", "user", serverName, command}, args...), nil + }, + }, + { + ClientType: VSCode, + Name: "VS Code", + EditorNames: []string{"vscode", "code", "vs-code"}, + MCPConfigPaths: []string{ + "~/.config/Code/User/mcp.json", + "~/Library/Application Support/Code/User/mcp.json", + "~/AppData/Roaming/Code/User/mcp.json", + }, + buildMCPInstallCommand: func(serverName, command string, args []string) ([]string, error) { + j, err := json.Marshal(map[string]any{ + "name": serverName, + "command": command, + "args": args, + }) + if err != nil { + return nil, fmt.Errorf("failed to marshal MCP config: %w", err) + } + return []string{"code", "--add-mcp", string(j)}, nil + }, + }, + { + ClientType: Antigravity, + Name: "Google Antigravity", + EditorNames: []string{"antigravity", "agy"}, + MCPServersPathPrefix: "/mcpServers", + MCPConfigPaths: []string{ + "~/.gemini/config/mcp_config.json", + }, + SkillsDir: "~/.gemini/config/skills", + }, + { + ClientType: KiroCLI, + Name: "Kiro CLI", + EditorNames: []string{"kiro-cli"}, + MCPConfigPaths: []string{ + "~/.kiro/settings/mcp.json", + }, + buildMCPInstallCommand: func(serverName, command string, args []string) ([]string, error) { + return []string{"kiro-cli", "mcp", "add", "--name", serverName, "--command", command, "--args", strings.Join(args, ",")}, nil + }, + SkillsDir: "~/.kiro/skills", + SkillsDirEnv: "${KIRO_HOME}/skills", + }, + { + ClientType: Copilot, + Name: "GitHub Copilot CLI", + EditorNames: []string{"copilot", "copilot-cli"}, + MCPConfigPaths: []string{ + "~/.copilot/mcp-config.json", + }, + buildMCPInstallCommand: func(serverName, command string, args []string) ([]string, error) { + return append([]string{"copilot", "mcp", "add", serverName, "--", command}, args...), nil + }, + }, +} diff --git a/internal/cmd/mcp_install.go b/internal/cmd/mcp_install.go index d790c39b..ab3b688b 100644 --- a/internal/cmd/mcp_install.go +++ b/internal/cmd/mcp_install.go @@ -93,21 +93,6 @@ If no client is specified, you'll be prompted to select one interactively.`, gen return cmd } -// MCPClient represents our internal client types -type MCPClient string - -const ( - ClaudeCode MCPClient = "claude-code" - Cursor MCPClient = "cursor" // Both the IDE and the CLI - Devin MCPClient = "devin" - Codex MCPClient = "codex" - Gemini MCPClient = "gemini" - VSCode MCPClient = "vscode" - Antigravity MCPClient = "antigravity" - KiroCLI MCPClient = "kiro-cli" - Copilot MCPClient = "copilot" // Both the IDE and the CLI -) - // MCPServerConfig represents the MCP server configuration type MCPServerConfig struct { Command string `json:"command"` @@ -130,139 +115,6 @@ type InstallOptions struct { CustomConfigPath string } -// clientConfig represents our own client configuration for Tiger MCP installation -type clientConfig struct { - ClientType MCPClient // Our internal client type - Name string - EditorNames []string // Supported client names for this client - MCPServersPathPrefix string // JSON path prefix for MCP servers config (only for JSON config manipulation clients like Cursor) - ConfigPaths []string // Config file locations - used for backup on all clients, and for JSON manipulation on JSON-config clients - // buildInstallCommand builds the CLI install command for CLI-based clients - // Parameters: serverName (name to register), command (binary path), args (arguments to binary) - buildInstallCommand func(serverName, command string, args []string) ([]string, error) -} - -// BuildInstallCommand constructs the install command with the given parameters -func (c *clientConfig) BuildInstallCommand(serverName, command string, args []string) ([]string, error) { - if c.buildInstallCommand == nil { - return nil, nil - } - return c.buildInstallCommand(serverName, command, args) -} - -// supportedClients defines the clients we support for Tiger MCP installation -// Note: A good place to find the json config location for MCPServersPathPrefix -// is in the supportedClientIntegrations map found in: -// https://github.com/stacklok/toolhive/blob/main/pkg/client/config.go -var supportedClients = []clientConfig{ - { - ClientType: ClaudeCode, - Name: "Claude Code", - EditorNames: []string{"claude-code"}, - ConfigPaths: []string{ - "~/.claude.json", - }, - buildInstallCommand: func(serverName, command string, args []string) ([]string, error) { - return append([]string{"claude", "mcp", "add", "-s", "user", serverName, command}, args...), nil - }, - }, - { - ClientType: Cursor, - Name: "Cursor", - EditorNames: []string{"cursor"}, - MCPServersPathPrefix: "/mcpServers", - ConfigPaths: []string{ - "~/.cursor/mcp.json", - }, - }, - { - ClientType: Devin, - Name: "Devin", - EditorNames: []string{"devin"}, - ConfigPaths: []string{ - "~/.config/devin/mcp_config.json", - }, - buildInstallCommand: func(serverName, command string, args []string) ([]string, error) { - return append([]string{"devin", "mcp", "add", "-s", "user", serverName, "--", command}, args...), nil - }, - }, - { - ClientType: Codex, - Name: "Codex", - EditorNames: []string{"codex"}, - ConfigPaths: []string{ - "~/.codex/config.toml", - "$CODEX_HOME/config.toml", - }, - buildInstallCommand: func(serverName, command string, args []string) ([]string, error) { - return append([]string{"codex", "mcp", "add", serverName, command}, args...), nil - }, - }, - { - ClientType: Gemini, - Name: "Gemini CLI", - EditorNames: []string{"gemini", "gemini-cli"}, - ConfigPaths: []string{ - "~/.gemini/settings.json", - }, - buildInstallCommand: func(serverName, command string, args []string) ([]string, error) { - return append([]string{"gemini", "mcp", "add", "-s", "user", serverName, command}, args...), nil - }, - }, - { - ClientType: VSCode, - Name: "VS Code", - EditorNames: []string{"vscode", "code", "vs-code"}, - ConfigPaths: []string{ - "~/.config/Code/User/mcp.json", - "~/Library/Application Support/Code/User/mcp.json", - "~/AppData/Roaming/Code/User/mcp.json", - }, - buildInstallCommand: func(serverName, command string, args []string) ([]string, error) { - j, err := json.Marshal(map[string]any{ - "name": serverName, - "command": command, - "args": args, - }) - if err != nil { - return nil, fmt.Errorf("failed to marshal MCP config: %w", err) - } - return []string{"code", "--add-mcp", string(j)}, nil - }, - }, - { - ClientType: Antigravity, - Name: "Google Antigravity", - EditorNames: []string{"antigravity", "agy"}, - MCPServersPathPrefix: "/mcpServers", - ConfigPaths: []string{ - "~/.gemini/antigravity/mcp_config.json", - }, - }, - { - ClientType: KiroCLI, - Name: "Kiro CLI", - EditorNames: []string{"kiro-cli"}, - ConfigPaths: []string{ - "~/.kiro/settings/mcp.json", - }, - buildInstallCommand: func(serverName, command string, args []string) ([]string, error) { - return []string{"kiro-cli", "mcp", "add", "--name", serverName, "--command", command, "--args", strings.Join(args, ",")}, nil - }, - }, - { - ClientType: Copilot, - Name: "GitHub Copilot CLI", - EditorNames: []string{"copilot", "copilot-cli"}, - ConfigPaths: []string{ - "~/.copilot/mcp-config.json", - }, - buildInstallCommand: func(serverName, command string, args []string) ([]string, error) { - return append([]string{"copilot", "mcp", "add", serverName, "--", command}, args...), nil - }, - }, -} - // getValidEditorNames returns all valid client names from supportedClients func getValidEditorNames() []string { var validNames []string @@ -322,17 +174,17 @@ func InstallMCPForClient(opts InstallOptions) error { if opts.CustomConfigPath != "" { // Expand custom config path for ~ and environment variables, then use it directly configPath = util.ExpandPath(opts.CustomConfigPath) - } else if len(clientCfg.ConfigPaths) > 0 { + } else if len(clientCfg.MCPConfigPaths) > 0 { // Use manual config path discovery for clients with configured paths - configPath, err = findClientConfigFile(clientCfg.ConfigPaths) + configPath, err = findClientConfigFile(clientCfg.MCPConfigPaths) if err != nil { return fmt.Errorf("failed to find configuration for %s: %w", opts.ClientName, err) } - } else if clientCfg.buildInstallCommand == nil { - // Client has neither ConfigPaths nor buildInstallCommand - return fmt.Errorf("client %s has no ConfigPaths or buildInstallCommand defined", opts.ClientName) + } else if clientCfg.buildMCPInstallCommand == nil { + // Client has neither MCPConfigPaths nor buildMCPInstallCommand + return fmt.Errorf("client %s has no MCPConfigPaths or buildMCPInstallCommand defined", opts.ClientName) } - // else: CLI-only client - configPath remains empty, will use buildInstallCommand + // else: CLI-only client - configPath remains empty, will use buildMCPInstallCommand // Create backup if requested and we have a config file if opts.CreateBackup && configPath != "" { @@ -343,7 +195,7 @@ func InstallMCPForClient(opts InstallOptions) error { } // Add MCP server to configuration - if clientCfg.buildInstallCommand != nil { + if clientCfg.buildMCPInstallCommand != nil { // Use CLI approach when install command builder is configured if err := addMCPServerViaCLI(clientCfg, opts.ServerName, opts.Command, opts.Args); err != nil { return fmt.Errorf("failed to add MCP server configuration: %w", err) @@ -384,8 +236,8 @@ func installTigerMCPForClient(cmd *cobra.Command, clientName string, createBacku configPath := customConfigPath if configPath == "" { clientCfg, _ := findClientConfig(clientName) - if clientCfg != nil && len(clientCfg.ConfigPaths) > 0 { - configPath, _ = findClientConfigFile(clientCfg.ConfigPaths) + if clientCfg != nil && len(clientCfg.MCPConfigPaths) > 0 { + configPath, _ = findClientConfigFile(clientCfg.MCPConfigPaths) } } @@ -628,12 +480,12 @@ func (m clientSelectModel) View() tea.View { // addMCPServerViaCLI adds an MCP server using a CLI command configured in clientConfig func addMCPServerViaCLI(clientCfg *clientConfig, serverName, command string, args []string) error { - if clientCfg.buildInstallCommand == nil { + if clientCfg.buildMCPInstallCommand == nil { return fmt.Errorf("no install command configured for client %s", clientCfg.Name) } // Build the install command with the provided parameters - installCommand, err := clientCfg.BuildInstallCommand(serverName, command, args) + installCommand, err := clientCfg.BuildMCPInstallCommand(serverName, command, args) if err != nil { return fmt.Errorf("failed to build install command: %w", err) } diff --git a/internal/cmd/mcp_install_test.go b/internal/cmd/mcp_install_test.go index 28aef0e9..58f50bdf 100644 --- a/internal/cmd/mcp_install_test.go +++ b/internal/cmd/mcp_install_test.go @@ -429,12 +429,12 @@ func TestFindClientConfig(t *testing.T) { } // Every client needs an install mechanism: JSON patching (path // prefix) or a CLI install command. - if found.MCPServersPathPrefix == "" && found.buildInstallCommand == nil { - t.Errorf("%s: needs MCPServersPathPrefix or buildInstallCommand", cfg.ClientType) + if found.MCPServersPathPrefix == "" && found.buildMCPInstallCommand == nil { + t.Errorf("%s: needs MCPServersPathPrefix or buildMCPInstallCommand", cfg.ClientType) } // CLI-only clients (no config paths) must have an install command. - if len(found.ConfigPaths) == 0 && found.buildInstallCommand == nil { - t.Errorf("%s: CLI-only clients must have buildInstallCommand", cfg.ClientType) + if len(found.MCPConfigPaths) == 0 && found.buildMCPInstallCommand == nil { + t.Errorf("%s: CLI-only clients must have buildMCPInstallCommand", cfg.ClientType) } } }) @@ -508,15 +508,15 @@ func TestFindClientConfigFile(t *testing.T) { t.Run("per-client fallback paths", func(t *testing.T) { t.Setenv("HOME", t.TempDir()) for _, cfg := range supportedClients { - if len(cfg.ConfigPaths) == 0 { + if len(cfg.MCPConfigPaths) == 0 { continue } - got, err := findClientConfigFile(cfg.ConfigPaths) + got, err := findClientConfigFile(cfg.MCPConfigPaths) if err != nil { t.Errorf("%s: unexpected error: %v", cfg.Name, err) continue } - if want := util.ExpandPath(cfg.ConfigPaths[0]); got != want { + if want := util.ExpandPath(cfg.MCPConfigPaths[0]); got != want { t.Errorf("%s: fallback = %q, want %q", cfg.Name, got, want) } } @@ -539,7 +539,7 @@ func TestAddMCPServerViaCLI(t *testing.T) { cfg := &clientConfig{ ClientType: "test-client", Name: "Test Client", - buildInstallCommand: func(serverName, command string, args []string) ([]string, error) { + buildMCPInstallCommand: func(serverName, command string, args []string) ([]string, error) { return []string{"nonexistent-command-12345", "arg1", "arg2"}, nil }, } @@ -566,7 +566,7 @@ func TestAddMCPServerViaCLI(t *testing.T) { Copilot: {"copilot", "mcp", "add", "tiger", "--", "/path/to/tiger", "mcp", "start"}, } for _, cfg := range supportedClients { - if cfg.buildInstallCommand == nil { + if cfg.buildMCPInstallCommand == nil { continue } wantCmd, ok := want[cfg.ClientType] @@ -574,7 +574,7 @@ func TestAddMCPServerViaCLI(t *testing.T) { t.Errorf("%s: CLI-based client missing from expected command table", cfg.ClientType) continue } - got, err := cfg.BuildInstallCommand("tiger", "/path/to/tiger", []string{"mcp", "start"}) + got, err := cfg.BuildMCPInstallCommand("tiger", "/path/to/tiger", []string{"mcp", "start"}) if err != nil { t.Errorf("%s: unexpected error: %v", cfg.ClientType, err) continue diff --git a/internal/cmd/root.go b/internal/cmd/root.go index c4890b8d..ff92590c 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -113,6 +113,7 @@ To get started, run: cmd.AddCommand(buildServiceCmd(app)) cmd.AddCommand(buildDbCmd(app)) cmd.AddCommand(buildMCPCmd(app)) + cmd.AddCommand(buildSkillsCmd(app)) cmd.AddCommand(buildFeedbackCmd(app)) wrapCommands(cmd, app, stdoutWriter, stderrWriter) diff --git a/internal/cmd/skills.go b/internal/cmd/skills.go new file mode 100644 index 00000000..7f6cd9cc --- /dev/null +++ b/internal/cmd/skills.go @@ -0,0 +1,22 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/timescale/tiger-cli/internal/common" +) + +func buildSkillsCmd(app *common.App) *cobra.Command { + cmd := &cobra.Command{ + Use: "skills", + Short: "Manage agent skills for AI coding agents", + Long: `Manage agent skills for AI coding agents. + +Skills give AI coding agents curated knowledge and best practices for working +with PostgreSQL, TimescaleDB, and Tiger Cloud.`, + } + + cmd.AddCommand(buildSkillsInstallCmd(app)) + + return cmd +} diff --git a/internal/cmd/skills_install.go b/internal/cmd/skills_install.go new file mode 100644 index 00000000..dccf9588 --- /dev/null +++ b/internal/cmd/skills_install.go @@ -0,0 +1,871 @@ +package cmd + +import ( + "archive/tar" + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "os" + "path" + "path/filepath" + "slices" + "strconv" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + lipgloss "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/spf13/cobra" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/common" + "github.com/timescale/tiger-cli/internal/util" +) + +const ( + skillsRepo = "timescale/pg-aiguide" + skillsRepoRef = "main" + // skillsRepoDir is the directory within the repo holding one subdirectory + // per skill. + skillsRepoDir = "skills" + + // skillMarkerFile is written into every skill Tiger CLI installs, marking + // it as one a later install may replace or remove. + skillMarkerFile = ".tiger-cli" + skillMarkerContent = "This skill is managed by Tiger CLI. Running 'tiger skills install' updates it\n" + + "to the latest version, or removes it if it's no longer available.\n" + + // skillsTempPrefix starts the name of every temporary directory an install + // creates in the skills directory. + skillsTempPrefix = ".tiger-" +) + +// skillsTarballURL is the GitHub REST API endpoint for the skills repo's +// tarball. GitHub answers it with a redirect to a short-lived download URL. +// It's a var so tests can point it at a local server. +var skillsTarballURL = "https://api.github.com/repos/" + skillsRepo + "/tarball/" + skillsRepoRef + +func buildSkillsInstallCmd(_ *common.App) *cobra.Command { + var force bool + var skillsDirFlag string + + cmd := &cobra.Command{ + Use: "install [client...]", + Aliases: []string{"add"}, + Short: "Install agent skills for AI coding agents", + Long: fmt.Sprintf(`Install agent skills for AI coding agents. + +Skills are installed for the current user into one or more install locations: +the universal ~/.agents/skills directory, which most coding agents read, and +the skills directories of clients that don't read it. Each argument selects a +location, either by name or by the name of a client that reads it. + +%s +With no arguments, you're prompted to select locations interactively, starting +from the locations Tiger CLI has installed skills to before. + +Re-running the command updates the installed skills to the latest version and +removes any that are no longer available. Existing skills that weren't +installed by Tiger CLI are never replaced unless --force is given.`, generateSkillsTargetsHelp()), + Example: ` # Interactive selection + tiger skills install + + # Install to ~/.agents/skills and for Claude Code + tiger skills install universal claude-code + + # Install into a custom skills directory + tiger skills install --skills-dir ~/my-skills`, + ValidArgs: skillsTargetNames(skillsTargets()), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if skillsDirFlag != "" && len(args) > 0 { + return errors.New("--skills-dir can't be combined with client arguments") + } + + var dirs []string + var err error + switch { + case skillsDirFlag != "": + dirs, err = skillsDirsFromFlag(skillsDirFlag) + case len(args) > 0: + dirs, err = skillsDirsFromArgs(args) + default: + dirs, err = skillsDirsFromPicker(cmd) + } + if err != nil { + return err + } + + skills, err := fetchSkills(cmd.Context()) + if err != nil { + return err + } + + if !force { + if err := checkSkillConflicts(skills, dirs); err != nil { + return err + } + } + + removed := make([][]string, len(dirs)) + for i, dir := range dirs { + if removed[i], err = installSkills(skills, dir); err != nil { + return err + } + } + + cmd.Printf("Installed %d skills:\n", len(skills)) + for _, s := range skills { + cmd.Printf(" %s\n", s.name) + } + cmd.Printf("\nInstalled to:\n") + for _, dir := range dirs { + cmd.Printf(" %s\n", dir) + } + for i, dir := range dirs { + if len(removed[i]) == 0 { + continue + } + cmd.Printf("\nRemoved from %s (no longer available):\n", dir) + for _, name := range removed[i] { + cmd.Printf(" %s\n", name) + } + } + cmd.Printf("\nRestart your coding agents to load the new skills.\n") + return nil + }, + } + + cmd.Flags().BoolVar(&force, "force", false, "Replace existing skills that weren't installed by Tiger CLI") + cmd.Flags().StringVar(&skillsDirFlag, "skills-dir", "", "Install into this skills directory only (not remembered for later installs)") + registerFlagCompletion(cmd, "skills-dir", dirCompletion) + + return cmd +} + +// generateSkillsTargetsHelp generates the install locations section of the +// help text. +func generateSkillsTargetsHelp() string { + var b strings.Builder + b.WriteString("Install locations:\n") + for _, t := range skillsTargets() { + fmt.Fprintf(&b, " %-24s %s (%s)\n", t.names[0], t.dir, strings.Join(t.readers, ", ")) + } + return b.String() +} + +// skillsTarget is a location skills can be installed to: the universal +// directory most clients read, or the skills directory of a client that +// doesn't read it. +type skillsTarget struct { + label string // display name + names []string // names that select it on the command line; the first is canonical + readers []string // display names of the clients that read it + dir string // see clientConfig.SkillsDir + dirEnv string // see clientConfig.SkillsDirEnv +} + +// skillsTargets returns every install location: universal first, then each +// client with a skills directory of its own, in supportedClients order. +func skillsTargets() []skillsTarget { + targets := []skillsTarget{{ + label: "Universal", + names: []string{"universal"}, + dir: "~/.agents/skills", + }} + for _, c := range supportedClients { + if c.SkillsDir == "" { + targets[0].names = append(targets[0].names, c.EditorNames...) + targets[0].readers = append(targets[0].readers, c.Name) + continue + } + targets = append(targets, skillsTarget{ + label: c.Name, + names: c.EditorNames, + readers: []string{c.Name}, + dir: c.SkillsDir, + dirEnv: c.SkillsDirEnv, + }) + } + return targets +} + +// skillsTargetNames returns every name that selects one of targets. +func skillsTargetNames(targets []skillsTarget) []string { + var names []string + for _, t := range targets { + names = append(names, t.names...) + } + return names +} + +// skillsDirsFromFlag returns the directory given by --skills-dir, made +// absolute (relative to the working directory). +func skillsDirsFromFlag(path string) ([]string, error) { + dir, err := filepath.Abs(util.ExpandPath(path)) + if err != nil { + return nil, fmt.Errorf("failed to resolve skills directory: %w", err) + } + return []string{dir}, nil +} + +// skillsDirsFromArgs returns the directories of the install locations the +// given names select, in skillsTargets order and without duplicates. Names +// match case-insensitively. +func skillsDirsFromArgs(names []string) ([]string, error) { + targets := skillsTargets() + selected := make([]bool, len(targets)) + for _, name := range names { + i := slices.IndexFunc(targets, func(t skillsTarget) bool { + return slices.ContainsFunc(t.names, func(n string) bool { return strings.EqualFold(n, name) }) + }) + if i < 0 { + return nil, fmt.Errorf("unsupported client: %s. Supported clients: %s", name, strings.Join(skillsTargetNames(targets), ", ")) + } + selected[i] = true + } + var result []skillsTarget + for i, t := range targets { + if selected[i] { + result = append(result, t) + } + } + return resolveSkillsTargetDirs(result) +} + +// skillsPickerItem is one install location offered by the picker. +type skillsPickerItem struct { + label string + dir string // as displayed + selected bool +} + +// skillsDirsFromPicker prompts the user to choose install locations, starting +// from the ones preselectSkillsDirs picks, and returns the chosen +// directories. It fails without a terminal to prompt on. +func skillsDirsFromPicker(cmd *cobra.Command) ([]string, error) { + if !util.IsTerminal(cmd.InOrStdin()) || !util.IsTerminal(cmd.ErrOrStderr()) { + return nil, errors.New("TTY not detected - specify install locations as arguments (e.g. 'tiger skills install universal')") + } + targets := skillsTargets() + dirs, err := resolveSkillsTargetDirs(targets) + if err != nil { + return nil, err + } + selected := preselectSkillsDirs(dirs) + items := make([]skillsPickerItem, len(targets)) + for i, t := range targets { + items[i] = skillsPickerItem{ + label: t.label, + dir: displayPath(dirs[i]), + selected: selected[i], + } + } + header := ansi.Wordwrap(fmt.Sprintf("Select where to install agent skills. %s installs to %s, which %s read.", + targets[0].label, items[0].dir, joinWithAnd(append(slices.Clone(targets[0].readers), "many other agents"))), 80, "") + + chosen, err := runSkillsPicker(cmd, header, items) + if err != nil { + return nil, err + } + var result []string + for i, dir := range dirs { + if chosen[i] { + result = append(result, dir) + } + } + if len(result) == 0 { + return nil, errors.New("no install locations selected") + } + return result, nil +} + +// resolveSkillsTargetDirs returns the absolute directory of each target. An +// env var override that doesn't give an absolute path is ignored, since +// there's no telling what a relative one is relative to. +func resolveSkillsTargetDirs(targets []skillsTarget) ([]string, error) { + dirs := make([]string, len(targets)) + for i, t := range targets { + if t.dirEnv != "" { + if expanded, ok := expandEnvStrict(t.dirEnv); ok && filepath.IsAbs(expanded) { + dirs[i] = filepath.Clean(expanded) + continue + } + } + // ExpandPath leaves a ~ in place if the home directory is unknown, + // which would otherwise install relative to the working directory. + dir := util.ExpandPath(t.dir) + if !filepath.IsAbs(dir) { + return nil, fmt.Errorf("failed to determine skills directory: %s is not an absolute path", dir) + } + dirs[i] = dir + } + return dirs, nil +} + +// expandEnvStrict expands env var references in s, reporting false if any +// referenced variable is unset or empty. +func expandEnvStrict(s string) (string, bool) { + ok := true + expanded := os.Expand(s, func(name string) string { + v := os.Getenv(name) + if v == "" { + ok = false + } + return v + }) + return expanded, ok +} + +// preselectSkillsDirs reports which of dirs the picker starts with +// selected: those holding skills Tiger CLI installed, or the first (universal) +// if none do. +func preselectSkillsDirs(dirs []string) []bool { + selected := make([]bool, len(dirs)) + found := false + for i, dir := range dirs { + selected[i] = hasInstalledSkills(dir) + found = found || selected[i] + } + if !found { + selected[0] = true + } + return selected +} + +// hasInstalledSkills reports whether skillsDir holds any skill Tiger CLI +// installed. Anything that can't be inspected counts as not installed: this +// only decides what the picker starts with, and installing to skillsDir would +// report the underlying problem. +func hasInstalledSkills(skillsDir string) bool { + entries, err := os.ReadDir(skillsDir) + if err != nil { + return false + } + for _, e := range entries { + if strings.HasPrefix(e.Name(), skillsTempPrefix) { + continue + } + if _, owned, err := skillOwnership(filepath.Join(skillsDir, e.Name())); err == nil && owned { + return true + } + } + return false +} + +// displayPath abbreviates the home directory in path to ~. +func displayPath(path string) string { + home, err := os.UserHomeDir() + if err != nil { + return path + } + if rel, ok := strings.CutPrefix(path, home+string(filepath.Separator)); ok { + return filepath.Join("~", rel) + } + return path +} + +// joinWithAnd joins items into an English list ("a, b, and c"). +func joinWithAnd(items []string) string { + switch len(items) { + case 0: + return "" + case 1: + return items[0] + case 2: + return items[0] + " and " + items[1] + } + return strings.Join(items[:len(items)-1], ", ") + ", and " + items[len(items)-1] +} + +// runSkillsPicker runs the interactive install location picker over items and +// returns which of them the user chose. It's a var so command tests can stub +// the interactive part. +var runSkillsPicker = func(cmd *cobra.Command, header string, items []skillsPickerItem) ([]bool, error) { + model := skillsPickerModel{ + header: header, + items: items, + } + program := tea.NewProgram(model, + tea.WithInput(cmd.InOrStdin()), + tea.WithOutput(cmd.ErrOrStderr()), + tea.WithContext(cmd.Context()), + tea.WithoutSignalHandler()) + final, err := program.Run() + if err != nil { + return nil, fmt.Errorf("failed to run install location selection: %w", err) + } + + result := final.(skillsPickerModel) + if !result.confirmed { + return nil, errors.New("installation cancelled") + } + chosen := make([]bool, len(result.items)) + for i, item := range result.items { + chosen[i] = item.selected + } + return chosen, nil +} + +// skillsPickerModel is the Bubble Tea model for the install location picker. +type skillsPickerModel struct { + header string + items []skillsPickerItem + cursor int + confirmed bool + done bool // set on quitting, however that happens, to clear the picker +} + +func (m skillsPickerModel) Init() tea.Cmd { + return nil +} + +func (m skillsPickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + key, ok := msg.(tea.KeyPressMsg) + if !ok { + return m, nil + } + switch k := key.String(); k { + case "ctrl+c", "q", "esc": + m.done = true + return m, tea.Quit + case "enter": + m.confirmed = true + m.done = true + return m, tea.Quit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.items)-1 { + m.cursor++ + } + case "space": + m.items[m.cursor].selected = !m.items[m.cursor].selected + case "1", "2", "3", "4", "5", "6", "7", "8", "9": + if i := int(k[0] - '1'); i < len(m.items) { + m.cursor = i + m.items[i].selected = !m.items[i].selected + } + } + return m, nil +} + +// Styles for the install location picker, taken from the terminal theme. +// Bright colors and bold are avoided because some themes render them as greys, +// and dim text uses the faint attribute so it suits light and dark themes +// alike. +var ( + skillsPickerCursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Blue) + skillsPickerCheckStyle = lipgloss.NewStyle().Foreground(lipgloss.Green) + skillsPickerDimStyle = lipgloss.NewStyle().Faint(true) +) + +func (m skillsPickerModel) View() tea.View { + if m.done { + return tea.NewView("") + } + width := 0 + for _, item := range m.items { + width = max(width, len(item.label)) + } + + var b strings.Builder + b.WriteString(m.header + "\n\n") + for i, item := range m.items { + cursor := " " + if m.cursor == i { + cursor = skillsPickerCursorStyle.Render(">") + } + check := " " + if item.selected { + check = skillsPickerCheckStyle.Render("✓") + } + fmt.Fprintf(&b, "%s [%s] %d. %-*s %s\n", cursor, check, i+1, width, item.label, skillsPickerDimStyle.Render(item.dir)) + } + b.WriteString("\n" + skillsPickerDimStyle.Render("Use ↑/↓ to navigate, space or number keys to toggle, enter to confirm, q to quit")) + return tea.NewView(b.String()) +} + +// skill is one skill directory, with its contents held in memory. +type skill struct { + name string + files []skillFile +} + +// skillFile is a regular file within a skill, with symlinks already resolved. +type skillFile struct { + path string // slash-separated, relative to the skill directory + executable bool + data []byte +} + +// fetchSkills downloads the skills repo's tarball from GitHub and extracts the +// skills from it. +func fetchSkills(ctx context.Context) ([]skill, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, skillsTarballURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := api.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to download skills: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, skillsDownloadError(resp) + } + + skills, err := extractSkills(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read skills archive: %w", err) + } + if err := resp.Body.Close(); err != nil { + return nil, fmt.Errorf("failed to read skills archive: %w", err) + } + if len(skills) == 0 { + return nil, errors.New("no skills found") + } + return skills, nil +} + +// skillsDownloadError describes a failed tarball request, calling out GitHub's +// rate limit for unauthenticated requests. +func skillsDownloadError(resp *http.Response) error { + if (resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests) && + resp.Header.Get("X-RateLimit-Remaining") == "0" { + if reset, err := strconv.ParseInt(resp.Header.Get("X-RateLimit-Reset"), 10, 64); err == nil { + return fmt.Errorf("failed to download skills: GitHub API rate limit exceeded. Try again after %s", + time.Unix(reset, 0).Local().Format("15:04 MST")) + } + return errors.New("failed to download skills: GitHub API rate limit exceeded. Try again later") + } + return fmt.Errorf("failed to download skills: unexpected status from GitHub: %s", resp.Status) +} + +// extractSkills reads a gzipped tarball of the skills repo and returns its +// skills, sorted by name. A skill is a subdirectory of skillsRepoDir holding a +// SKILL.md. Symlinks within skillsRepoDir are resolved to the files they point +// at, since skills share files that way and an installed skill must stand +// alone; anything else in the repo is ignored. +func extractSkills(r io.Reader) ([]skill, error) { + gz, err := gzip.NewReader(r) + if err != nil { + return nil, err + } + defer gz.Close() + + a := &repoArchive{ + files: map[string]archiveFile{}, + links: map[string]string{}, + } + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, err + } + + // GitHub tarballs nest everything under a single top-level directory + // named after the repo and commit. ValidPath keeps names safe to + // join onto the install directory later. + _, name, _ := strings.Cut(hdr.Name, "/") + if !strings.HasPrefix(name, skillsRepoDir+"/") || !fs.ValidPath(name) { + continue + } + + switch hdr.Typeflag { + case tar.TypeReg: + data, err := io.ReadAll(tr) + if err != nil { + return nil, err + } + a.files[name] = archiveFile{ + executable: hdr.Mode&0o111 != 0, + data: data, + } + case tar.TypeSymlink: + // An absolute target can't refer to a file in the repo. + if path.IsAbs(hdr.Linkname) { + continue + } + a.links[name] = path.Join(path.Dir(name), hdr.Linkname) + } + } + + if err := gz.Close(); err != nil { + return nil, err + } + + var skills []skill + for _, name := range a.children(skillsRepoDir) { + s := skill{name: name} + a.walk(path.Join(skillsRepoDir, name), "", map[string]bool{}, func(f skillFile) { + s.files = append(s.files, f) + }) + if !slices.ContainsFunc(s.files, func(f skillFile) bool { return f.path == "SKILL.md" }) { + continue + } + skills = append(skills, s) + } + return skills, nil +} + +// repoArchive indexes the regular files and symlinks under skillsRepoDir in +// the repo's tarball by their slash-separated path within the repo. +type repoArchive struct { + files map[string]archiveFile + links map[string]string // symlink path -> target path +} + +// archiveFile is a regular file in the repo's tarball. +type archiveFile struct { + executable bool + data []byte +} + +// walk emits every regular file reachable at src, which may be a file, a +// symlink, or a directory, with paths rebased from src onto dst. following +// holds the symlinks being resolved on the way to src, so a symlink that leads +// back to one of them (a cycle) is skipped, as are dangling symlinks. +func (a *repoArchive) walk(src, dst string, following map[string]bool, emit func(skillFile)) { + if f, ok := a.files[src]; ok { + if dst != "" { + emit(skillFile{ + path: dst, + executable: f.executable, + data: f.data, + }) + } + return + } + if target, ok := a.links[src]; ok { + if !following[src] { + following[src] = true + a.walk(target, dst, following, emit) + delete(following, src) + } + return + } + for _, child := range a.children(src) { + a.walk(path.Join(src, child), path.Join(dst, child), following, emit) + } +} + +// children returns the sorted names of the entries directly inside dir. +func (a *repoArchive) children(dir string) []string { + prefix := dir + "/" + seen := map[string]bool{} + var names []string + add := func(p string) { + rest, ok := strings.CutPrefix(p, prefix) + if !ok { + return + } + child, _, _ := strings.Cut(rest, "/") + if !seen[child] { + seen[child] = true + names = append(names, child) + } + } + for p := range a.files { + add(p) + } + for p := range a.links { + add(p) + } + slices.Sort(names) + return names +} + +// checkSkillConflicts returns an error naming every place in dirs where a +// skill would replace something Tiger CLI didn't install. +func checkSkillConflicts(skills []skill, dirs []string) error { + var conflicts []string + for _, dir := range dirs { + for _, s := range skills { + p := filepath.Join(dir, s.name) + exists, owned, err := skillOwnership(p) + if err != nil { + return fmt.Errorf("failed to check existing skill %s: %w", p, err) + } + if exists && !owned { + conflicts = append(conflicts, p) + } + } + } + if len(conflicts) > 0 { + return fmt.Errorf("skills already exist and weren't installed by Tiger CLI: %s. Use --force to replace them", + strings.Join(conflicts, ", ")) + } + return nil +} + +// installSkills writes each skill into skillsDir, replacing any existing copy, +// then removes skills Tiger CLI installed earlier that are no longer among +// them, returning their names. +func installSkills(skills []skill, skillsDir string) ([]string, error) { + if err := os.MkdirAll(skillsDir, 0o755); err != nil { + return nil, fmt.Errorf("failed to create skills directory: %w", err) + } + for _, s := range skills { + if err := writeSkill(filepath.Join(skillsDir, s.name), s); err != nil { + return nil, fmt.Errorf("failed to install skill %s: %w", s.name, err) + } + } + + removed, err := removeStaleSkills(skills, skillsDir) + if err != nil { + return nil, fmt.Errorf("failed to remove skills that are no longer available: %w", err) + } + return removed, nil +} + +// writeSkill writes s to dir along with the marker file, replacing whatever +// is there. The new copy is built in a temporary directory beside dir and +// swapped in, so a failure partway through leaves the existing copy intact. +func writeSkill(dir string, s skill) error { + tmp, err := os.MkdirTemp(filepath.Dir(dir), skillsTempPrefix+"new-"+s.name+"-") + if err != nil { + return err + } + defer os.RemoveAll(tmp) + + for _, f := range s.files { + p := filepath.Join(tmp, filepath.FromSlash(f.path)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return err + } + mode := fs.FileMode(0o644) + if f.executable { + mode = 0o755 + } + if err := os.WriteFile(p, f.data, mode); err != nil { + return err + } + } + if err := os.WriteFile(filepath.Join(tmp, skillMarkerFile), []byte(skillMarkerContent), 0o644); err != nil { + return err + } + // MkdirTemp creates the directory 0700; skills are ordinary user files. + if err := os.Chmod(tmp, 0o755); err != nil { + return err + } + if err := replacePath(tmp, dir); err != nil { + return err + } + return os.RemoveAll(tmp) +} + +// replacePath moves src to dst, replacing any file, directory, or symlink at +// dst. A rename can't replace a non-empty directory, so the existing entry is +// first moved aside, and restored if the swap fails. If restoring fails too, +// it's left where it was moved and the error says where. A symlink at dst is +// removed as a link, never followed. +func replacePath(src, dst string) error { + if _, err := os.Lstat(dst); errors.Is(err, fs.ErrNotExist) { + return os.Rename(src, dst) + } else if err != nil { + return err + } + + aside, err := os.MkdirTemp(filepath.Dir(dst), skillsTempPrefix+"old-") + if err != nil { + return err + } + keepAside := false + defer func() { + if !keepAside { + os.RemoveAll(aside) + } + }() + + old := filepath.Join(aside, "old") + if err := os.Rename(dst, old); err != nil { + return err + } + if err := os.Rename(src, dst); err != nil { + if restoreErr := os.Rename(old, dst); restoreErr != nil { + keepAside = true + return fmt.Errorf("%w (and failed to restore %s, so the previous copy was left at %s: %w)", err, dst, old, restoreErr) + } + return err + } + return os.RemoveAll(aside) +} + +// removeStaleSkills removes the skills in skillsDir that Tiger CLI installed +// but that aren't among skills, returning their names. It also quietly removes +// temporary directories left behind by an install that was killed partway +// through. +func removeStaleSkills(skills []skill, skillsDir string) ([]string, error) { + current := make(map[string]bool, len(skills)) + for _, s := range skills { + current[s.name] = true + } + + entries, err := os.ReadDir(skillsDir) + if err != nil { + return nil, err + } + var removed []string + for _, e := range entries { + if !e.IsDir() || current[e.Name()] { + continue + } + dir := filepath.Join(skillsDir, e.Name()) + if strings.HasPrefix(e.Name(), skillsTempPrefix) { + if err := os.RemoveAll(dir); err != nil { + return nil, err + } + continue + } + _, owned, err := skillOwnership(dir) + if err != nil { + return nil, err + } + if !owned { + continue + } + if err := os.RemoveAll(dir); err != nil { + return nil, err + } + removed = append(removed, e.Name()) + } + return removed, nil +} + +// skillOwnership reports whether anything exists at dir, and whether it's a +// skill Tiger CLI installed: a directory (not a symlink, since Tiger CLI never +// creates those) holding the marker file. +func skillOwnership(dir string) (exists, owned bool, err error) { + info, err := os.Lstat(dir) + if errors.Is(err, fs.ErrNotExist) { + return false, false, nil + } else if err != nil { + return false, false, err + } + if !info.IsDir() { + return true, false, nil + } + info, err = os.Lstat(filepath.Join(dir, skillMarkerFile)) + if errors.Is(err, fs.ErrNotExist) { + return true, false, nil + } else if err != nil { + return true, false, err + } + return true, info.Mode().IsRegular(), nil +} diff --git a/internal/cmd/skills_install_test.go b/internal/cmd/skills_install_test.go new file mode 100644 index 00000000..ae2a07e2 --- /dev/null +++ b/internal/cmd/skills_install_test.go @@ -0,0 +1,740 @@ +package cmd + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "errors" + "fmt" + "io/fs" + "maps" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/google/go-cmp/cmp" + "github.com/spf13/cobra" +) + +// tarEntry is one entry of a test tarball, named relative to the repo root. +type tarEntry struct { + name string + body string + link string // symlink target, if set + mode int64 // file mode; 0o644 if unset +} + +// skillsTarball builds a gzipped tarball shaped like GitHub's: a pax global +// header followed by everything nested under a top-level directory named after +// the repo and commit. +func skillsTarball(t *testing.T, entries ...tarEntry) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + headers := []*tar.Header{ + {Name: "pax_global_header", Typeflag: tar.TypeXGlobalHeader, PAXRecords: map[string]string{"comment": "abc123"}}, + {Name: "timescale-pg-aiguide-abc123/", Typeflag: tar.TypeDir, Mode: 0o755}, + } + for _, e := range headers { + if err := tw.WriteHeader(e); err != nil { + t.Fatal(err) + } + } + for _, e := range entries { + hdr := &tar.Header{Name: "timescale-pg-aiguide-abc123/" + e.name, Mode: e.mode} + if hdr.Mode == 0 { + hdr.Mode = 0o644 + } + switch { + case e.link != "": + hdr.Typeflag = tar.TypeSymlink + hdr.Linkname = e.link + case e.name[len(e.name)-1] == '/': + hdr.Typeflag = tar.TypeDir + hdr.Mode = 0o755 + default: + hdr.Typeflag = tar.TypeReg + hdr.Size = int64(len(e.body)) + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if hdr.Typeflag == tar.TypeReg { + if _, err := tw.Write([]byte(e.body)); err != nil { + t.Fatal(err) + } + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// withSkillsServer points the skills download at a local server standing in +// for GitHub. The server expects the tarball API request, then answers it +// with handle. +func withSkillsServer(handle http.HandlerFunc) runOption { + return withSkillsServerDownload(handle, nil) +} + +// withSkillsTarball serves body as the repo tarball, via a redirect to a +// separate download URL as GitHub does. +func withSkillsTarball(body []byte) runOption { + return withSkillsServerDownload(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/download", http.StatusFound) + }, body) +} + +// withSkillsServerDownload is withSkillsServer, also serving download at +// /download if set. +func withSkillsServerDownload(handle http.HandlerFunc, download []byte) runOption { + return withSetup(func(t *testing.T) { + mux := http.NewServeMux() + if download != nil { + mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) { + w.Write(download) + }) + } + mux.HandleFunc("/repos/timescale/pg-aiguide/tarball/main", func(w http.ResponseWriter, r *http.Request) { + got := map[string]string{ + "method": r.Method, + "Accept": r.Header.Get("Accept"), + "X-GitHub-Api-Version": r.Header.Get("X-GitHub-Api-Version"), + } + want := map[string]string{ + "method": http.MethodGet, + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("tarball request mismatch (-want +got):\n%s", diff) + } + handle(w, r) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + original := skillsTarballURL + skillsTarballURL = srv.URL + "/repos/timescale/pg-aiguide/tarball/main" + t.Cleanup(func() { skillsTarballURL = original }) + }) +} + +// readTree returns every file and symlink under root, keyed by slash-separated +// path. Files map to their content (prefixed with "[executable] " if so) and +// symlinks to "-> target". Symlinks are not followed. +func readTree(t *testing.T, root string) map[string]string { + t.Helper() + tree := map[string]string{} + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(root, p) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + switch { + case d.Type()&fs.ModeSymlink != 0: + target, err := os.Readlink(p) + if err != nil { + return err + } + tree[rel] = "-> " + target + case d.Type().IsRegular(): + data, err := os.ReadFile(p) + if err != nil { + return err + } + info, err := d.Info() + if err != nil { + return err + } + if info.Mode().Perm()&0o111 != 0 { + tree[rel] = "[executable] " + string(data) + } else { + tree[rel] = string(data) + } + } + return nil + }) + if err != nil { + t.Fatalf("failed to read tree %s: %v", root, err) + } + return tree +} + +// checkTree asserts the exact files and symlinks under root (see readTree). +func checkTree(root string, want map[string]string) checkFunc { + return func(t *testing.T, _ cmdResult) { + t.Helper() + if diff := cmp.Diff(want, readTree(t, root)); diff != "" { + t.Errorf("tree %s mismatch (-want +got):\n%s", root, diff) + } + } +} + +// writeTree creates the given files and symlinks under root (see readTree for +// the format, minus executable bits). +func writeTree(t *testing.T, root string, tree map[string]string) { + t.Helper() + for rel, content := range tree { + p := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if target, ok := strings.CutPrefix(content, "-> "); ok { + if err := os.Symlink(target, p); err != nil { + t.Fatal(err) + } + continue + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } +} + +// skillsInstallOutput is the exact stdout of a successful install of the +// fixture skills into dirs, with removed holding any sections reporting +// removed skills. +func skillsInstallOutput(dirs []string, removed ...string) string { + var b strings.Builder + b.WriteString("Installed 2 skills:\n alpha\n beta\n\nInstalled to:\n") + for _, dir := range dirs { + b.WriteString(" " + dir + "\n") + } + for _, r := range removed { + b.WriteString("\n" + r) + } + b.WriteString("\nRestart your coding agents to load the new skills.\n") + return b.String() +} + +// withRunSkillsPicker stubs the interactive install location picker: it +// expects to be offered exactly want, and answers with choose. A nil want +// expects the picker not to be shown at all. +func withRunSkillsPicker(want []skillsPickerItem, choose []bool) runOption { + return withSetup(func(t *testing.T) { + original := runSkillsPicker + runSkillsPicker = func(_ *cobra.Command, _ string, items []skillsPickerItem) ([]bool, error) { + if want == nil { + t.Error("install location picker shown, want none") + return nil, errors.New("picker shown") + } + if diff := cmp.Diff(want, items, cmp.AllowUnexported(skillsPickerItem{})); diff != "" { + t.Errorf("picker items mismatch (-want +got):\n%s", diff) + } + return choose, nil + } + t.Cleanup(func() { runSkillsPicker = original }) + }) +} + +// pickerItems returns the items the picker offers with the default install +// locations (the home directory shown as ~), selecting those in selected. +func pickerItems(selected ...string) []skillsPickerItem { + items := []skillsPickerItem{ + {label: "Universal", dir: "~/.agents/skills"}, + {label: "Claude Code", dir: "~/.claude/skills"}, + {label: "Google Antigravity", dir: "~/.gemini/config/skills"}, + {label: "Kiro CLI", dir: "~/.kiro/skills"}, + } + for i := range items { + items[i].selected = slices.Contains(selected, items[i].label) + } + return items +} + +func TestSkillsInstallCmd(t *testing.T) { + // The fixture repo exercises skill discovery and symlink resolution: + // file and directory symlinks between skills (resolved), and ones that + // leave skills/, are absolute, dangle, or loop (skipped), plus entries + // that aren't skills. + tarball := skillsTarball(t, + tarEntry{name: "README.md", body: "repo readme"}, + tarEntry{name: "skills/"}, + tarEntry{name: "skills/README.md", body: "not a skill"}, + tarEntry{name: "skills/alpha/"}, + tarEntry{name: "skills/alpha/SKILL.md", body: "alpha skill"}, + tarEntry{name: "skills/alpha/references/guide.md", body: "alpha guide"}, + tarEntry{name: "skills/alpha/scripts/run.sh", body: "#!/bin/sh", mode: 0o755}, + tarEntry{name: "skills/beta/SKILL.md", body: "beta skill"}, + tarEntry{name: "skills/beta/references/alpha-guide.md", link: "../../alpha/references/guide.md"}, + tarEntry{name: "skills/beta/references/alpha-refs", link: "../../alpha/references"}, + tarEntry{name: "skills/beta/references/readme.md", link: "../../../README.md"}, + tarEntry{name: "skills/beta/references/absolute.md", link: "/etc/passwd"}, + tarEntry{name: "skills/beta/references/dangling.md", link: "missing.md"}, + tarEntry{name: "skills/beta/references/loop.md", link: "loop.md"}, + tarEntry{name: "skills/beta/references/loop-dir", link: "."}, + tarEntry{name: "skills/no-skill-md/README.md", body: "no SKILL.md"}, + ) + // installedSkills is the tree the fixture installs into dir (relative to + // the home directory). + installedSkills := func(dir string) map[string]string { + return map[string]string{ + dir + "/alpha/.tiger-cli": skillMarkerContent, + dir + "/alpha/SKILL.md": "alpha skill", + dir + "/alpha/references/guide.md": "alpha guide", + dir + "/alpha/scripts/run.sh": "[executable] #!/bin/sh", + dir + "/beta/.tiger-cli": skillMarkerContent, + dir + "/beta/SKILL.md": "beta skill", + dir + "/beta/references/alpha-guide.md": "alpha guide", + dir + "/beta/references/alpha-refs/guide.md": "alpha guide", + // A directory symlink cycle is followed once, then cut off. + dir + "/beta/references/loop-dir/alpha-guide.md": "alpha guide", + dir + "/beta/references/loop-dir/alpha-refs/guide.md": "alpha guide", + } + } + + // home creates a home directory seeded with tree (see writeTree). + home := func(tree map[string]string) string { + dir := t.TempDir() + writeTree(t, dir, tree) + return dir + } + // withTrees merges trees into one. + withTrees := func(trees ...map[string]string) map[string]string { + merged := map[string]string{} + for _, tree := range trees { + maps.Copy(merged, tree) + } + return merged + } + + universalHome := home(nil) + claudeHome := home(nil) + multiHome := home(nil) + claudeConfigHome := home(nil) + claudeConfigDir := filepath.Join(claudeConfigHome, "claude-config") + claudeEmptyEnvHome := home(nil) + claudeRelativeEnvHome := home(nil) + claudePickerEnvHome := home(nil) + antigravityHome := home(nil) + aliasHome := home(nil) + universalPickerHome := home(nil) + customHome := home(nil) + + // existingClaude is an earlier install for Claude Code, which the picker + // starts with selected. + existingClaude := installedSkills(".claude/skills") + existingClaudeHome := home(existingClaude) + pickerHome := home(withTrees(installedSkills(".agents/skills"), existingClaude)) + unreadableHome := home(existingClaude) + + // reinstallHome holds a previous install: one of our skills with a stale + // file (replaced), one no longer available (removed), skills of the + // user's, hidden or not, and a symlink of the user's to a skill of ours + // (kept), and both kinds of temporary directory left by an install that + // was killed (quietly removed). + reinstallHome := t.TempDir() + writeTree(t, reinstallHome, map[string]string{ + ".claude/skills/alpha/.tiger-cli": skillMarkerContent, + ".claude/skills/alpha/SKILL.md": "old alpha", + ".claude/skills/alpha/stale.md": "stale", + ".claude/skills/retired/.tiger-cli": skillMarkerContent, + ".claude/skills/retired/SKILL.md": "retired skill", + ".claude/skills/other/SKILL.md": "user's skill", + ".claude/skills/.tiger-new-beta-123/.tiger-cli": skillMarkerContent, + ".claude/skills/.tiger-new-beta-123/SKILL.md": "leftover", + ".claude/skills/.tiger-old-456/old/.tiger-cli": skillMarkerContent, + ".claude/skills/.tiger-old-456/old/SKILL.md": "leftover", + ".claude/skills/.hidden/SKILL.md": "user's hidden entry", + ".claude/skills/linked": "-> " + filepath.Join(reinstallHome, "marked"), + "marked/.tiger-cli": skillMarkerContent, + "marked/SKILL.md": "marked skill", + }) + reinstallTree := withTrees(installedSkills(".claude/skills"), map[string]string{ + ".claude/skills/other/SKILL.md": "user's skill", + ".claude/skills/.hidden/SKILL.md": "user's hidden entry", + ".claude/skills/linked": "-> " + filepath.Join(reinstallHome, "marked"), + "marked/.tiger-cli": skillMarkerContent, + "marked/SKILL.md": "marked skill", + }) + + // conflictTree holds things of the user's under the names of skills being + // installed, in both of the locations being installed to, which aren't + // replaced without --force. The symlink counts as the user's even though + // it points at one of our skills, since Tiger CLI never creates symlinks. + conflictHome := t.TempDir() + conflictTree := map[string]string{ + ".agents/skills/alpha": "-> " + filepath.Join(conflictHome, "marked"), + ".agents/skills/beta/.tiger-cli": skillMarkerContent, + ".agents/skills/beta/SKILL.md": "our beta", + ".claude/skills/beta/SKILL.md": "user's beta", + "marked/.tiger-cli": skillMarkerContent, + "marked/SKILL.md": "marked skill", + } + writeTree(t, conflictHome, conflictTree) + + // forceHome holds a symlink and a directory of the user's under the names + // of skills being installed. --force replaces both; the symlink is + // replaced as a link, leaving its target alone. + forceHome := home(map[string]string{ + ".agents/skills/alpha": "-> " + filepath.Join("..", "..", "elsewhere"), + ".agents/skills/beta/SKILL.md": "user's beta", + ".agents/skills/beta/stale.md": "stale", + "elsewhere/SKILL.md": "symlink target", + }) + forceTree := withTrees(installedSkills(".agents/skills"), map[string]string{"elsewhere/SKILL.md": "symlink target"}) + + universalDir := func(home string) string { return filepath.Join(home, ".agents", "skills") } + claudeDir := func(home string) string { return filepath.Join(home, ".claude", "skills") } + + rateLimitReset := time.Date(2030, 1, 1, 15, 4, 0, 0, time.UTC).Unix() + + runCmdTests(t, []cmdTest{ + { + name: "--skills-dir with client arguments", + args: []string{"skills", "install", "claude-code", "--skills-dir", "/tmp/skills"}, + wantErr: "--skills-dir can't be combined with client arguments", + }, + { + name: "unsupported client", + args: []string{"skills", "install", "claude-code", "bogus"}, + wantErr: "unsupported client: bogus. Supported clients: universal, cursor, devin, codex, gemini, gemini-cli, vscode, code, vs-code, copilot, copilot-cli, claude-code, antigravity, agy, kiro-cli", + }, + { + name: "no arguments and no TTY", + args: []string{"skills", "install"}, + opts: []runOption{withEnv("HOME", home(nil))}, + wantErr: "TTY not detected - specify install locations as arguments (e.g. 'tiger skills install universal')", + }, + { + name: "nothing selected in the picker", + args: []string{"skills", "install"}, + opts: []runOption{ + withEnv("HOME", home(nil)), + withIsTerminal(true), + withRunSkillsPicker(pickerItems("Universal"), []bool{false, false, false, false}), + }, + wantErr: "no install locations selected", + }, + { + name: "download fails", + args: []string{"skills", "install", "universal"}, + opts: []runOption{ + withEnv("HOME", home(nil)), + withSkillsServer(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }), + }, + wantErr: "failed to download skills: unexpected status from GitHub: 500 Internal Server Error", + }, + { + name: "rate limited", + args: []string{"skills", "install", "universal"}, + opts: []runOption{ + withEnv("HOME", home(nil)), + withSkillsServer(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-RateLimit-Remaining", "0") + w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(rateLimitReset, 10)) + w.WriteHeader(http.StatusForbidden) + }), + }, + wantErr: "failed to download skills: GitHub API rate limit exceeded. Try again after 15:04 UTC", + }, + { + name: "invalid archive", + args: []string{"skills", "install", "universal"}, + opts: []runOption{ + withEnv("HOME", home(nil)), + withSkillsServer(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("not a tarball")) + }), + }, + wantErr: "failed to read skills archive: gzip: invalid header", + }, + { + name: "no skills in archive", + args: []string{"skills", "install", "universal"}, + opts: []runOption{ + withEnv("HOME", home(nil)), + withSkillsTarball(skillsTarball(t, tarEntry{name: "README.md", body: "repo readme"})), + }, + wantErr: "no skills found", + }, + { + name: "existing skills not installed by Tiger CLI", + args: []string{"skills", "install", "universal", "claude-code"}, + opts: []runOption{withEnv("HOME", conflictHome), withSkillsTarball(tarball)}, + wantErr: fmt.Sprintf("skills already exist and weren't installed by Tiger CLI: %s, %s. Use --force to replace them", + filepath.Join(universalDir(conflictHome), "alpha"), filepath.Join(claudeDir(conflictHome), "beta")), + checks: []checkFunc{checkTree(conflictHome, conflictTree)}, + }, + { + name: "installs to ~/.agents/skills", + args: []string{"skills", "install", "universal"}, + opts: []runOption{withEnv("HOME", universalHome), withSkillsTarball(tarball)}, + wantStdout: skillsInstallOutput([]string{universalDir(universalHome)}), + checks: []checkFunc{checkTree(universalHome, installedSkills(".agents/skills"))}, + }, + { + // Arguments select exactly the locations named, so the universal + // directory isn't written. + name: "installs to claude code's skills directory only", + args: []string{"skills", "install", "claude-code"}, + opts: []runOption{withEnv("HOME", claudeHome), withSkillsTarball(tarball)}, + wantStdout: skillsInstallOutput([]string{claudeDir(claudeHome)}), + checks: []checkFunc{checkTree(claudeHome, installedSkills(".claude/skills"))}, + }, + { + name: "installs to several locations", + args: []string{"skills", "install", "kiro-cli", "claude-code", "universal"}, + opts: []runOption{withEnv("HOME", multiHome), withSkillsTarball(tarball)}, + wantStdout: skillsInstallOutput([]string{ + universalDir(multiHome), + claudeDir(multiHome), + filepath.Join(multiHome, ".kiro", "skills"), + }), + checks: []checkFunc{checkTree(multiHome, withTrees( + installedSkills(".agents/skills"), + installedSkills(".claude/skills"), + installedSkills(".kiro/skills"), + ))}, + }, + { + name: "respects CLAUDE_CONFIG_DIR", + args: []string{"skills", "install", "claude-code"}, + opts: []runOption{ + withEnv("HOME", claudeConfigHome), + withEnv("CLAUDE_CONFIG_DIR", claudeConfigDir), + withSkillsTarball(tarball), + }, + wantStdout: skillsInstallOutput([]string{filepath.Join(claudeConfigDir, "skills")}), + checks: []checkFunc{checkTree(claudeConfigHome, installedSkills("claude-config/skills"))}, + }, + { + name: "empty CLAUDE_CONFIG_DIR counts as unset", + args: []string{"skills", "install", "claude-code"}, + opts: []runOption{ + withEnv("HOME", claudeEmptyEnvHome), + withEnv("CLAUDE_CONFIG_DIR", ""), + withSkillsTarball(tarball), + }, + wantStdout: skillsInstallOutput([]string{claudeDir(claudeEmptyEnvHome)}), + checks: []checkFunc{checkTree(claudeEmptyEnvHome, installedSkills(".claude/skills"))}, + }, + { + name: "relative CLAUDE_CONFIG_DIR is ignored", + args: []string{"skills", "install", "claude-code"}, + opts: []runOption{ + withEnv("HOME", claudeRelativeEnvHome), + withEnv("CLAUDE_CONFIG_DIR", "claude-config"), + withSkillsTarball(tarball), + }, + wantStdout: skillsInstallOutput([]string{claudeDir(claudeRelativeEnvHome)}), + checks: []checkFunc{checkTree(claudeRelativeEnvHome, installedSkills(".claude/skills"))}, + }, + { + name: "installs to antigravity's skills directory", + args: []string{"skills", "install", "antigravity"}, + opts: []runOption{withEnv("HOME", antigravityHome), withSkillsTarball(tarball)}, + wantStdout: skillsInstallOutput([]string{filepath.Join(antigravityHome, ".gemini", "config", "skills")}), + checks: []checkFunc{checkTree(antigravityHome, installedSkills(".gemini/config/skills"))}, + }, + { + // Client names that read the universal directory select it, once. + name: "add alias and case-insensitive client names", + args: []string{"skills", "add", "CURSOR", "codex"}, + opts: []runOption{withEnv("HOME", aliasHome), withSkillsTarball(tarball)}, + wantStdout: skillsInstallOutput([]string{universalDir(aliasHome)}), + checks: []checkFunc{checkTree(aliasHome, installedSkills(".agents/skills"))}, + }, + { + // Without other installs, the picker starts with universal + // selected. + name: "installs to universal from the picker", + args: []string{"skills", "install"}, + opts: []runOption{ + withEnv("HOME", universalPickerHome), + withIsTerminal(true), + withRunSkillsPicker(pickerItems("Universal"), []bool{true, false, false, false}), + withSkillsTarball(tarball), + }, + wantStdout: skillsInstallOutput([]string{universalDir(universalPickerHome)}), + checks: []checkFunc{checkTree(universalPickerHome, installedSkills(".agents/skills"))}, + }, + { + // An earlier install for Claude Code only is what the picker + // starts with, so universal isn't selected. + name: "picker starts from earlier installs", + args: []string{"skills", "install"}, + opts: []runOption{ + withEnv("HOME", existingClaudeHome), + withIsTerminal(true), + withRunSkillsPicker(pickerItems("Claude Code"), []bool{false, true, false, false}), + withSkillsTarball(tarball), + }, + wantStdout: skillsInstallOutput([]string{claudeDir(existingClaudeHome)}), + checks: []checkFunc{checkTree(existingClaudeHome, existingClaude)}, + }, + { + // A location that can't be inspected starts unselected, even + // though it holds an earlier install. The directory is made + // unreadable by the setup hook (and restored, so t.TempDir can + // clean up); root ignores file permissions, hence the skip. + name: "picker leaves locations it can't inspect unselected", + args: []string{"skills", "install"}, + opts: []runOption{ + withEnv("HOME", unreadableHome), + withIsTerminal(true), + withSetup(func(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("cannot test permission errors as root user") + } + dir := claudeDir(unreadableHome) + if err := os.Chmod(dir, 0o000); err != nil { + t.Fatalf("failed to chmod dir: %v", err) + } + t.Cleanup(func() { os.Chmod(dir, 0o755) }) + }), + withRunSkillsPicker(pickerItems("Universal"), []bool{true, false, false, false}), + withSkillsTarball(tarball), + }, + wantStdout: skillsInstallOutput([]string{universalDir(unreadableHome)}), + checks: []checkFunc{checkTree(filepath.Join(unreadableHome, ".agents"), installedSkills("skills"))}, + }, + { + // The picker shows where each location actually is, including an + // env var override. + name: "picker shows CLAUDE_CONFIG_DIR", + args: []string{"skills", "install"}, + opts: []runOption{ + withEnv("HOME", claudePickerEnvHome), + withEnv("CLAUDE_CONFIG_DIR", filepath.Join(claudePickerEnvHome, "claude-config")), + withIsTerminal(true), + withRunSkillsPicker([]skillsPickerItem{ + {label: "Universal", dir: "~/.agents/skills", selected: true}, + {label: "Claude Code", dir: "~/claude-config/skills"}, + {label: "Google Antigravity", dir: "~/.gemini/config/skills"}, + {label: "Kiro CLI", dir: "~/.kiro/skills"}, + }, []bool{false, true, false, false}), + withSkillsTarball(tarball), + }, + wantStdout: skillsInstallOutput([]string{filepath.Join(claudePickerEnvHome, "claude-config", "skills")}), + checks: []checkFunc{checkTree(claudePickerEnvHome, installedSkills("claude-config/skills"))}, + }, + { + // The picker starts from the earlier installs; what's chosen is + // installed, and an unchosen earlier install is left as it is. + name: "installs to the locations chosen in the picker", + args: []string{"skills", "install"}, + opts: []runOption{ + withEnv("HOME", pickerHome), + withIsTerminal(true), + withRunSkillsPicker(pickerItems("Universal", "Claude Code"), []bool{false, true, false, true}), + withSkillsTarball(tarball), + }, + wantStdout: skillsInstallOutput([]string{claudeDir(pickerHome), filepath.Join(pickerHome, ".kiro", "skills")}), + checks: []checkFunc{checkTree(pickerHome, withTrees( + installedSkills(".agents/skills"), + existingClaude, + installedSkills(".kiro/skills"), + ))}, + }, + { + name: "reinstall replaces our skills and removes ones no longer available", + args: []string{"skills", "install", "claude-code"}, + opts: []runOption{withEnv("HOME", reinstallHome), withSkillsTarball(tarball)}, + wantStdout: skillsInstallOutput([]string{claudeDir(reinstallHome)}, + fmt.Sprintf("Removed from %s (no longer available):\n retired\n", claudeDir(reinstallHome))), + checks: []checkFunc{ + checkTree(reinstallHome, reinstallTree), + // Running again over a current install leaves it unchanged. + func(t *testing.T, _ cmdResult) { + again := runCommand(t, []string{"skills", "install", "claude-code"}, nil) + if again.err != nil { + t.Fatalf("second install failed: %v", again.err) + } + assertOutput(t, again.stdout, skillsInstallOutput([]string{claudeDir(reinstallHome)})) + }, + checkTree(reinstallHome, reinstallTree), + }, + }, + { + name: "--force replaces skills not installed by Tiger CLI", + args: []string{"skills", "install", "universal", "--force"}, + opts: []runOption{withEnv("HOME", forceHome), withSkillsTarball(tarball)}, + wantStdout: skillsInstallOutput([]string{universalDir(forceHome)}), + checks: []checkFunc{checkTree(forceHome, forceTree)}, + }, + { + name: "--skills-dir installs there only", + args: []string{"skills", "install", "--skills-dir", "~/custom"}, + opts: []runOption{ + withEnv("HOME", customHome), + withIsTerminal(true), + withRunSkillsPicker(nil, nil), + withSkillsTarball(tarball), + }, + wantStdout: skillsInstallOutput([]string{filepath.Join(customHome, "custom")}), + checks: []checkFunc{checkTree(customHome, installedSkills("custom"))}, + }, + }) +} + +// TestSkillsPickerModel checks the picker's keys: toggling, moving, and that +// only enter confirms. Helper-level because the picker is a Bubble Tea model +// that needs a real TTY to run through the command; the command tests stub it +// via withRunSkillsPicker. +func TestSkillsPickerModel(t *testing.T) { + // Ctrl+C is {Code: 'c', Mod: tea.ModCtrl}; the raw control byte {Code: 3} + // stringifies to "\x03" and would match nothing. + cases := []struct { + name string + keys []tea.KeyPressMsg + wantSelected []bool + wantConfirmed bool + }{ + {"enter keeps the preselection", []tea.KeyPressMsg{{Code: tea.KeyEnter}}, []bool{true, false, false}, true}, + {"space toggles the current row", []tea.KeyPressMsg{{Code: tea.KeySpace}, {Code: tea.KeyEnter}}, []bool{false, false, false}, true}, + {"down then space toggles the next row", []tea.KeyPressMsg{{Code: tea.KeyDown}, {Code: tea.KeySpace}, {Code: tea.KeyEnter}}, []bool{true, true, false}, true}, + {"number keys toggle their row", []tea.KeyPressMsg{{Code: '3'}, {Code: '1'}, {Code: tea.KeyEnter}}, []bool{false, false, true}, true}, + {"number keys past the end do nothing", []tea.KeyPressMsg{{Code: '4'}, {Code: tea.KeyEnter}}, []bool{true, false, false}, true}, + {"cursor can't run past the end", []tea.KeyPressMsg{{Code: tea.KeyDown}, {Code: tea.KeyDown}, {Code: tea.KeyDown}, {Code: tea.KeySpace}, {Code: tea.KeyEnter}}, []bool{true, false, true}, true}, + {"q cancels", []tea.KeyPressMsg{{Code: tea.KeySpace}, {Code: 'q'}}, []bool{false, false, false}, false}, + {"esc cancels", []tea.KeyPressMsg{{Code: tea.KeyEsc}}, []bool{true, false, false}, false}, + {"ctrl+c cancels", []tea.KeyPressMsg{{Code: 'c', Mod: tea.ModCtrl}}, []bool{true, false, false}, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var m tea.Model = skillsPickerModel{items: []skillsPickerItem{ + {label: "Universal", dir: "~/.agents/skills", selected: true}, + {label: "Claude Code", dir: "~/.claude/skills"}, + {label: "Kiro CLI", dir: "~/.kiro/skills"}, + }} + for _, key := range tc.keys { + m, _ = m.Update(key) + } + result := m.(skillsPickerModel) + selected := make([]bool, len(result.items)) + for i, item := range result.items { + selected[i] = item.selected + } + if diff := cmp.Diff(tc.wantSelected, selected); diff != "" { + t.Errorf("selection mismatch (-want +got):\n%s", diff) + } + if result.confirmed != tc.wantConfirmed { + t.Errorf("confirmed = %v, want %v", result.confirmed, tc.wantConfirmed) + } + if !result.done { + t.Error("done = false, want true (every case ends by quitting)") + } + }) + } +}