diff --git a/README.md b/README.md index aab5769..67f5955 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,15 @@ tscli get --help tscli create key --help ``` +Initialize AI agent guidance and prompt surfaces globally by default, or pass `--dir .` for a repo-local install: + +```bash +tscli agent init +tscli agent update +tscli agent init --dir . +tscli agent update --dir . +``` + ## ✨ Highlights | Area | What you can do | @@ -122,6 +131,7 @@ The primary docs live in the in-repo Docsify site under `docs/`. Most useful pages: - `docs/getting-started.md`: install, first command, and flags +- `docs/agents.md`: global and repo-local AI agent integrations plus refresh workflow - `docs/configuration.md`: config keys, profiles, and precedence - `docs/authentication.md`: API-key auth methods and security guidance - `docs/command-reference.md`: generated command reference and workflow diff --git a/cmd/tscli/agent/cli.go b/cmd/tscli/agent/cli.go new file mode 100644 index 0000000..ade40f1 --- /dev/null +++ b/cmd/tscli/agent/cli.go @@ -0,0 +1,19 @@ +package agent + +import ( + agentinit "github.com/jaxxstorm/tscli/cmd/tscli/agent/init" + agentupdate "github.com/jaxxstorm/tscli/cmd/tscli/agent/update" + "github.com/spf13/cobra" +) + +func Command(root *cobra.Command) *cobra.Command { + command := &cobra.Command{ + Use: "agent", + Short: "Manage AI agent integrations for tscli", + Long: "Generate and refresh tscli-backed AI agent instructions, skills, prompts, and commands for either a repo-local checkout or global user-level tooling.", + } + + command.AddCommand(agentinit.Command(root)) + command.AddCommand(agentupdate.Command(root)) + return command +} diff --git a/cmd/tscli/agent/init/cli.go b/cmd/tscli/agent/init/cli.go new file mode 100644 index 0000000..8477b7f --- /dev/null +++ b/cmd/tscli/agent/init/cli.go @@ -0,0 +1,47 @@ +package init + +import ( + "fmt" + "strings" + + pkgagent "github.com/jaxxstorm/tscli/pkg/agent" + "github.com/spf13/cobra" +) + +func Command(root *cobra.Command) *cobra.Command { + var dir string + var tools []string + var force bool + + cmd := &cobra.Command{ + Use: "init", + Short: "Initialize AI agent integrations for tscli", + Long: "Generate tscli-backed AI agent instructions, command catalogs, and native prompt, skill, or command surfaces for supported tools.", + RunE: func(_ *cobra.Command, _ []string) error { + result, err := pkgagent.Init(root, pkgagent.InstallOptions{ + RootDir: dir, + Tools: tools, + Force: force, + }) + if err != nil { + return err + } + + fmt.Printf("tscli agent integrations initialized (%s) in %s\n", result.InstallScope, result.RootDir) + fmt.Printf("tools: %s\n", strings.Join(result.Tools, ", ")) + fmt.Printf("indexed leaf commands: %d\n", result.CommandCount) + fmt.Printf("manifest: %s\n", result.ManifestPath) + if result.InstallScope == pkgagent.ScopeLocal { + fmt.Printf("refresh with: tscli agent update --dir %s\n", result.RootDir) + } else { + fmt.Printf("refresh with: tscli agent update\n") + } + return nil + }, + } + + cmd.Flags().StringVar(&dir, "dir", "", "Optional repository root for a repo-local install; omit to install global user-level integrations") + cmd.Flags().StringSliceVar(&tools, "tool", nil, fmt.Sprintf("Tool integrations to install (supported: %s; availability depends on global vs local install target)", strings.Join(pkgagent.SupportedTools(), ", "))) + cmd.Flags().BoolVar(&force, "force", false, "Overwrite unmanaged files at generated target paths") + return cmd +} diff --git a/cmd/tscli/agent/update/cli.go b/cmd/tscli/agent/update/cli.go new file mode 100644 index 0000000..e5a3102 --- /dev/null +++ b/cmd/tscli/agent/update/cli.go @@ -0,0 +1,39 @@ +package update + +import ( + "fmt" + "strings" + + pkgagent "github.com/jaxxstorm/tscli/pkg/agent" + "github.com/spf13/cobra" +) + +func Command(root *cobra.Command) *cobra.Command { + var dir string + var force bool + + cmd := &cobra.Command{ + Use: "update", + Short: "Refresh AI agent integrations for tscli", + Long: "Refresh the generated tscli agent bundle using the tool selection recorded in the target manifest. Without --dir, update refreshes the global user-level install.", + RunE: func(_ *cobra.Command, _ []string) error { + result, err := pkgagent.Update(root, pkgagent.UpdateOptions{ + RootDir: dir, + Force: force, + }) + if err != nil { + return err + } + + fmt.Printf("tscli agent integrations updated (%s) in %s\n", result.InstallScope, result.RootDir) + fmt.Printf("tools: %s\n", strings.Join(result.Tools, ", ")) + fmt.Printf("indexed leaf commands: %d\n", result.CommandCount) + fmt.Printf("manifest: %s\n", result.ManifestPath) + return nil + }, + } + + cmd.Flags().StringVar(&dir, "dir", "", "Optional repository root containing a repo-local tscli agent manifest; omit to update the global install") + cmd.Flags().BoolVar(&force, "force", false, "Overwrite unmanaged files at generated target paths") + return cmd +} diff --git a/coverage/coverage-gaps.json b/coverage/coverage-gaps.json index 97f4fd8..fba8f5a 100644 --- a/coverage/coverage-gaps.json +++ b/coverage/coverage-gaps.json @@ -2,8 +2,10 @@ "openapi_operations": 85, "excluded_operations": [], "in_scope_operations": 85, - "manifest_commands": 89, + "manifest_commands": 91, "excluded_commands": [ + "agent init", + "agent update", "config get", "config profiles delete", "config profiles list", diff --git a/coverage/coverage-gaps.md b/coverage/coverage-gaps.md index b1daa0a..fbc812f 100644 --- a/coverage/coverage-gaps.md +++ b/coverage/coverage-gaps.md @@ -3,8 +3,8 @@ - OpenAPI operations: `85` - Excluded operations: `0` - In-scope operations: `85` -- Manifest commands: `89` -- Excluded commands: `9` +- Manifest commands: `91` +- Excluded commands: `11` - Covered operations: `85` - Uncovered operations: `0` - Covered commands: `80` diff --git a/coverage/exclusions.yaml b/coverage/exclusions.yaml index acdcf3a..d4e6aa4 100644 --- a/coverage/exclusions.yaml +++ b/coverage/exclusions.yaml @@ -3,6 +3,8 @@ operations: {} commands: + agent init: "Local agent bundle generation command; no Tailscale API call." + agent update: "Local agent bundle refresh command; no Tailscale API call." config get: "Local config lookup command; no Tailscale API call." config profiles delete: "Local profile management command; no Tailscale API call." config profiles list: "Local profile management command; no Tailscale API call." diff --git a/docs/README.md b/docs/README.md index f24bb83..cc791d8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ This docs site is the source of truth for setup, authentication, configuration, New users: - [Getting Started](getting-started.md) +- [AI Agent Integrations](agents.md) - [Authentication](authentication.md) - [Configuration](configuration.md) @@ -16,6 +17,7 @@ New users: Existing users: - [Command Reference](command-reference.md) +- [AI Agent Integrations](agents.md) - [Configuration](configuration.md) Contributors: diff --git a/docs/_sidebar.md b/docs/_sidebar.md index a38e8ac..769ab13 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -1,5 +1,6 @@ - [Home](/) - [Getting Started](getting-started.md) +- [AI Agents](agents.md) - [Command Reference](command-reference.md) - [Configuration](configuration.md) - [Authentication](authentication.md) diff --git a/docs/agents.md b/docs/agents.md new file mode 100644 index 0000000..8d318c9 --- /dev/null +++ b/docs/agents.md @@ -0,0 +1,77 @@ +# AI Agent Integrations + +`tscli agent init` bootstraps `tscli`-aware instructions, skills, prompts, and commands so AI agents can use `tscli` directly instead of ad hoc API calls. + +## What it generates + +The command writes a versioned bundle into either your user-level config directories or a target repository. + +Global install surfaces: + +- `.config/tscli/agent/manifest.yaml`: versioned manifest used by `tscli agent update` +- `.config/tscli/agent/commands.md`: generated catalog of the current `tscli` leaf commands +- `.codex/skills/tscli/SKILL.md`: Codex skill surface +- `.claude/commands/tscli-inspect.md`: Claude Code read-only slash command +- `.claude/commands/tscli-operate.md`: Claude Code mutation slash command +- `.config/opencode/commands/tscli-inspect.md`: OpenCode read-only command +- `.config/opencode/commands/tscli-operate.md`: OpenCode mutation command + +Repo-local install surfaces: + +- `AGENTS.md`: global instructions for agent-aware tools +- `CLAUDE.md`: Claude Code project memory +- `.tscli/agent/manifest.yaml`: versioned manifest used by `tscli agent update` +- `.tscli/agent/commands.md`: generated catalog of the current `tscli` leaf commands +- `.codex/skills/tscli/SKILL.md`: Codex skill surface +- `.github/skills/tscli/SKILL.md`: GitHub Copilot skill surface +- `.github/prompts/tscli-inspect.prompt.md`: GitHub Copilot read-only prompt +- `.github/prompts/tscli-operate.prompt.md`: GitHub Copilot mutation prompt + +## Initialize + +Install global user-level integrations: + +```bash +tscli agent init +``` + +Install repo-local integrations into another directory: + +```bash +tscli agent init --dir /path/to/repo +``` + +Restrict generation to selected tool surfaces: + +```bash +tscli agent init --dir /path/to/repo --tool codex --tool claude --tool opencode +``` + +Supported `--tool` values are `generic`, `codex`, `claude`, `opencode`, and `copilot`. + +Global installs support `codex`, `claude`, and `opencode`. + +Repo-local installs support `generic`, `codex`, `claude`, `opencode`, and `copilot`. + +## Update + +Refresh the global generated bundle after upgrading `tscli` or adding new commands: + +```bash +tscli agent update +``` + +Refresh a repo-local bundle: + +```bash +tscli agent update --dir /path/to/repo +``` + +`update` reads the scope-appropriate manifest to preserve the original tool selection and rewrite the managed files in place. + +## Operating model + +- Agents should prefer `tscli list ...`, `tscli get ...`, `tscli create ...`, `tscli set ...`, and `tscli delete ...` over raw API calls when a command exists. +- The generated command catalog is derived from the live Cobra command tree, so it stays aligned with the CLI surface. +- Global command surfaces are reusable user-level integrations; repo-local instruction files remain project-scoped. +- If a generated target path already contains an unmanaged file, rerun with `--force` only if you intend to replace it. diff --git a/docs/commands/README.md b/docs/commands/README.md index 807e995..85ba31f 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -13,6 +13,9 @@ make docs-generate ## Commands - [tscli](tscli.md) +- [tscli agent](tscli_agent.md) +- [tscli agent init](tscli_agent_init.md) +- [tscli agent update](tscli_agent_update.md) - [tscli config](tscli_config.md) - [tscli config get](tscli_config_get.md) - [tscli config profiles](tscli_config_profiles.md) diff --git a/docs/commands/_sidebar.md b/docs/commands/_sidebar.md index 7783e32..1a53953 100644 --- a/docs/commands/_sidebar.md +++ b/docs/commands/_sidebar.md @@ -1,6 +1,9 @@ - [tscli](tscli.md) +- [tscli agent](tscli_agent.md) +- [tscli agent init](tscli_agent_init.md) +- [tscli agent update](tscli_agent_update.md) - [tscli config](tscli_config.md) - [tscli config get](tscli_config_get.md) - [tscli config profiles](tscli_config_profiles.md) diff --git a/docs/commands/tscli.md b/docs/commands/tscli.md index 8769358..66af4a4 100644 --- a/docs/commands/tscli.md +++ b/docs/commands/tscli.md @@ -20,6 +20,7 @@ A CLI tool for interacting with the Tailscale API. ### SEE ALSO +* [tscli agent](tscli_agent.md) - Manage AI agent integrations for tscli * [tscli config](tscli_config.md) - Config commands * [tscli create](tscli_create.md) - Create commands * [tscli delete](tscli_delete.md) - Delete commands diff --git a/docs/commands/tscli_agent.md b/docs/commands/tscli_agent.md new file mode 100644 index 0000000..182d079 --- /dev/null +++ b/docs/commands/tscli_agent.md @@ -0,0 +1,31 @@ + + +## tscli agent + +Manage AI agent integrations for tscli + +### Synopsis + +Generate and refresh tscli-backed AI agent instructions, skills, prompts, and commands for either a repo-local checkout or global user-level tooling. + +### Options + +``` + -h, --help help for agent +``` + +### Options inherited from parent commands + +``` + -k, --api-key string Tailscale API key + -d, --debug Dump HTTP requests/responses + -o, --output string Output: [human json pretty yaml] + -n, --tailnet string Tailscale tailnet (default "-") +``` + +### SEE ALSO + +* [tscli](tscli.md) - +* [tscli agent init](tscli_agent_init.md) - Initialize AI agent integrations for tscli +* [tscli agent update](tscli_agent_update.md) - Refresh AI agent integrations for tscli + diff --git a/docs/commands/tscli_agent_init.md b/docs/commands/tscli_agent_init.md new file mode 100644 index 0000000..ca9af42 --- /dev/null +++ b/docs/commands/tscli_agent_init.md @@ -0,0 +1,36 @@ + + +## tscli agent init + +Initialize AI agent integrations for tscli + +### Synopsis + +Generate tscli-backed AI agent instructions, command catalogs, and native prompt, skill, or command surfaces for supported tools. + +``` +tscli agent init [flags] +``` + +### Options + +``` + --dir string Optional repository root for a repo-local install; omit to install global user-level integrations + --force Overwrite unmanaged files at generated target paths + -h, --help help for init + --tool strings Tool integrations to install (supported: generic, codex, claude, opencode, copilot; availability depends on global vs local install target) +``` + +### Options inherited from parent commands + +``` + -k, --api-key string Tailscale API key + -d, --debug Dump HTTP requests/responses + -o, --output string Output: [human json pretty yaml] + -n, --tailnet string Tailscale tailnet (default "-") +``` + +### SEE ALSO + +* [tscli agent](tscli_agent.md) - Manage AI agent integrations for tscli + diff --git a/docs/commands/tscli_agent_update.md b/docs/commands/tscli_agent_update.md new file mode 100644 index 0000000..c425708 --- /dev/null +++ b/docs/commands/tscli_agent_update.md @@ -0,0 +1,35 @@ + + +## tscli agent update + +Refresh AI agent integrations for tscli + +### Synopsis + +Refresh the generated tscli agent bundle using the tool selection recorded in the target manifest. Without --dir, update refreshes the global user-level install. + +``` +tscli agent update [flags] +``` + +### Options + +``` + --dir string Optional repository root containing a repo-local tscli agent manifest; omit to update the global install + --force Overwrite unmanaged files at generated target paths + -h, --help help for update +``` + +### Options inherited from parent commands + +``` + -k, --api-key string Tailscale API key + -d, --debug Dump HTTP requests/responses + -o, --output string Output: [human json pretty yaml] + -n, --tailnet string Tailscale tailnet (default "-") +``` + +### SEE ALSO + +* [tscli agent](tscli_agent.md) - Manage AI agent integrations for tscli + diff --git a/internal/cli/root.go b/internal/cli/root.go index 4e58405..a4228b8 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -3,6 +3,7 @@ package cli import ( "fmt" + agentcmd "github.com/jaxxstorm/tscli/cmd/tscli/agent" configuration "github.com/jaxxstorm/tscli/cmd/tscli/config" "github.com/jaxxstorm/tscli/cmd/tscli/create" "github.com/jaxxstorm/tscli/cmd/tscli/delete" @@ -34,7 +35,7 @@ func Configure() *cobra.Command { if cmd.Name() == "help" || cmd.Name() == "version" || cmd.Name() == "completion" { return nil } - if isConfigCommand(cmd) { + if isLocalCommand(cmd) { return nil } @@ -48,6 +49,7 @@ func Configure() *cobra.Command { } root.AddCommand( + agentcmd.Command(root), get.Command(), list.Command(), delete.Command(), @@ -78,9 +80,10 @@ func Configure() *cobra.Command { return root } -func isConfigCommand(cmd *cobra.Command) bool { +func isLocalCommand(cmd *cobra.Command) bool { for current := cmd; current != nil; current = current.Parent() { - if current.Name() == "config" { + switch current.Name() { + case "config", "agent": return true } } diff --git a/pkg/agent/install.go b/pkg/agent/install.go new file mode 100644 index 0000000..5760d99 --- /dev/null +++ b/pkg/agent/install.go @@ -0,0 +1,777 @@ +package agent + +import ( + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +const ( + ToolGeneric = "generic" + ToolCodex = "codex" + ToolClaude = "claude" + ToolOpenCode = "opencode" + ToolCopilot = "copilot" + + ScopeLocal = "local" + ScopeGlobal = "global" + + managedBy = "tscli-agent" + managedMarker = "Code generated by `tscli agent init`; DO NOT EDIT." + manifestSchema = 1 + bundleVersion = "v2" + + localManifestRelPath = ".tscli/agent/manifest.yaml" + localCommandCatalogRelPath = ".tscli/agent/commands.md" + localAgentsRelPath = "AGENTS.md" + localClaudeRelPath = "CLAUDE.md" + localCodexSkillRelPath = ".codex/skills/tscli/SKILL.md" + localGitHubSkillRelPath = ".github/skills/tscli/SKILL.md" + localGitHubInspectRelPath = ".github/prompts/tscli-inspect.prompt.md" + localGitHubOperateRelPath = ".github/prompts/tscli-operate.prompt.md" + + globalManifestRelPath = ".config/tscli/agent/manifest.yaml" + globalCommandCatalogRelPath = ".config/tscli/agent/commands.md" + globalCodexSkillRelPath = ".codex/skills/tscli/SKILL.md" + globalClaudeInspectRelPath = ".claude/commands/tscli-inspect.md" + globalClaudeOperateRelPath = ".claude/commands/tscli-operate.md" + globalOpenCodeInspectRelPath = ".config/opencode/commands/tscli-inspect.md" + globalOpenCodeOperateRelPath = ".config/opencode/commands/tscli-operate.md" +) + +var ( + allSupportedTools = []string{ + ToolGeneric, + ToolCodex, + ToolClaude, + ToolOpenCode, + ToolCopilot, + } + defaultLocalTools = []string{ + ToolGeneric, + ToolCodex, + ToolClaude, + ToolOpenCode, + ToolCopilot, + } + defaultGlobalTools = []string{ + ToolCodex, + ToolClaude, + ToolOpenCode, + } +) + +type InstallOptions struct { + RootDir string + Tools []string + Force bool +} + +type UpdateOptions struct { + RootDir string + Force bool +} + +type Result struct { + RootDir string + InstallScope string + Tools []string + Files []string + ManifestPath string + CommandCount int + BundleVersion string +} + +type Manifest struct { + ManagedBy string `yaml:"managed-by"` + SchemaVersion int `yaml:"schema-version"` + BundleVersion string `yaml:"bundle-version"` + InstallScope string `yaml:"install-scope"` + Tools []string `yaml:"tools"` + Files []string `yaml:"files"` + CommandCount int `yaml:"command-count"` + CommandCatalog string `yaml:"command-catalog"` +} + +type asset struct { + Path string + Content string +} + +type commandInfo struct { + Path string + Short string + Group string +} + +type installTarget struct { + Scope string + RootDir string + ManifestRelPath string + CommandCatalogPath string +} + +func Init(root *cobra.Command, opts InstallOptions) (Result, error) { + target, err := resolveInstallTarget(opts.RootDir) + if err != nil { + return Result{}, err + } + + tools, err := normalizeTools(opts.Tools, target.Scope) + if err != nil { + return Result{}, err + } + + return install(root, target, tools, opts.Force, loadManagedPaths(target)) +} + +func Update(root *cobra.Command, opts UpdateOptions) (Result, error) { + target, err := resolveInstallTarget(opts.RootDir) + if err != nil { + return Result{}, err + } + + manifest, err := loadManifest(target) + if err != nil { + return Result{}, err + } + + tools, err := normalizeTools(manifest.Tools, target.Scope) + if err != nil { + return Result{}, err + } + + return install(root, target, tools, opts.Force, pathSet(manifest.Files)) +} + +func SupportedTools() []string { + return append([]string(nil), allSupportedTools...) +} + +func install(root *cobra.Command, target installTarget, tools []string, force bool, managedPaths map[string]struct{}) (Result, error) { + if root == nil { + return Result{}, fmt.Errorf("root command is required") + } + + commands := collectLeafCommands(root) + assets := renderAssets(target, commands, tools) + manifest := Manifest{ + ManagedBy: managedBy, + SchemaVersion: manifestSchema, + BundleVersion: bundleVersion, + InstallScope: target.Scope, + Tools: append([]string(nil), tools...), + Files: append(assetPaths(assets), target.ManifestRelPath), + CommandCount: len(commands), + CommandCatalog: target.CommandCatalogPath, + } + manifestAsset := asset{ + Path: target.ManifestRelPath, + Content: renderManifest(manifest), + } + nextManagedPaths := pathSet(manifest.Files) + + for _, generated := range assets { + if err := writeManagedFile(target.RootDir, generated, force, managedPaths); err != nil { + return Result{}, err + } + } + if err := removeStaleManagedFiles(target.RootDir, managedPaths, nextManagedPaths); err != nil { + return Result{}, err + } + if err := writeManagedFile(target.RootDir, manifestAsset, force, managedPaths); err != nil { + return Result{}, err + } + + return Result{ + RootDir: target.RootDir, + InstallScope: target.Scope, + Tools: append([]string(nil), tools...), + Files: manifest.Files, + ManifestPath: target.ManifestRelPath, + CommandCount: len(commands), + BundleVersion: bundleVersion, + }, nil +} + +func resolveInstallTarget(rootDir string) (installTarget, error) { + if strings.TrimSpace(rootDir) == "" { + home, err := os.UserHomeDir() + if err != nil { + return installTarget{}, fmt.Errorf("resolve home directory: %w", err) + } + if home == "" { + return installTarget{}, fmt.Errorf("resolve home directory: empty home path") + } + return installTarget{ + Scope: ScopeGlobal, + RootDir: home, + ManifestRelPath: globalManifestRelPath, + CommandCatalogPath: globalCommandCatalogRelPath, + }, nil + } + + abs, err := filepath.Abs(rootDir) + if err != nil { + return installTarget{}, fmt.Errorf("resolve root dir: %w", err) + } + return installTarget{ + Scope: ScopeLocal, + RootDir: abs, + ManifestRelPath: localManifestRelPath, + CommandCatalogPath: localCommandCatalogRelPath, + }, nil +} + +func normalizeTools(tools []string, scope string) ([]string, error) { + supported := supportedToolsForScope(scope) + if len(tools) == 0 { + return append([]string(nil), defaultToolsForScope(scope)...), nil + } + + seen := map[string]struct{}{} + normalized := make([]string, 0, len(tools)) + for _, tool := range tools { + tool = strings.ToLower(strings.TrimSpace(tool)) + if tool == "" { + continue + } + if !slices.Contains(supported, tool) { + return nil, fmt.Errorf("unsupported tool %q for %s installs (supported: %s)", tool, scope, strings.Join(supported, ", ")) + } + if _, ok := seen[tool]; ok { + continue + } + seen[tool] = struct{}{} + normalized = append(normalized, tool) + } + + slices.SortFunc(normalized, func(a, b string) int { + return toolOrder(a) - toolOrder(b) + }) + return normalized, nil +} + +func supportedToolsForScope(scope string) []string { + switch scope { + case ScopeGlobal: + return append([]string(nil), defaultGlobalTools...) + default: + return append([]string(nil), defaultLocalTools...) + } +} + +func defaultToolsForScope(scope string) []string { + switch scope { + case ScopeGlobal: + return defaultGlobalTools + default: + return defaultLocalTools + } +} + +func toolOrder(tool string) int { + switch tool { + case ToolGeneric: + return 0 + case ToolCodex: + return 1 + case ToolClaude: + return 2 + case ToolOpenCode: + return 3 + case ToolCopilot: + return 4 + default: + return 99 + } +} + +func loadManifest(target installTarget) (Manifest, error) { + path := filepath.Join(target.RootDir, filepath.FromSlash(target.ManifestRelPath)) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + switch target.Scope { + case ScopeLocal: + return Manifest{}, fmt.Errorf("agent manifest not found at %s; run `tscli agent init --dir %s` first", target.ManifestRelPath, target.RootDir) + default: + return Manifest{}, fmt.Errorf("agent manifest not found at %s; run `tscli agent init` first", target.ManifestRelPath) + } + } + return Manifest{}, fmt.Errorf("read manifest: %w", err) + } + + var manifest Manifest + if err := yaml.Unmarshal(data, &manifest); err != nil { + return Manifest{}, fmt.Errorf("decode manifest: %w", err) + } + if manifest.ManagedBy != managedBy { + return Manifest{}, fmt.Errorf("manifest at %s is not managed by tscli agent", target.ManifestRelPath) + } + switch manifest.SchemaVersion { + case 0, manifestSchema: + default: + return Manifest{}, fmt.Errorf("manifest at %s uses unsupported schema-version %d (expected %d); rerun `tscli agent init` to regenerate it", target.ManifestRelPath, manifest.SchemaVersion, manifestSchema) + } + if manifest.InstallScope == "" { + manifest.InstallScope = target.Scope + } + if manifest.InstallScope != target.Scope { + if manifest.InstallScope == ScopeLocal { + return Manifest{}, fmt.Errorf("manifest at %s is for a local install; rerun with `tscli agent update --dir %s`", target.ManifestRelPath, target.RootDir) + } + return Manifest{}, fmt.Errorf("manifest at %s is for a global install; rerun with `tscli agent update`", target.ManifestRelPath) + } + return manifest, nil +} + +func collectLeafCommands(root *cobra.Command) []commandInfo { + var commands []commandInfo + var walk func(*cobra.Command) + + walk = func(cmd *cobra.Command) { + if cmd.Run != nil || cmd.RunE != nil { + fullPath := strings.TrimSpace(cmd.CommandPath()) + relativePath := strings.TrimSpace(strings.TrimPrefix(fullPath, root.Name())) + if relativePath == "" { + relativePath = root.Name() + } + group := relativePath + if idx := strings.IndexByte(relativePath, ' '); idx >= 0 { + group = relativePath[:idx] + } + commands = append(commands, commandInfo{ + Path: fullPath, + Short: strings.TrimSpace(cmd.Short), + Group: group, + }) + } + + children := append([]*cobra.Command(nil), cmd.Commands()...) + slices.SortFunc(children, func(a, b *cobra.Command) int { + return strings.Compare(a.Name(), b.Name()) + }) + for _, child := range children { + if shouldExcludeCommand(child) { + continue + } + walk(child) + } + } + + walk(root) + slices.SortFunc(commands, func(a, b commandInfo) int { + if groupCmp := strings.Compare(a.Group, b.Group); groupCmp != 0 { + return groupCmp + } + return strings.Compare(a.Path, b.Path) + }) + return commands +} + +func shouldExcludeCommand(cmd *cobra.Command) bool { + if cmd.Hidden { + return true + } + switch cmd.Name() { + case "help", "completion": + return true + default: + return false + } +} + +func renderAssets(target installTarget, commands []commandInfo, tools []string) []asset { + rendered := map[string]string{ + target.CommandCatalogPath: renderCommandCatalog(commands), + } + + addAsset := func(path, content string) { + rendered[path] = content + } + + for _, tool := range tools { + switch target.Scope { + case ScopeGlobal: + switch tool { + case ToolCodex: + addAsset(globalCodexSkillRelPath, renderCodexSkill(relativeReference(globalCodexSkillRelPath, target.CommandCatalogPath))) + case ToolClaude: + addAsset(globalClaudeInspectRelPath, renderClaudeCommand("Inspect Tailscale control-plane state with tscli.", relativeReference(globalClaudeInspectRelPath, target.CommandCatalogPath), true)) + addAsset(globalClaudeOperateRelPath, renderClaudeCommand("Operate Tailscale resources with tscli.", relativeReference(globalClaudeOperateRelPath, target.CommandCatalogPath), false)) + case ToolOpenCode: + addAsset(globalOpenCodeInspectRelPath, renderOpenCodeCommand("Inspect Tailscale control-plane state with tscli.", relativeReference(globalOpenCodeInspectRelPath, target.CommandCatalogPath), true)) + addAsset(globalOpenCodeOperateRelPath, renderOpenCodeCommand("Operate Tailscale resources with tscli.", relativeReference(globalOpenCodeOperateRelPath, target.CommandCatalogPath), false)) + } + default: + switch tool { + case ToolGeneric, ToolOpenCode: + addAsset(localAgentsRelPath, renderAgentsFile(target, tools, len(commands))) + case ToolCodex: + addAsset(localCodexSkillRelPath, renderCodexSkill(relativeReference(localCodexSkillRelPath, target.CommandCatalogPath))) + case ToolClaude: + addAsset(localClaudeRelPath, renderClaudeProjectFile(relativeReference(localClaudeRelPath, target.CommandCatalogPath))) + case ToolCopilot: + addAsset(localGitHubSkillRelPath, renderGitHubSkill(relativeReference(localGitHubSkillRelPath, target.CommandCatalogPath))) + addAsset(localGitHubInspectRelPath, renderInspectPrompt(relativeReference(localGitHubInspectRelPath, target.CommandCatalogPath))) + addAsset(localGitHubOperateRelPath, renderOperatePrompt(relativeReference(localGitHubOperateRelPath, target.CommandCatalogPath))) + } + } + } + + assets := make([]asset, 0, len(rendered)) + for path, content := range rendered { + assets = append(assets, asset{Path: path, Content: content}) + } + slices.SortFunc(assets, func(a, b asset) int { + return strings.Compare(a.Path, b.Path) + }) + return assets +} + +func renderManifest(manifest Manifest) string { + data, _ := yaml.Marshal(manifest) + return "# Code generated by `tscli agent init`; DO NOT EDIT.\n\n" + string(data) +} + +func renderAgentsFile(target installTarget, tools []string, commandCount int) string { + var b strings.Builder + b.WriteString("\n\n") + b.WriteString("# tscli Agent Instructions\n\n") + b.WriteString("This repository is initialized for `tscli`-aware agents. Use `tscli` as the preferred interface for inspecting and operating the Tailscale control plane from this repo.\n\n") + b.WriteString("## Workflow\n\n") + b.WriteString("- For straightforward read requests, go directly to the most likely `tscli list ...` or `tscli get ...` command.\n") + b.WriteString("- Check `tscli config show` or `tscli config profiles list` only when auth context is ambiguous, the user asks for it, or a command fails because of context.\n") + b.WriteString("- Use `tscli --help` only when the target leaf command or flags are genuinely unclear.\n") + b.WriteString("- After mutations, report the exact `tscli` command you ran and the affected resources.\n") + b.WriteString("- Refresh this integration bundle with `tscli agent update --dir .` whenever `tscli` gains new commands or guidance changes.\n\n") + b.WriteString("## Generated Assets\n\n") + b.WriteString(fmt.Sprintf("- Command catalog: `%s`\n", target.CommandCatalogPath)) + b.WriteString(fmt.Sprintf("- Bundle version: `%s`\n", bundleVersion)) + b.WriteString(fmt.Sprintf("- Install scope: `%s`\n", target.Scope)) + b.WriteString(fmt.Sprintf("- Supported tools in this install: `%s`\n", strings.Join(tools, "`, `"))) + b.WriteString(fmt.Sprintf("- Indexed leaf commands: `%d`\n\n", commandCount)) + b.WriteString("## Tool-Specific Surfaces\n\n") + + if hasTool(tools, ToolGeneric) { + b.WriteString("- Generic project instructions: `AGENTS.md`\n") + } + if hasTool(tools, ToolOpenCode) { + b.WriteString("- OpenCode project rules: `AGENTS.md`\n") + } + if hasTool(tools, ToolCodex) { + b.WriteString("- Codex skill: `.codex/skills/tscli/SKILL.md`\n") + } + if hasTool(tools, ToolClaude) { + b.WriteString("- Claude Code project memory: `CLAUDE.md`\n") + } + if hasTool(tools, ToolCopilot) { + b.WriteString("- GitHub Copilot skill: `.github/skills/tscli/SKILL.md`\n") + b.WriteString("- GitHub Copilot prompts: `.github/prompts/tscli-inspect.prompt.md`, `.github/prompts/tscli-operate.prompt.md`\n") + } + return b.String() +} + +func renderCommandCatalog(commands []commandInfo) string { + var b strings.Builder + b.WriteString("\n\n") + b.WriteString("# tscli Agent Command Catalog\n\n") + b.WriteString("Use this generated inventory to map tasks onto the current `tscli` command tree. When flags are unclear, follow up with `tscli --help`.\n\n") + + currentGroup := "" + for _, command := range commands { + if command.Group != currentGroup { + if currentGroup != "" { + b.WriteString("\n") + } + currentGroup = command.Group + b.WriteString(fmt.Sprintf("## `%s`\n\n", currentGroup)) + } + if command.Short == "" { + b.WriteString(fmt.Sprintf("- `%s`\n", command.Path)) + continue + } + b.WriteString(fmt.Sprintf("- `%s`: %s\n", command.Path, command.Short)) + } + return b.String() +} + +func renderCodexSkill(commandCatalogRef string) string { + return strings.Join([]string{ + "---", + "name: tscli", + "description: Use tscli as the preferred interface for Tailscale control-plane inspection and operations.", + "license: MIT", + "compatibility: Requires tscli installed and configured for the target tailnet.", + "metadata:", + " author: tscli", + " version: \"1.0\"", + " generatedBy: \"tscli-agent/v2\"", + "---", + "", + "", + "", + "Use `tscli` first whenever the user asks to inspect or operate Tailscale resources.", + "", + "## Operating rules", + "", + "1. Prefer `tscli` over ad hoc curl or handwritten API calls.", + "2. For straightforward read requests, run the most likely `tscli list ...` or `tscli get ...` command immediately.", + "3. Check `tscli config show` or `tscli config profiles list` only when auth context is ambiguous, the user asks for it, or a command fails because of context.", + "4. Use `tscli --help` only when the target command or flags are genuinely unclear, and default to at most one discovery command before the target command for read-only tasks.", + "5. Keep progress updates terse; do not narrate obvious intermediate steps.", + "6. Summarize the exact `tscli` commands executed and the resulting control-plane changes.", + "", + "## Command surface", + "", + fmt.Sprintf("Open `%s` for the generated command catalog.", commandCatalogRef), + "", + }, "\n") +} + +func renderClaudeProjectFile(commandCatalogRef string) string { + return strings.Join([]string{ + "# tscli Claude Instructions", + "", + "Use `tscli` first whenever the user asks to inspect or operate Tailscale resources from this repository.", + "", + "## Operating rules", + "", + "1. Prefer `tscli` over direct API calls when a command exists.", + "2. For straightforward read requests, run the most likely `tscli list ...` or `tscli get ...` command immediately.", + "3. Check `tscli config show` or `tscli config profiles list` only when auth context is ambiguous, the user asks for it, or a command fails because of context.", + "4. Use `tscli --help` only when the target command or flags are genuinely unclear.", + "5. Keep progress updates terse and skip obvious intermediate narration.", + "6. Summarize the exact `tscli` commands used and the observed state changes.", + "", + "## Command surface", + "", + fmt.Sprintf("Review @%s for the generated command catalog.", commandCatalogRef), + "", + }, "\n") +} + +func renderGitHubSkill(commandCatalogRef string) string { + return strings.Join([]string{ + "---", + "name: tscli", + "description: Use tscli to inspect and operate the Tailscale control plane from this repository.", + "license: MIT", + "compatibility: Requires tscli installed and configured for the target tailnet.", + "metadata:", + " author: tscli", + " version: \"1.0\"", + " generatedBy: \"tscli-agent/v2\"", + "---", + "", + "", + "", + "Use `tscli` as the default interface for Tailscale control-plane work in this repository.", + "", + "## Operating rules", + "", + "1. Prefer `tscli` over direct API calls when a command exists.", + "2. For straightforward read requests, run the most likely `tscli list ...` or `tscli get ...` command immediately.", + "3. Check `tscli config show` only when auth context is ambiguous or a command fails because of context.", + "4. Validate exact flags with `tscli --help` only when needed.", + "5. Keep progress updates terse and report the exact `tscli` command path used and the observed outcome.", + "", + "## Command surface", + "", + fmt.Sprintf("Open `%s` for the generated command catalog.", commandCatalogRef), + "", + }, "\n") +} + +func renderInspectPrompt(commandCatalogRef string) string { + return strings.Join([]string{ + "---", + "description: Inspect Tailscale control-plane state using tscli.", + "---", + "", + "", + "", + "Use `tscli` for read-only Tailscale investigation in this repository.", + "", + "Workflow:", + "- Confirm context with `tscli config show` or `tscli config profiles list` when needed.", + "- Prefer `tscli list ...` and `tscli get ...`.", + "- Use `tscli --help` if flags are uncertain.", + "- Summarize the exact commands run and the key fields returned.", + "", + fmt.Sprintf("Reference: `%s`", commandCatalogRef), + "", + }, "\n") +} + +func renderOperatePrompt(commandCatalogRef string) string { + return strings.Join([]string{ + "---", + "description: Operate Tailscale resources using tscli.", + "---", + "", + "", + "", + "Use `tscli` for Tailscale control-plane mutations in this repository.", + "", + "Workflow:", + "- Inspect current state before changing it.", + "- Prefer `tscli create ...`, `tscli set ...`, and `tscli delete ...` instead of direct API calls.", + "- Check `tscli --help` for the exact flags and payload expectations.", + "- After the mutation, summarize the exact command path used and the resulting state change.", + "", + fmt.Sprintf("Reference: `%s`", commandCatalogRef), + "", + }, "\n") +} + +func renderClaudeCommand(description string, commandCatalogRef string, readOnly bool) string { + body := renderCommandTemplate(commandCatalogRef, readOnly) + return strings.Join([]string{ + "---", + fmt.Sprintf("description: %s", description), + "argument-hint: [task]", + "---", + "", + body, + }, "\n") +} + +func renderOpenCodeCommand(description string, commandCatalogRef string, readOnly bool) string { + body := renderCommandTemplate(commandCatalogRef, readOnly) + return strings.Join([]string{ + "---", + fmt.Sprintf("description: %s", description), + "---", + "", + body, + }, "\n") +} + +func renderCommandTemplate(commandCatalogRef string, readOnly bool) string { + verb := "inspect" + workflow := []string{ + "- Run the most likely `tscli list ...` or `tscli get ...` command immediately.", + "- Check `tscli config show` or `tscli config profiles list` only when auth context is ambiguous or a command fails because of context.", + "- Check `tscli --help` only if flags are uncertain.", + } + if !readOnly { + verb = "operate on" + workflow = []string{ + "- Confirm context with `tscli config show` or `tscli config profiles list` when credentials or active tailnet matter.", + "- Inspect current state before mutating it.", + "- Prefer `tscli create ...`, `tscli set ...`, and `tscli delete ...` over direct API calls.", + "- Validate exact flags with `tscli --help` only when needed.", + } + } + + lines := []string{ + fmt.Sprintf("Use `tscli` to %s Tailscale resources for the user's request: $ARGUMENTS", verb), + "", + "Generated command catalog:", + fmt.Sprintf("@%s", commandCatalogRef), + "", + "Workflow:", + } + lines = append(lines, workflow...) + lines = append(lines, + "- Keep progress updates terse and summarize the exact `tscli` commands used and the observed result.", + "", + ) + return strings.Join(lines, "\n") +} + +func writeManagedFile(rootDir string, generated asset, force bool, managedPaths map[string]struct{}) error { + path := filepath.Join(rootDir, filepath.FromSlash(generated.Path)) + + if existing, err := os.ReadFile(path); err == nil { + if string(existing) == generated.Content { + return nil + } + _, knownManagedPath := managedPaths[generated.Path] + if !force && !knownManagedPath && !isManagedContent(string(existing)) { + return fmt.Errorf("%s exists and is not managed by tscli agent; rerun with --force to overwrite it", generated.Path) + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("read %s: %w", generated.Path, err) + } + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create parent directories for %s: %w", generated.Path, err) + } + if err := os.WriteFile(path, []byte(generated.Content), 0o644); err != nil { + return fmt.Errorf("write %s: %w", generated.Path, err) + } + return nil +} + +func isManagedContent(content string) bool { + return strings.Contains(content, managedMarker) || strings.Contains(content, "managed-by: "+managedBy) +} + +func assetPaths(assets []asset) []string { + paths := make([]string, 0, len(assets)) + for _, generated := range assets { + paths = append(paths, generated.Path) + } + return paths +} + +func loadManagedPaths(target installTarget) map[string]struct{} { + manifest, err := loadManifest(target) + if err != nil { + return map[string]struct{}{} + } + return pathSet(manifest.Files) +} + +func removeStaleManagedFiles(rootDir string, previousPaths, nextPaths map[string]struct{}) error { + for relPath := range previousPaths { + if _, keep := nextPaths[relPath]; keep { + continue + } + path := filepath.Join(rootDir, filepath.FromSlash(relPath)) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove stale managed file %s: %w", relPath, err) + } + pruneEmptyParents(rootDir, filepath.Dir(path)) + } + return nil +} + +func pathSet(paths []string) map[string]struct{} { + set := make(map[string]struct{}, len(paths)) + for _, path := range paths { + set[path] = struct{}{} + } + return set +} + +func pruneEmptyParents(rootDir, dir string) { + rootDir = filepath.Clean(rootDir) + for dir != "." && dir != string(filepath.Separator) && dir != rootDir { + if err := os.Remove(dir); err != nil { + return + } + dir = filepath.Dir(dir) + } +} + +func relativeReference(fromRelPath, toRelPath string) string { + fromDir := filepath.Dir(filepath.FromSlash(fromRelPath)) + rel, err := filepath.Rel(fromDir, filepath.FromSlash(toRelPath)) + if err != nil { + return toRelPath + } + return filepath.ToSlash(rel) +} + +func hasTool(tools []string, tool string) bool { + return slices.Contains(tools, tool) +} diff --git a/pkg/agent/install_test.go b/pkg/agent/install_test.go new file mode 100644 index 0000000..20bfcfb --- /dev/null +++ b/pkg/agent/install_test.go @@ -0,0 +1,275 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +func TestInitWritesManagedAssets(t *testing.T) { + root := testRoot() + repo := t.TempDir() + + result, err := Init(root, InstallOptions{ + RootDir: repo, + }) + if err != nil { + t.Fatalf("init agent bundle: %v", err) + } + + if result.CommandCount != 3 { + t.Fatalf("expected 3 leaf commands, got %d", result.CommandCount) + } + + manifestPath := filepath.Join(repo, filepath.FromSlash(localManifestRelPath)) + data, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatalf("read manifest: %v", err) + } + + var manifest Manifest + if err := yaml.Unmarshal(data, &manifest); err != nil { + t.Fatalf("decode manifest: %v", err) + } + if manifest.ManagedBy != managedBy { + t.Fatalf("expected managed-by %q, got %q", managedBy, manifest.ManagedBy) + } + if manifest.BundleVersion != bundleVersion { + t.Fatalf("expected bundle version %q, got %q", bundleVersion, manifest.BundleVersion) + } + if manifest.InstallScope != ScopeLocal { + t.Fatalf("expected install scope %q, got %q", ScopeLocal, manifest.InstallScope) + } + + catalog, err := os.ReadFile(filepath.Join(repo, filepath.FromSlash(localCommandCatalogRelPath))) + if err != nil { + t.Fatalf("read catalog: %v", err) + } + body := string(catalog) + for _, command := range []string{"tscli config show", "tscli list devices", "tscli set device"} { + if !strings.Contains(body, command) { + t.Fatalf("expected command catalog to include %q, got:\n%s", command, body) + } + } + + agents, err := os.ReadFile(filepath.Join(repo, localAgentsRelPath)) + if err != nil { + t.Fatalf("read AGENTS.md: %v", err) + } + if !strings.Contains(string(agents), "tscli agent update") { + t.Fatalf("expected AGENTS.md to mention refresh command, got:\n%s", string(agents)) + } +} + +func TestInitWritesGlobalManagedAssets(t *testing.T) { + root := testRoot() + home := t.TempDir() + t.Setenv("HOME", home) + + result, err := Init(root, InstallOptions{}) + if err != nil { + t.Fatalf("init global agent bundle: %v", err) + } + + if result.InstallScope != ScopeGlobal { + t.Fatalf("expected global install scope, got %q", result.InstallScope) + } + if got := strings.Join(result.Tools, ","); got != strings.Join(defaultGlobalTools, ",") { + t.Fatalf("expected default global tools %q, got %q", strings.Join(defaultGlobalTools, ","), got) + } + + for _, rel := range []string{ + globalManifestRelPath, + globalCommandCatalogRelPath, + globalCodexSkillRelPath, + globalClaudeInspectRelPath, + globalClaudeOperateRelPath, + globalOpenCodeInspectRelPath, + globalOpenCodeOperateRelPath, + } { + if _, err := os.Stat(filepath.Join(home, filepath.FromSlash(rel))); err != nil { + t.Fatalf("expected %s to exist: %v", rel, err) + } + } +} + +func TestUpdateRefreshesManagedFilesFromManifest(t *testing.T) { + root := testRoot() + repo := t.TempDir() + + if _, err := Init(root, InstallOptions{ + RootDir: repo, + Tools: []string{ToolCodex}, + }); err != nil { + t.Fatalf("init agent bundle: %v", err) + } + + codexSkillPath := filepath.Join(repo, filepath.FromSlash(localCodexSkillRelPath)) + if err := os.WriteFile(codexSkillPath, []byte("stale"), 0o644); err != nil { + t.Fatalf("write stale codex skill: %v", err) + } + + result, err := Update(root, UpdateOptions{RootDir: repo}) + if err != nil { + t.Fatalf("update agent bundle: %v", err) + } + + if got := strings.Join(result.Tools, ","); got != ToolCodex { + t.Fatalf("expected codex-only update, got %q", got) + } + + updated, err := os.ReadFile(codexSkillPath) + if err != nil { + t.Fatalf("read updated codex skill: %v", err) + } + if !strings.Contains(string(updated), managedMarker) { + t.Fatalf("expected managed codex skill content after update, got:\n%s", string(updated)) + } + + if _, err := os.Stat(filepath.Join(repo, filepath.FromSlash(localGitHubSkillRelPath))); !os.IsNotExist(err) { + t.Fatalf("did not expect copilot assets to be created during codex-only update") + } +} + +func TestInitRemovesStaleManagedFilesWhenToolSelectionNarrows(t *testing.T) { + root := testRoot() + repo := t.TempDir() + + if _, err := Init(root, InstallOptions{RootDir: repo}); err != nil { + t.Fatalf("init full agent bundle: %v", err) + } + + stalePaths := []string{ + localAgentsRelPath, + localClaudeRelPath, + localGitHubSkillRelPath, + localGitHubInspectRelPath, + localGitHubOperateRelPath, + } + for _, rel := range stalePaths { + if _, err := os.Stat(filepath.Join(repo, filepath.FromSlash(rel))); err != nil { + t.Fatalf("expected %s to exist before narrowing tools: %v", rel, err) + } + } + + result, err := Init(root, InstallOptions{RootDir: repo, Tools: []string{ToolCodex}}) + if err != nil { + t.Fatalf("re-init narrowed bundle: %v", err) + } + if got := strings.Join(result.Tools, ","); got != ToolCodex { + t.Fatalf("expected codex-only re-init, got %q", got) + } + + for _, rel := range stalePaths { + if _, err := os.Stat(filepath.Join(repo, filepath.FromSlash(rel))); !os.IsNotExist(err) { + t.Fatalf("expected stale managed file %s to be removed, got err=%v", rel, err) + } + } + + if _, err := os.Stat(filepath.Join(repo, filepath.FromSlash(localCodexSkillRelPath))); err != nil { + t.Fatalf("expected codex skill to remain after narrowing tools: %v", err) + } +} + +func TestLoadManifestRejectsUnsupportedSchemaVersion(t *testing.T) { + repo := t.TempDir() + target := installTarget{ + Scope: ScopeLocal, + RootDir: repo, + ManifestRelPath: localManifestRelPath, + CommandCatalogPath: localCommandCatalogRelPath, + } + + if err := os.MkdirAll(filepath.Join(repo, filepath.FromSlash(".tscli/agent")), 0o755); err != nil { + t.Fatalf("mkdir manifest dir: %v", err) + } + content := strings.Join([]string{ + "managed-by: tscli-agent", + "schema-version: 99", + "bundle-version: vFuture", + "install-scope: local", + "tools: [codex]", + "files: [.tscli/agent/commands.md, .codex/skills/tscli/SKILL.md, .tscli/agent/manifest.yaml]", + "command-count: 1", + "command-catalog: .tscli/agent/commands.md", + "", + }, "\n") + if err := os.WriteFile(filepath.Join(repo, filepath.FromSlash(localManifestRelPath)), []byte(content), 0o644); err != nil { + t.Fatalf("write manifest: %v", err) + } + + _, err := loadManifest(target) + if err == nil || !strings.Contains(err.Error(), "unsupported schema-version 99") { + t.Fatalf("expected unsupported schema version error, got %v", err) + } +} + +func TestLoadManifestAllowsLegacySchemaVersionZero(t *testing.T) { + repo := t.TempDir() + target := installTarget{ + Scope: ScopeLocal, + RootDir: repo, + ManifestRelPath: localManifestRelPath, + CommandCatalogPath: localCommandCatalogRelPath, + } + + if err := os.MkdirAll(filepath.Join(repo, filepath.FromSlash(".tscli/agent")), 0o755); err != nil { + t.Fatalf("mkdir manifest dir: %v", err) + } + content := strings.Join([]string{ + "managed-by: tscli-agent", + "schema-version: 0", + "bundle-version: v1", + "tools: [codex]", + "files: [.tscli/agent/commands.md, .codex/skills/tscli/SKILL.md, .tscli/agent/manifest.yaml]", + "command-count: 1", + "command-catalog: .tscli/agent/commands.md", + "", + }, "\n") + if err := os.WriteFile(filepath.Join(repo, filepath.FromSlash(localManifestRelPath)), []byte(content), 0o644); err != nil { + t.Fatalf("write manifest: %v", err) + } + + manifest, err := loadManifest(target) + if err != nil { + t.Fatalf("load legacy manifest: %v", err) + } + if manifest.SchemaVersion != 0 { + t.Fatalf("expected legacy schema version 0, got %d", manifest.SchemaVersion) + } + if manifest.InstallScope != ScopeLocal { + t.Fatalf("expected legacy manifest scope to default to %q, got %q", ScopeLocal, manifest.InstallScope) + } +} + +func testRoot() *cobra.Command { + root := &cobra.Command{Use: "tscli"} + + configCmd := &cobra.Command{Use: "config"} + configCmd.AddCommand(&cobra.Command{ + Use: "show", + Short: "Show tscli configuration", + RunE: func(*cobra.Command, []string) error { return nil }, + }) + + listCmd := &cobra.Command{Use: "list"} + listCmd.AddCommand(&cobra.Command{ + Use: "devices", + Short: "List devices", + RunE: func(*cobra.Command, []string) error { return nil }, + }) + + setCmd := &cobra.Command{Use: "set"} + setCmd.AddCommand(&cobra.Command{ + Use: "device", + Short: "Update a device", + RunE: func(*cobra.Command, []string) error { return nil }, + }) + + root.AddCommand(configCmd, listCmd, setCmd) + return root +} diff --git a/test/cli/agent_integration_test.go b/test/cli/agent_integration_test.go new file mode 100644 index 0000000..608ad8c --- /dev/null +++ b/test/cli/agent_integration_test.go @@ -0,0 +1,122 @@ +package cli_test + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestAgentInitCreatesIntegrationAssetsWithoutAuth(t *testing.T) { + repo := t.TempDir() + + res := executeCLINoDefaults(t, []string{"agent", "init", "--dir", repo}, nil) + if res.err != nil { + t.Fatalf("agent init: %v\nstderr:\n%s", res.err, res.stderr) + } + if !strings.Contains(res.stdout, "tscli agent integrations initialized") { + t.Fatalf("expected init success output, got %q", res.stdout) + } + + for _, rel := range []string{ + "AGENTS.md", + "CLAUDE.md", + ".tscli/agent/manifest.yaml", + ".tscli/agent/commands.md", + ".codex/skills/tscli/SKILL.md", + ".github/skills/tscli/SKILL.md", + ".github/prompts/tscli-inspect.prompt.md", + ".github/prompts/tscli-operate.prompt.md", + } { + if _, err := os.Stat(filepath.Join(repo, filepath.FromSlash(rel))); err != nil { + t.Fatalf("expected %s to exist: %v", rel, err) + } + } +} + +func TestAgentInitDefaultsToGlobalInstall(t *testing.T) { + home := t.TempDir() + + res := executeCLINoDefaults(t, []string{"agent", "init"}, map[string]string{"HOME": home}) + if res.err != nil { + t.Fatalf("agent init: %v\nstderr:\n%s", res.err, res.stderr) + } + if !strings.Contains(res.stdout, "initialized (global)") { + t.Fatalf("expected global init output, got %q", res.stdout) + } + + for _, rel := range []string{ + ".config/tscli/agent/manifest.yaml", + ".config/tscli/agent/commands.md", + ".codex/skills/tscli/SKILL.md", + ".claude/commands/tscli-inspect.md", + ".claude/commands/tscli-operate.md", + ".config/opencode/commands/tscli-inspect.md", + ".config/opencode/commands/tscli-operate.md", + } { + if _, err := os.Stat(filepath.Join(home, filepath.FromSlash(rel))); err != nil { + t.Fatalf("expected %s to exist: %v", rel, err) + } + } +} + +func TestAgentUpdateRefreshesInitializedBundle(t *testing.T) { + repo := t.TempDir() + + initRes := executeCLINoDefaults(t, []string{"agent", "init", "--dir", repo, "--tool", "codex"}, nil) + if initRes.err != nil { + t.Fatalf("agent init: %v\nstderr:\n%s", initRes.err, initRes.stderr) + } + + codexSkillPath := filepath.Join(repo, ".codex", "skills", "tscli", "SKILL.md") + if err := os.WriteFile(codexSkillPath, []byte("stale"), 0o644); err != nil { + t.Fatalf("write stale codex skill: %v", err) + } + + updateRes := executeCLINoDefaults(t, []string{"agent", "update", "--dir", repo}, nil) + if updateRes.err != nil { + t.Fatalf("agent update: %v\nstderr:\n%s", updateRes.err, updateRes.stderr) + } + if !strings.Contains(updateRes.stdout, "tscli agent integrations updated") { + t.Fatalf("expected update success output, got %q", updateRes.stdout) + } + + body, err := os.ReadFile(codexSkillPath) + if err != nil { + t.Fatalf("read refreshed codex skill: %v", err) + } + if !strings.Contains(string(body), "Code generated by `tscli agent init`; DO NOT EDIT.") { + t.Fatalf("expected refreshed managed codex skill, got:\n%s", string(body)) + } +} + +func TestAgentUpdateRefreshesGlobalInstall(t *testing.T) { + home := t.TempDir() + env := map[string]string{"HOME": home} + + initRes := executeCLINoDefaults(t, []string{"agent", "init", "--tool", "claude"}, env) + if initRes.err != nil { + t.Fatalf("agent init: %v\nstderr:\n%s", initRes.err, initRes.stderr) + } + + inspectPath := filepath.Join(home, ".claude", "commands", "tscli-inspect.md") + if err := os.WriteFile(inspectPath, []byte("stale"), 0o644); err != nil { + t.Fatalf("write stale claude command: %v", err) + } + + updateRes := executeCLINoDefaults(t, []string{"agent", "update"}, env) + if updateRes.err != nil { + t.Fatalf("agent update: %v\nstderr:\n%s", updateRes.err, updateRes.stderr) + } + if !strings.Contains(updateRes.stdout, "updated (global)") { + t.Fatalf("expected global update success output, got %q", updateRes.stdout) + } + + body, err := os.ReadFile(inspectPath) + if err != nil { + t.Fatalf("read refreshed claude command: %v", err) + } + if !strings.Contains(string(body), "description: Inspect Tailscale control-plane state with tscli.") { + t.Fatalf("expected refreshed managed claude command, got:\n%s", string(body)) + } +} diff --git a/test/cli/example_output_test.go b/test/cli/example_output_test.go index 436f31f..0611aba 100644 --- a/test/cli/example_output_test.go +++ b/test/cli/example_output_test.go @@ -13,6 +13,7 @@ import ( type exampleOutputCase struct { command string args []string + argsFunc func(*testing.T, map[string]string) []string shape jsonShapeExpectation textContains []string supportsModes bool @@ -87,11 +88,15 @@ func runExampleOutputCase(t *testing.T, tc exampleOutputCase, mode string) execR env := map[string]string{ "TSCLI_OUTPUT": mode, } + args := tc.args + if tc.argsFunc != nil { + args = tc.argsFunc(t, env) + } if tc.setup != nil { tc.setup(t, mock, env) } - res := executeCLI(t, tc.args, env) + res := executeCLI(t, args, env) if res.err != nil { t.Fatalf("unexpected error for %q: %v\nstderr:\n%s", tc.command, res.err, res.stderr) } @@ -100,6 +105,20 @@ func runExampleOutputCase(t *testing.T, tc exampleOutputCase, mode string) execR func exampleOutputCases() []exampleOutputCase { return []exampleOutputCase{ + localTextCaseWithArgs("agent init", func(t *testing.T, _ map[string]string) []string { + return []string{"agent", "init", "--dir", t.TempDir()} + }, nil, "tscli agent integrations initialized"), + localTextCaseWithArgs("agent update", func(t *testing.T, env map[string]string) []string { + repo := t.TempDir() + env["TSCLI_AGENT_TEST_DIR"] = repo + return []string{"agent", "update", "--dir", repo} + }, func(t *testing.T, _ *apimock.Server, env map[string]string) { + repo := env["TSCLI_AGENT_TEST_DIR"] + res := executeCLINoDefaults(t, []string{"agent", "init", "--dir", repo}, nil) + if res.err != nil { + t.Fatalf("prepare agent update example: %v\nstderr:\n%s", res.err, res.stderr) + } + }, "tscli agent integrations updated"), localTextCase("config get", []string{"config", "get", "output"}, nil, "json"), localTextCase("config profiles delete", []string{"config", "profiles", "delete", "sandbox"}, setupProfileHome, "tailnet profile sandbox removed"), localObjectCase("config profiles list", []string{"config", "profiles", "list"}, setupProfileHome, jsonShapeExpectation{ @@ -270,6 +289,17 @@ func localTextCase(command string, args []string, setup func(*testing.T, *apimoc return customCase(command, args, jsonShapeExpectation{}, false, setup, contains...) } +func localTextCaseWithArgs(command string, argsFunc func(*testing.T, map[string]string) []string, setup func(*testing.T, *apimock.Server, map[string]string), contains ...string) exampleOutputCase { + return exampleOutputCase{ + command: command, + argsFunc: argsFunc, + shape: jsonShapeExpectation{}, + textContains: contains, + supportsModes: false, + setup: setup, + } +} + func customCase(command string, args []string, shape jsonShapeExpectation, supportsModes bool, setup func(*testing.T, *apimock.Server, map[string]string), contains ...string) exampleOutputCase { return exampleOutputCase{ command: command, diff --git a/test/cli/testdata/leaf_commands.txt b/test/cli/testdata/leaf_commands.txt index e766957..56b93ee 100644 --- a/test/cli/testdata/leaf_commands.txt +++ b/test/cli/testdata/leaf_commands.txt @@ -1,3 +1,5 @@ +agent init +agent update config get config profiles delete config profiles list diff --git a/test/docs/docs_test.go b/test/docs/docs_test.go index 8ac5f9d..9eb01c1 100644 --- a/test/docs/docs_test.go +++ b/test/docs/docs_test.go @@ -13,6 +13,7 @@ func TestRequiredDocsPagesExist(t *testing.T) { "docs/_sidebar.md", "docs/README.md", "docs/getting-started.md", + "docs/agents.md", "docs/command-reference.md", "docs/configuration.md", "docs/authentication.md", @@ -31,6 +32,7 @@ func TestDocsSidebarHasCoreLinks(t *testing.T) { links := []string{ "Getting Started", + "AI Agents", "Command Reference", "Configuration", "Authentication", @@ -48,6 +50,7 @@ func TestGeneratedCommandDocsArtifactsExist(t *testing.T) { "docs/commands/README.md", "docs/commands/_sidebar.md", "docs/commands/tscli.md", + "docs/commands/tscli_agent.md", "docs/commands/tscli_config.md", "docs/commands/tscli_create.md", "docs/commands/tscli_get.md",