Skip to content
Merged
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,8 +363,19 @@ craft auto-detects your AI agent and installs skills to the correct directory:

When both agents are detected, craft prompts you to choose. Use `--target <path>` to override auto-detection.

**Global installs** (`craft get`, `craft install -g`) write to agent skill directories.
**Project installs** (`craft install`) vendor to `forge/` in the project root (gitignored).
**Global installs** (`craft get`, `craft install -g`) write to agent skill directories using a flat naming scheme so agents can discover skills. Each skill becomes a direct child of the skills root:

```
~/.claude/skills/
├── github.com--acme--company-standards--coding-style/
│ └── SKILL.md
└── github.com--acme--company-standards--review-checklist/
└── SKILL.md
```

The flat directory name is derived from the composite key: slashes become `--`, dots and casing are preserved (e.g., `github.com/acme/company-standards/coding-style` → `github.com--acme--company-standards--coding-style`). The encoding is injective — distinct composite keys always produce distinct flat keys.

**Project installs** (`craft install`) vendor to `forge/` in the project root (gitignored), using nested composite-key paths.

## Known Limitations

Expand Down
2 changes: 1 addition & 1 deletion internal/cli/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ func runGet(cmd *cobra.Command, args []string) error {
// Install to agent directories
progress.Update("Installing skills...")
for _, targetPath := range targetPaths {
if err := installlib.Install(targetPath, skillFiles); err != nil {
if err := installlib.InstallFlat(targetPath, skillFiles); err != nil {
progress.Fail("Installation failed")
return fmt.Errorf("installation failed: %w\n note: dependencies were added to the global manifest but installation could not complete\n hint: run 'craft install -g' to retry, or 'craft remove -g <alias>' to undo", err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ func runInstallGlobal(cmd *cobra.Command) error {

progress.Update("Installing skills...")
for _, targetPath := range targetPaths {
if err := installlib.Install(targetPath, skillFiles); err != nil {
if err := installlib.InstallFlat(targetPath, skillFiles); err != nil {
progress.Fail("Installation failed")
return fmt.Errorf("installation failed: %w", err)
}
Expand Down
7 changes: 6 additions & 1 deletion internal/cli/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"strings"
"text/tabwriter"

installlib "github.com/erdemtuna/craft/internal/install"
"github.com/erdemtuna/craft/internal/resolve"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -84,7 +85,11 @@ func runList(cmd *cobra.Command, args []string) error {
for _, d := range deps {
cmd.Printf("%s %s %s\n", sanitize(d.alias), d.version, sanitize(d.url))
if len(d.skills) > 0 {
cmd.Printf(" skills: %s\n", sanitize(strings.Join(d.skills, ", ")))
displaySkills := d.skills
if globalFlag {
displaySkills = installlib.QualifySkillNames(d.url, d.skills)
}
cmd.Printf(" skills: %s\n", sanitize(strings.Join(displaySkills, ", ")))
} else {
cmd.Printf(" skills: (none)\n")
}
Expand Down
12 changes: 10 additions & 2 deletions internal/cli/remove.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"sort"
"strings"

installlib "github.com/erdemtuna/craft/internal/install"
"github.com/erdemtuna/craft/internal/manifest"
"github.com/erdemtuna/craft/internal/pinfile"
"github.com/erdemtuna/craft/internal/resolve"
Expand Down Expand Up @@ -138,7 +139,12 @@ func runRemove(cmd *cobra.Command, args []string) error {
for _, skillName := range orphaned {
removedFromAny := false
for _, tp := range targetPath {
skillDir := filepath.Join(tp, nsPrefix, skillName)
var skillDir string
if globalFlag {
skillDir = filepath.Join(tp, installlib.FlatKey(installlib.CompositeKey(nsPrefix, skillName)))
} else {
skillDir = filepath.Join(tp, nsPrefix, skillName)
}
// Path traversal protection
absSkillDir, err := filepath.Abs(skillDir)
if err != nil {
Expand All @@ -158,7 +164,9 @@ func runRemove(cmd *cobra.Command, args []string) error {
cmd.PrintErrf(" warning: could not remove %s: %v\n", skillDir, err)
} else {
removedFromAny = true
cleanEmptyParents(tp, filepath.Dir(skillDir))
if !globalFlag {
cleanEmptyParents(tp, filepath.Dir(skillDir))
}
}
}
}
Expand Down
67 changes: 67 additions & 0 deletions internal/cli/remove_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -362,3 +362,70 @@ func TestCleanEmptyParents_StopsAtRoot(t *testing.T) {
t.Error("root should not be deleted")
}
}

func TestRunRemoveGlobal_FlatCleanup(t *testing.T) {
// Set up isolated HOME for global manifest/pinfile
fakeHome := t.TempDir()
origHome := os.Getenv("HOME")
t.Setenv("HOME", fakeHome)
defer func() { _ = os.Setenv("HOME", origHome) }()

// Reset globalFlag after test (cobra persistent flags leak between tests)
defer func() { globalFlag = false }()

craftDir := filepath.Join(fakeHome, ".craft")
_ = os.MkdirAll(craftDir, 0755)

manifestContent := `schema_version: 1
name: global-pkg
dependencies:
my-dep: github.com/org/[email protected]
`
_ = os.WriteFile(filepath.Join(craftDir, "craft.yaml"), []byte(manifestContent), 0644)

pinContent := `pin_version: 1
resolved:
github.com/org/[email protected]:
commit: abc123
integrity: sha256-test
skills:
- my-skill
`
_ = os.WriteFile(filepath.Join(craftDir, "craft.pin.yaml"), []byte(pinContent), 0644)

// Create flat skill directory (as InstallFlat would produce)
targetDir := filepath.Join(fakeHome, "agent-skills")
flatSkillDir := filepath.Join(targetDir, "github.com--org--repo--my-skill")
_ = os.MkdirAll(flatSkillDir, 0755)
_ = os.WriteFile(filepath.Join(flatSkillDir, "SKILL.md"), []byte("skill"), 0644)

var buf bytes.Buffer
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
rootCmd.SetArgs([]string{"remove", "-g", "--target", targetDir, "my-dep"})
err := rootCmd.Execute()

if err != nil {
t.Fatalf("unexpected error: %v\noutput: %s", err, buf.String())
}

output := buf.String()
if !strings.Contains(output, "Removed") {
t.Errorf("expected 'Removed' message, got: %s", output)
}

// Verify flat skill directory was removed
if _, err := os.Stat(flatSkillDir); err == nil {
t.Error("flat skill directory should have been removed")
}

// Verify no nested parent directories were created
if _, err := os.Stat(filepath.Join(targetDir, "github.com")); err == nil {
t.Error("no nested parent directories should exist")
}

// Verify target dir itself still exists
if _, err := os.Stat(targetDir); err != nil {
t.Error("target directory should still exist")
}
}
20 changes: 16 additions & 4 deletions internal/cli/tree.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cli
import (
"strings"

installlib "github.com/erdemtuna/craft/internal/install"
"github.com/erdemtuna/craft/internal/resolve"
"github.com/erdemtuna/craft/internal/ui"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -30,7 +31,7 @@ func runTree(cmd *cobra.Command, args []string) error {
var localSkills []string
for _, s := range m.Skills {
parts := strings.Split(strings.TrimRight(s, "/"), "/")
localSkills = append(localSkills, parts[len(parts)-1])
localSkills = append(localSkills, sanitize(parts[len(parts)-1]))
}

// Build alias lookup from manifest
Expand All @@ -57,10 +58,21 @@ func runTree(cmd *cobra.Command, args []string) error {
alias = parsed.Repo
}

skills := entry.Skills
if globalFlag {
skills = installlib.QualifySkillNames(parsed.PackageIdentity(), entry.Skills)
}

// Sanitize all user-derived strings before rendering
sanitizedSkills := make([]string, len(skills))
for i, s := range skills {
sanitizedSkills[i] = sanitize(s)
}

deps = append(deps, ui.DepNode{
Alias: alias,
URL: key,
Skills: entry.Skills,
Alias: sanitize(alias),
URL: sanitize(key),
Skills: sanitizedSkills,
})
}

Expand Down
2 changes: 1 addition & 1 deletion internal/cli/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ func runUpdate(cmd *cobra.Command, args []string) error {
}

for _, targetPath := range targetPaths {
if err := installlib.Install(targetPath, skillFiles); err != nil {
if err := installlib.InstallFlat(targetPath, skillFiles); err != nil {
progress.Fail("Installation failed")
return fmt.Errorf("installation failed: %w", err)
}
Expand Down
47 changes: 47 additions & 0 deletions internal/install/installer.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,50 @@ func Install(target string, skills map[string]map[string][]byte) error {

return nil
}

// FlatKey converts a composite skill key (host/owner/repo/skill) into a flat
// directory name suitable for agent skill discovery. Slashes become "--".
// Dots and casing are preserved. The encoding is injective — distinct
// composite keys always produce distinct flat keys.
//
// Example: "github.com/org/repo/my-skill" → "github.com--org--repo--my-skill"
func FlatKey(compositeKey string) string {
return strings.ReplaceAll(compositeKey, "/", "--")
}

// InstallFlat installs skills using flat directory names so that each skill
// is a direct child of the target directory. This is used for global installs
// where AI agents need to discover skills by scanning immediate children.
// It transforms composite keys via FlatKey then delegates to Install.
func InstallFlat(target string, skills map[string]map[string][]byte) error {
flat := make(map[string]map[string][]byte, len(skills))
for k, v := range skills {
fk := FlatKey(k)
if fk == "" {
return fmt.Errorf("empty composite key produces empty flat key")
}
flat[fk] = v
}
return Install(target, flat)
}

// CompositeKey builds a composite skill key from a package identity and skill
// name. This is the canonical way to construct the key used by both Install
// and remove operations, ensuring format consistency.
func CompositeKey(packageIdentity, skillName string) string {
return packageIdentity + "/" + skillName
}

// QualifySkillNames prefixes each skill name with its package identity to form
// composite key display names. Used by list and tree commands for global scope.
// Empty skill names are skipped to avoid trailing-slash display artifacts.
func QualifySkillNames(packageIdentity string, skills []string) []string {
qualified := make([]string, 0, len(skills))
for _, s := range skills {
if s == "" {
continue
}
qualified = append(qualified, CompositeKey(packageIdentity, s))
}
return qualified
}
Loading