Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions acceptance/skills_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
2 changes: 1 addition & 1 deletion internal/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
46 changes: 37 additions & 9 deletions internal/cmd/skills.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"fmt"
"os"

"github.com/spf13/cobra"
Expand All @@ -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)
}
Expand All @@ -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)
Expand Down Expand Up @@ -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")
}
85 changes: 55 additions & 30 deletions internal/skills/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
},
}
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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)}
Expand Down Expand Up @@ -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))
Expand Down
Loading