diff --git a/acceptance/skills_test.go b/acceptance/skills_test.go index 01a5e4d8..0b9d510e 100644 --- a/acceptance/skills_test.go +++ b/acceptance/skills_test.go @@ -286,3 +286,106 @@ func TestSkillsListMixedStates(t *testing.T) { assert.Assert(t, strings.Contains(combined, "missing"), "expected 'missing' for deleted skill, got: %s", combined) } + +func TestSkillsInstallProjectScope(t *testing.T) { + env := testenv.NewTestEnv(t) + projectDir := t.TempDir() + + // Project scope requires no pre-existing agent dirs. + result := binary.RunCLI(t, []string{"skill", "install", "--scope", "project"}, env, projectDir) + + assert.Equal(t, result.ExitCode, 0, "stdout: %s\nstderr: %s", result.Stdout, result.Stderr) + + combined := result.Stdout + result.Stderr + assert.Assert(t, strings.Contains(combined, "claude:"), + "expected per-agent output for claude, got: %s", combined) + + // Skills should be installed into the project dir, not the home dir. + for _, name := range []string{"chunk-review", "chunk-testing-gaps", "chunk-sidecar", "debug-ci-failures"} { + skillFile := filepath.Join(projectDir, ".claude", "skills", name, "SKILL.md") + info, err := os.Stat(skillFile) + assert.NilError(t, err, "expected project-scope skill %s to exist at %s", name, skillFile) + assert.Assert(t, info.Size() > 0, "expected project-scope skill %s to be non-empty", name) + } + + // Nothing should be installed in the user's home dir. + _, err := os.Stat(filepath.Join(env.HomeDir, ".claude", "skills")) + assert.Assert(t, os.IsNotExist(err), "project-scope install should not touch user home skills dir") +} + +func TestSkillsInstallProjectScopeUpToDate(t *testing.T) { + env := testenv.NewTestEnv(t) + projectDir := t.TempDir() + + // First install. + binary.RunCLI(t, []string{"skill", "install", "--scope", "project"}, env, projectDir) + + // Second install should show "up to date". + result := binary.RunCLI(t, []string{"skill", "install", "--scope", "project"}, env, projectDir) + assert.Equal(t, result.ExitCode, 0) + + combined := result.Stdout + result.Stderr + assert.Assert(t, strings.Contains(combined, "up to date"), + "expected up-to-date message on second project-scope install, got: %s", combined) +} + +func TestSkillsInstallProjectScopeNotIsolatedFromUserScope(t *testing.T) { + env := testenv.NewTestEnv(t) + projectDir := t.TempDir() + + // Install user-scope into home dir. + claudeDir := filepath.Join(env.HomeDir, ".claude") + assert.NilError(t, os.MkdirAll(claudeDir, 0o755)) + binary.RunCLI(t, []string{"skill", "install", "--scope", "user"}, env, env.HomeDir) + + // Install project-scope from a different dir. + result := binary.RunCLI(t, []string{"skill", "install", "--scope", "project"}, env, projectDir) + assert.Equal(t, result.ExitCode, 0, "project-scope install failed: %s", result.Stderr) + + // Both locations should have skills. + for _, name := range []string{"chunk-review", "chunk-testing-gaps"} { + userFile := filepath.Join(claudeDir, "skills", name, "SKILL.md") + projectFile := filepath.Join(projectDir, ".claude", "skills", name, "SKILL.md") + _, err := os.Stat(userFile) + assert.NilError(t, err, "expected user-scope skill %s to still exist", name) + _, err = os.Stat(projectFile) + assert.NilError(t, err, "expected project-scope skill %s to exist", name) + } +} + +func TestSkillsListProjectScope(t *testing.T) { + env := testenv.NewTestEnv(t) + projectDir := t.TempDir() + + // Before install: project scope agents are always shown as available. + result := binary.RunCLI(t, []string{"skill", "list", "--scope", "project"}, env, projectDir) + assert.Equal(t, result.ExitCode, 0, "stdout: %s\nstderr: %s", result.Stdout, result.Stderr) + + combined := result.Stdout + result.Stderr + assert.Assert(t, strings.Contains(combined, "claude:"), + "expected per-agent status for claude, got: %s", combined) + // Skills not yet installed should be missing. + assert.Assert(t, strings.Contains(combined, "missing"), + "expected 'missing' state before project-scope install, got: %s", combined) + + // Install and re-list. + binary.RunCLI(t, []string{"skill", "install", "--scope", "project"}, env, projectDir) + + result = binary.RunCLI(t, []string{"skill", "list", "--scope", "project"}, env, projectDir) + assert.Equal(t, result.ExitCode, 0) + combined = result.Stdout + result.Stderr + assert.Assert(t, strings.Contains(combined, "current"), + "expected 'current' state after project-scope install, got: %s", combined) +} + +func TestSkillsInstallInvalidScope(t *testing.T) { + env := testenv.NewTestEnv(t) + + result := binary.RunCLI(t, []string{"skill", "install", "--scope", "global"}, env, env.HomeDir) + assert.Assert(t, result.ExitCode != 0, + "expected non-zero exit for invalid scope, got: %d", result.ExitCode) + + combined := result.Stdout + result.Stderr + assert.Assert(t, strings.Contains(combined, "invalid scope"), + "expected 'invalid scope' error, got: %s", combined) +} diff --git a/internal/cmd/init.go b/internal/cmd/init.go index 11c199c1..d647768e 100644 --- a/internal/cmd/init.go +++ b/internal/cmd/init.go @@ -312,7 +312,7 @@ func installSkillsStep(streams iostream.Streams) { if homeDir == "" { return } - for _, r := range skills.InstallByName(homeDir, "chunk-sidecar") { + for _, r := range skills.InstallByName(skills.ScopeUser, homeDir, "chunk-sidecar") { if r.Skipped { continue } diff --git a/internal/cmd/skills.go b/internal/cmd/skills.go index cd1da9af..268930fe 100644 --- a/internal/cmd/skills.go +++ b/internal/cmd/skills.go @@ -1,6 +1,7 @@ package cmd import ( + "fmt" "os" "github.com/spf13/cobra" @@ -26,17 +27,18 @@ func newSkillCmd() *cobra.Command { func newSkillInstallCmd() *cobra.Command { var jsonOut bool + var scopeFlag string cmd := &cobra.Command{ Use: "install", Short: "Install or update all skills into agent config directories", RunE: func(cmd *cobra.Command, _ []string) error { - home := os.Getenv(config.EnvHome) - if home == "" { - return &userError{msg: msgHomeNotSet, errMsg: errMsgHomeNotSet} + scope, baseDir, err := resolveScope(scopeFlag) + if err != nil { + return err } io := iostream.FromCmd(cmd) - results := skills.Install(home) + results := skills.Install(scope, baseDir) if jsonOut { return iostream.PrintJSON(io.Out, results) } @@ -61,20 +63,25 @@ func newSkillInstallCmd() *cobra.Command { } cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") + cmd.Flags().StringVar(&scopeFlag, "scope", "user", "Installation scope: user or project") return cmd } func newSkillListCmd() *cobra.Command { var jsonOut bool + var scopeFlag string cmd := &cobra.Command{ Use: cmdList, Short: "List bundled skills and their per-agent installation status", RunE: func(cmd *cobra.Command, _ []string) error { - home := os.Getenv(config.EnvHome) + scope, baseDir, err := resolveScope(scopeFlag) + if err != nil { + return err + } io := iostream.FromCmd(cmd) - statuses := skills.Status(home) + statuses := skills.Status(scope, baseDir) if jsonOut { return iostream.PrintJSON(io.Out, statuses) @@ -103,18 +110,39 @@ func newSkillListCmd() *cobra.Command { } cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") + cmd.Flags().StringVar(&scopeFlag, "scope", "user", "Installation scope: user or project") return cmd } +// resolveScope translates the --scope flag value into a Scope and the appropriate base directory. +func resolveScope(scopeFlag string) (skills.Scope, string, error) { + switch skills.Scope(scopeFlag) { + case skills.ScopeUser: + home := os.Getenv(config.EnvHome) + if home == "" { + return "", "", &userError{msg: msgHomeNotSet, errMsg: errMsgHomeNotSet} + } + return skills.ScopeUser, home, nil + case skills.ScopeProject: + cwd, err := os.Getwd() + if err != nil { + return "", "", fmt.Errorf("get working directory: %w", err) + } + return skills.ScopeProject, cwd, nil + default: + return "", "", &userError{msg: "invalid scope: must be 'user' or 'project'", errMsg: "invalid scope"} + } +} + func stateDisplay(state skills.State) (icon, label string) { switch state { case skills.StateCurrent: - return ui.Green("\u2713"), ui.Green("current") + return ui.Green("✓"), ui.Green("current") case skills.StateOutdated: - return ui.Yellow("\u26a0"), ui.Yellow("outdated") + return ui.Yellow("⚠"), ui.Yellow("outdated") case skills.StateMissing: - return ui.Dim("\u2717"), ui.Dim("missing") + return ui.Dim("✗"), ui.Dim("missing") } return ui.Dim("?"), ui.Dim("unknown") } diff --git a/internal/skills/install.go b/internal/skills/install.go index 5a16deb7..ed2f55ea 100644 --- a/internal/skills/install.go +++ b/internal/skills/install.go @@ -7,6 +7,18 @@ import ( "github.com/CircleCI-Public/chunk-cli/skills" ) +// Scope determines where skills are installed. +type Scope string + +const ( + // ScopeUser installs into the user's agent config directories (~/.claude, ~/.agents). + // Agents whose config directories do not exist are skipped. + ScopeUser Scope = "user" + // ScopeProject installs into the project's agent config directories (.claude, .agents). + // Directories are created as needed; no pre-existing config dir is required. + ScopeProject Scope = "project" +) + // State describes the installation state of a skill for a specific agent. type State string @@ -45,23 +57,29 @@ var All = []Skill{ // Agent represents a target agent with its config directories. type Agent struct { - Name string - ConfigDir string // parent config dir (must exist for install) - SkillsDir string // where skill subdirectories live + Name string + ConfigDir string // parent config dir + SkillsDir string // where skill subdirectories live + SkipIfAbsent bool // when true, skip install if ConfigDir does not exist } -// Agents returns the list of supported agents for the given home directory. -func Agents(homeDir string) []Agent { +// agents returns the list of supported agents for the given scope and base directory. +// For ScopeUser, baseDir is the user's home directory. +// For ScopeProject, baseDir is the project root directory. +func agents(scope Scope, baseDir string) []Agent { + skipIfAbsent := scope == ScopeUser return []Agent{ { - Name: "claude", - ConfigDir: filepath.Join(homeDir, ".claude"), - SkillsDir: filepath.Join(homeDir, ".claude", "skills"), + Name: "claude", + ConfigDir: filepath.Join(baseDir, ".claude"), + SkillsDir: filepath.Join(baseDir, ".claude", "skills"), + SkipIfAbsent: skipIfAbsent, }, { - Name: "codex", - ConfigDir: filepath.Join(homeDir, ".agents"), - SkillsDir: filepath.Join(homeDir, ".agents", "skills"), + Name: "codex", + ConfigDir: filepath.Join(baseDir, ".agents"), + SkillsDir: filepath.Join(baseDir, ".agents", "skills"), + SkipIfAbsent: skipIfAbsent, }, } } @@ -91,20 +109,21 @@ type AgentInstallResult struct { Updated []string `json:"updated"` } -// Install installs all embedded skills for agents whose config dirs exist. -// Agents with missing config dirs are skipped. -func Install(homeDir string) []AgentInstallResult { - agents := Agents(homeDir) - results := make([]AgentInstallResult, 0, len(agents)) - for _, agent := range agents { +// Install installs all embedded skills for the given scope and base directory. +// For ScopeUser, agents whose config dirs do not exist are skipped. +// For ScopeProject, dirs are created as needed. +func Install(scope Scope, baseDir string) []AgentInstallResult { + all := agents(scope, baseDir) + results := make([]AgentInstallResult, 0, len(all)) + for _, agent := range all { results = append(results, installForAgent(agent, All)) } return results } -// InstallByName installs a single skill by name for agents whose config dirs exist. +// InstallByName installs a single skill by name. // Returns nil if the skill name is not found. -func InstallByName(homeDir, name string) []AgentInstallResult { +func InstallByName(scope Scope, baseDir, name string) []AgentInstallResult { var s *Skill for i := range All { if All[i].Name == name { @@ -115,17 +134,19 @@ func InstallByName(homeDir, name string) []AgentInstallResult { if s == nil { return nil } - agents := Agents(homeDir) - results := make([]AgentInstallResult, 0, len(agents)) - for _, agent := range agents { + all := agents(scope, baseDir) + results := make([]AgentInstallResult, 0, len(all)) + for _, agent := range all { results = append(results, installForAgent(agent, []Skill{*s})) } return results } func installForAgent(agent Agent, subset []Skill) AgentInstallResult { - if _, err := os.Stat(agent.ConfigDir); os.IsNotExist(err) { - return AgentInstallResult{Agent: agent.Name, Skipped: true, Installed: make([]string, 0), Updated: make([]string, 0)} + if agent.SkipIfAbsent { + if _, err := os.Stat(agent.ConfigDir); os.IsNotExist(err) { + return AgentInstallResult{Agent: agent.Name, Skipped: true, Installed: make([]string, 0), Updated: make([]string, 0)} + } } result := AgentInstallResult{Agent: agent.Name, Installed: make([]string, 0), Updated: make([]string, 0)} @@ -174,14 +195,18 @@ type AgentStatus struct { } // Status returns per-agent, per-skill installation state without modifying anything. -func Status(homeDir string) []AgentStatus { - agents := Agents(homeDir) - results := make([]AgentStatus, 0, len(agents)) +// For ScopeUser, an agent is available only when its config dir exists. +// For ScopeProject, agents are always considered available. +func Status(scope Scope, baseDir string) []AgentStatus { + all := agents(scope, baseDir) + results := make([]AgentStatus, 0, len(all)) - for _, agent := range agents { + for _, agent := range all { available := true - if _, err := os.Stat(agent.ConfigDir); os.IsNotExist(err) { - available = false + if agent.SkipIfAbsent { + if _, err := os.Stat(agent.ConfigDir); os.IsNotExist(err) { + available = false + } } ss := make([]AgentSkillStatus, 0, len(All)) diff --git a/internal/skills/skills_test.go b/internal/skills/skills_test.go index 5397d2cd..64b607ef 100644 --- a/internal/skills/skills_test.go +++ b/internal/skills/skills_test.go @@ -21,7 +21,7 @@ func TestInstallBothAgents(t *testing.T) { assert.NilError(t, os.MkdirAll(filepath.Join(home, dir), 0o755)) } - results := skills.Install(home) + results := skills.Install(skills.ScopeUser, home) assert.Equal(t, len(results), 2) for _, r := range results { @@ -48,7 +48,7 @@ func TestInstallSkipsAgentWithoutConfigDir(t *testing.T) { // Only create .claude, not .agents. assert.NilError(t, os.MkdirAll(filepath.Join(home, ".claude"), 0o755)) - results := skills.Install(home) + results := skills.Install(skills.ScopeUser, home) assert.Equal(t, len(results), 2) var claude, codex skills.AgentInstallResult @@ -75,11 +75,11 @@ func TestInstallIdempotent(t *testing.T) { home := t.TempDir() assert.NilError(t, os.MkdirAll(filepath.Join(home, ".claude"), 0o755)) - results1 := skills.Install(home) + results1 := skills.Install(skills.ScopeUser, home) assert.Equal(t, len(results1[0].Installed), len(skillNames)) // Second install should report all up to date. - results2 := skills.Install(home) + results2 := skills.Install(skills.ScopeUser, home) assert.Equal(t, len(results2[0].Installed), 0, "should have no new installs") assert.Equal(t, len(results2[0].Updated), 0, "should have no updates") } @@ -89,13 +89,13 @@ func TestInstallDetectsOutdated(t *testing.T) { assert.NilError(t, os.MkdirAll(filepath.Join(home, ".claude"), 0o755)) // First install. - skills.Install(home) + skills.Install(skills.ScopeUser, home) // Tamper with one skill file to make it outdated. path := filepath.Join(home, ".claude", "skills", "chunk-review", "SKILL.md") assert.NilError(t, os.WriteFile(path, []byte("old content"), 0o644)) - results := skills.Install(home) + results := skills.Install(skills.ScopeUser, home) claude := results[0] assert.Equal(t, len(claude.Installed), 0) assert.Equal(t, len(claude.Updated), 1) @@ -108,7 +108,7 @@ func TestInstallContentMatchesEmbedded(t *testing.T) { assert.NilError(t, os.MkdirAll(filepath.Join(home, dir), 0o755)) } - skills.Install(home) + skills.Install(skills.ScopeUser, home) for _, name := range skillNames { claudePath := filepath.Join(home, ".claude", "skills", name, "SKILL.md") @@ -124,10 +124,46 @@ func TestInstallContentMatchesEmbedded(t *testing.T) { } } +func TestInstallProjectScope(t *testing.T) { + projectDir := t.TempDir() + + // Project scope does not require pre-existing agent dirs. + results := skills.Install(skills.ScopeProject, projectDir) + assert.Equal(t, len(results), 2) + + for _, r := range results { + assert.Assert(t, !r.Skipped, "agent %s should not be skipped for project scope", r.Agent) + assert.Equal(t, len(r.Installed), len(skillNames), + "agent %s: expected %d installed, got %d", r.Agent, len(skillNames), len(r.Installed)) + } + + // Verify files exist under project-relative dirs. + for _, dir := range []string{".claude", ".agents"} { + for _, name := range skillNames { + path := filepath.Join(projectDir, dir, "skills", name, "SKILL.md") + info, err := os.Stat(path) + assert.NilError(t, err, "expected %s to exist for project scope", path) + assert.Assert(t, info.Size() > 0, "expected %s to be non-empty", path) + } + } +} + +func TestInstallProjectScopeIdempotent(t *testing.T) { + projectDir := t.TempDir() + + skills.Install(skills.ScopeProject, projectDir) + + results := skills.Install(skills.ScopeProject, projectDir) + for _, r := range results { + assert.Equal(t, len(r.Installed), 0, "agent %s: should have no new installs on second run", r.Agent) + assert.Equal(t, len(r.Updated), 0, "agent %s: should have no updates on second run", r.Agent) + } +} + func TestStatusNotInstalled(t *testing.T) { home := t.TempDir() - statuses := skills.Status(home) + statuses := skills.Status(skills.ScopeUser, home) assert.Equal(t, len(statuses), 2) for _, agent := range statuses { @@ -143,9 +179,9 @@ func TestStatusCurrent(t *testing.T) { home := t.TempDir() assert.NilError(t, os.MkdirAll(filepath.Join(home, ".claude"), 0o755)) - skills.Install(home) + skills.Install(skills.ScopeUser, home) - statuses := skills.Status(home) + statuses := skills.Status(skills.ScopeUser, home) var claude skills.AgentStatus for _, s := range statuses { if s.Agent == "claude" { @@ -164,13 +200,13 @@ func TestStatusOutdated(t *testing.T) { home := t.TempDir() assert.NilError(t, os.MkdirAll(filepath.Join(home, ".claude"), 0o755)) - skills.Install(home) + skills.Install(skills.ScopeUser, home) // Tamper with a skill. path := filepath.Join(home, ".claude", "skills", "chunk-review", "SKILL.md") assert.NilError(t, os.WriteFile(path, []byte("tampered"), 0o644)) - statuses := skills.Status(home) + statuses := skills.Status(skills.ScopeUser, home) var claude skills.AgentStatus for _, s := range statuses { if s.Agent == "claude" { @@ -190,7 +226,7 @@ func TestStatusOutdated(t *testing.T) { func TestStatusIncludesDescriptions(t *testing.T) { home := t.TempDir() - statuses := skills.Status(home) + statuses := skills.Status(skills.ScopeUser, home) for _, agent := range statuses { for _, s := range agent.Skills { assert.Assert(t, s.Description != "", @@ -204,7 +240,7 @@ func TestStatusAgentNotAvailable(t *testing.T) { // Only create .claude. assert.NilError(t, os.MkdirAll(filepath.Join(home, ".claude"), 0o755)) - statuses := skills.Status(home) + statuses := skills.Status(skills.ScopeUser, home) for _, agent := range statuses { if agent.Agent == "claude" { assert.Assert(t, agent.Available) @@ -217,6 +253,21 @@ func TestStatusAgentNotAvailable(t *testing.T) { } } +func TestStatusProjectScopeAlwaysAvailable(t *testing.T) { + projectDir := t.TempDir() + + // No dirs created — project scope agents should still be "available". + statuses := skills.Status(skills.ScopeProject, projectDir) + assert.Equal(t, len(statuses), 2) + for _, agent := range statuses { + assert.Assert(t, agent.Available, "agent %s should be available for project scope", agent.Agent) + for _, s := range agent.Skills { + assert.Equal(t, s.State, skills.StateMissing, + "skill %s for %s should be missing before install", s.Name, agent.Agent) + } + } +} + func TestSkillStateDetectsStates(t *testing.T) { dir := t.TempDir() s := skills.All[0] // chunk-testing-gaps