diff --git a/README.md b/README.md index ae0b31f..9e9b2ff 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,16 @@ dci skill kiro # installs to ~/.kiro/skills/dci-cli/ dci skill gemini # installs to ~/.gemini/skills/dci-cli/ ``` +Inspect or refresh installed skill files with: + +```bash +dci skill list +dci skill update codex +dci skill update # updates every detected installation +``` + +Updates preserve locally edited managed files unless `--force` is passed. Unmanaged files in the skill directory are left in place and do not block an update. + Run `dci skill --help` for the full list of supported agents. Alternatively, for Codex you can use the `skill-installer` helper: diff --git a/main.go b/main.go index 156fd98..6cafdac 100644 --- a/main.go +++ b/main.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "io" - "io/fs" "net/url" "os" "path/filepath" @@ -1032,74 +1031,6 @@ func registerCustomerContextCommands(configDir string) { cli.Root.AddCommand(cmd) } -// installSkill copies embedded skill files into targetDir/skills/dci-cli/. -func installSkill(targetDir string) error { - const srcRoot = "skills/dci-cli" - destRoot := filepath.Join(targetDir, "skills", "dci-cli") - - return fs.WalkDir(skillFS, srcRoot, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - rel, err := filepath.Rel(srcRoot, path) - if err != nil { - return err - } - dest := filepath.Join(destRoot, rel) - - if d.IsDir() { - return os.MkdirAll(dest, 0o755) - } - data, err := skillFS.ReadFile(path) - if err != nil { - return err - } - return os.WriteFile(dest, data, 0o644) - }) -} - -func registerSkillCommands() { - agents := []struct { - name string - dir string - }{ - {"claude", ".claude"}, - {"codex", ".codex"}, - {"kiro", ".kiro"}, - {"gemini", ".gemini"}, - {"opencode", ".config/opencode"}, // OpenCode uses XDG config dir (~/.config), not a dotfile - } - - cmd := &cobra.Command{ - Use: "skill", - Short: "Install the dci skill for an AI agent", - } - - for _, a := range agents { - agentName := a.name - agentDir := a.dir - cmd.AddCommand(&cobra.Command{ - Use: agentName, - Short: fmt.Sprintf("Install skill into ~/%s/skills/dci-cli/", agentDir), - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { - home, err := os.UserHomeDir() - if err != nil { - return fmt.Errorf("cannot determine home directory: %w", err) - } - targetDir := filepath.Join(home, agentDir) - if err := installSkill(targetDir); err != nil { - return fmt.Errorf("failed to install skill: %w", err) - } - fmt.Fprintf(os.Stdout, "Skill installed to %s\n", filepath.Join(targetDir, "skills", "dci-cli")) - return nil - }, - }) - } - - cli.Root.AddCommand(cmd) -} - func brandDCIRootCommand() { applyCommandBranding(findDCICommand(), "DoiT Cloud Intelligence API CLI", apiExamples) } diff --git a/main_test.go b/main_test.go index 606d8b0..8b2ea11 100644 --- a/main_test.go +++ b/main_test.go @@ -1873,8 +1873,9 @@ func TestInstallSkillFileCount(t *testing.T) { t.Fatalf("walking installed dir: %v", err) } - if len(installedFiles) != len(expectedSkillFiles) { - t.Errorf("expected %d files, got %d: %v", len(expectedSkillFiles), len(installedFiles), installedFiles) + wantFileCount := len(expectedSkillFiles) + 1 + if len(installedFiles) != wantFileCount { + t.Errorf("expected %d files, got %d: %v", wantFileCount, len(installedFiles), installedFiles) } } diff --git a/skill_management.go b/skill_management.go new file mode 100644 index 0000000..9ff1149 --- /dev/null +++ b/skill_management.go @@ -0,0 +1,442 @@ +package main + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/rest-sh/restish/cli" + "github.com/spf13/cobra" +) + +const ( + embeddedSkillRoot = "skills/dci-cli" + skillManifestName = ".dci-skill-manifest.json" +) + +type skillAgent struct { + Name string + RelativeDir string +} + +var skillAgents = []skillAgent{ + {Name: "claude", RelativeDir: ".claude"}, + {Name: "codex", RelativeDir: ".codex"}, + {Name: "kiro", RelativeDir: ".kiro"}, + {Name: "gemini", RelativeDir: ".gemini"}, + {Name: "opencode", RelativeDir: ".config/opencode"}, +} + +type skillFileInfo struct { + Path string `json:"path"` + Bytes int `json:"bytes"` + EstimatedTokens int `json:"estimated_tokens"` +} + +type skillDiff struct { + Changed []string + Missing []string + Extra []string +} + +type skillManifest struct { + Version int `json:"version"` + Files map[string]string `json:"files"` +} + +func installSkill(targetDir string) error { + destinationRoot := filepath.Join(targetDir, "skills", "dci-cli") + manifest := skillManifest{Version: 1, Files: map[string]string{}} + err := fs.WalkDir(skillFS, embeddedSkillRoot, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + relativePath, err := filepath.Rel(embeddedSkillRoot, path) + if err != nil { + return err + } + destination := filepath.Join(destinationRoot, relativePath) + if entry.IsDir() { + return os.MkdirAll(destination, 0o755) + } + data, err := skillFS.ReadFile(path) + if err != nil { + return err + } + manifest.Files[filepath.ToSlash(relativePath)] = skillFileDigest(data) + return os.WriteFile(destination, data, 0o644) + }) + if err != nil { + return err + } + return writeSkillManifest(destinationRoot, manifest) +} + +func skillFileDigest(data []byte) string { + return fmt.Sprintf("%x", sha256.Sum256(data)) +} + +func writeSkillManifest(root string, manifest skillManifest) error { + data, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + return os.WriteFile(filepath.Join(root, skillManifestName), data, 0o644) +} + +func readSkillManifest(root string) (skillManifest, bool, error) { + data, err := os.ReadFile(filepath.Join(root, skillManifestName)) + if os.IsNotExist(err) { + return skillManifest{}, false, nil + } + if err != nil { + return skillManifest{}, false, err + } + var manifest skillManifest + if err := json.Unmarshal(data, &manifest); err != nil { + return skillManifest{}, false, err + } + if manifest.Version != 1 || manifest.Files == nil { + return skillManifest{}, false, fmt.Errorf("unsupported skill manifest version %d", manifest.Version) + } + return manifest, true, nil +} + +func embeddedSkillFiles() ([]skillFileInfo, error) { + files := make([]skillFileInfo, 0) + err := fs.WalkDir(skillFS, embeddedSkillRoot, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + data, err := skillFS.ReadFile(path) + if err != nil { + return err + } + relativePath, err := filepath.Rel(embeddedSkillRoot, path) + if err != nil { + return err + } + files = append(files, skillFileInfo{ + Path: filepath.ToSlash(relativePath), + Bytes: len(data), + EstimatedTokens: (len(data) + 3) / 4, + }) + return nil + }) + sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) + return files, err +} + +func inspectInstalledSkill(targetDir string) (skillDiff, error) { + root := filepath.Join(targetDir, "skills", "dci-cli") + embedded := map[string][]byte{} + err := fs.WalkDir(skillFS, embeddedSkillRoot, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + relativePath, err := filepath.Rel(embeddedSkillRoot, path) + if err != nil { + return err + } + data, err := skillFS.ReadFile(path) + if err != nil { + return err + } + embedded[filepath.ToSlash(filepath.Clean(relativePath))] = data + return nil + }) + if err != nil { + return skillDiff{}, err + } + + manifest, hasManifest, err := readSkillManifest(root) + if err != nil { + return skillDiff{}, err + } + baseline := manifest.Files + if !hasManifest { + baseline = make(map[string]string, len(embedded)) + for relativePath, data := range embedded { + baseline[relativePath] = skillFileDigest(data) + } + } + + result := skillDiff{} + for relativePath, expectedDigest := range baseline { + actual, readErr := os.ReadFile(filepath.Join(root, relativePath)) + if os.IsNotExist(readErr) { + result.Missing = append(result.Missing, filepath.ToSlash(relativePath)) + continue + } + if readErr != nil { + return skillDiff{}, readErr + } + if skillFileDigest(actual) != expectedDigest { + result.Changed = append(result.Changed, filepath.ToSlash(relativePath)) + } + } + + if _, statErr := os.Stat(root); statErr == nil { + err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + relativePath, err := filepath.Rel(root, path) + if err != nil { + return err + } + relativePath = filepath.ToSlash(filepath.Clean(relativePath)) + if relativePath == skillManifestName { + return nil + } + if _, exists := baseline[relativePath]; !exists { + result.Extra = append(result.Extra, filepath.ToSlash(relativePath)) + } + return nil + }) + if err != nil { + return skillDiff{}, err + } + } + + for _, values := range [][]string{result.Changed, result.Missing, result.Extra} { + sort.Strings(values) + } + return result, nil +} + +func (diff skillDiff) HasLocalChanges() bool { + return len(diff.Changed) > 0 || len(diff.Missing) > 0 +} + +func (diff skillDiff) LocalChangePaths() []string { + paths := append([]string{}, diff.Changed...) + paths = append(paths, diff.Missing...) + sort.Strings(paths) + return paths +} + +func skillAgentByName(name string) (skillAgent, bool) { + for _, agent := range skillAgents { + if agent.Name == name { + return agent, true + } + } + return skillAgent{}, false +} + +func defaultSkillTarget(agent skillAgent) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot determine home directory: %w", err) + } + return filepath.Join(home, agent.RelativeDir), nil +} + +func resolvedSkillTarget(agent skillAgent, override string) (string, error) { + if strings.TrimSpace(override) != "" { + return filepath.Clean(override), nil + } + return defaultSkillTarget(agent) +} + +func detectedSkillTargets(installedOnly bool) ([]struct { + Agent skillAgent + Path string +}, error) { + result := make([]struct { + Agent skillAgent + Path string + }, 0) + for _, agent := range skillAgents { + path, err := defaultSkillTarget(agent) + if err != nil { + return nil, err + } + probe := path + if installedOnly { + probe = filepath.Join(path, "skills", "dci-cli") + } + if info, statErr := os.Stat(probe); statErr == nil && info.IsDir() { + result = append(result, struct { + Agent skillAgent + Path string + }{Agent: agent, Path: path}) + } + } + return result, nil +} + +func registerSkillCommands() { + var installAll bool + skillCommand := &cobra.Command{ + Use: "skill", + Short: "Manage the dci skill for AI agents", + Args: cobra.NoArgs, + RunE: func(command *cobra.Command, args []string) error { + if !installAll { + return command.Help() + } + targets, err := detectedSkillTargets(false) + if err != nil { + return err + } + if len(targets) == 0 { + return errorsForNoDetectedAgents() + } + for _, target := range targets { + if err := installSkill(target.Path); err != nil { + return fmt.Errorf("install %s skill: %w", target.Agent.Name, err) + } + fmt.Fprintf(os.Stdout, "Skill installed for %s at %s\n", target.Agent.Name, filepath.Join(target.Path, "skills", "dci-cli")) + } + return nil + }, + } + skillCommand.Flags().BoolVar(&installAll, "all", false, "Install into every detected agent directory") + + for _, configuredAgent := range skillAgents { + agent := configuredAgent + var targetOverride string + agentCommand := &cobra.Command{ + Use: agent.Name, + Short: fmt.Sprintf("Install skill for %s", agent.Name), + Args: cobra.NoArgs, + RunE: func(command *cobra.Command, args []string) error { + target, err := resolvedSkillTarget(agent, targetOverride) + if err != nil { + return err + } + if err := installSkill(target); err != nil { + return fmt.Errorf("install %s skill: %w", agent.Name, err) + } + fmt.Fprintf(os.Stdout, "Skill installed to %s\n", filepath.Join(target, "skills", "dci-cli")) + return nil + }, + } + agentCommand.Flags().StringVar(&targetOverride, "dir", "", "Override the agent configuration directory") + skillCommand.AddCommand(agentCommand) + } + + skillCommand.AddCommand(newSkillListCommand()) + skillCommand.AddCommand(newSkillUpdateCommand()) + cli.Root.AddCommand(skillCommand) +} + +func newSkillListCommand() *cobra.Command { + var jsonOutput bool + command := &cobra.Command{ + Use: "list", + Short: "List embedded skill files and token estimates", + Args: cobra.NoArgs, + RunE: func(command *cobra.Command, args []string) error { + files, err := embeddedSkillFiles() + if err != nil { + return err + } + if jsonOutput || agentMode { + return json.NewEncoder(os.Stdout).Encode(map[string]interface{}{"files": files}) + } + fmt.Fprintln(os.Stdout, "PATH\tBYTES\tESTIMATED TOKENS") + for _, file := range files { + fmt.Fprintf(os.Stdout, "%s\t%d\t%d\n", file.Path, file.Bytes, file.EstimatedTokens) + } + return nil + }, + } + command.Flags().BoolVar(&jsonOutput, "json", false, "Emit JSON") + return command +} + +func newSkillUpdateCommand() *cobra.Command { + var targetOverride string + var force bool + command := &cobra.Command{ + Use: "update [agent]", + Short: "Refresh installed skill files from this CLI version", + Args: cobra.MaximumNArgs(1), + RunE: func(command *cobra.Command, args []string) error { + targets, err := skillUpdateTargets(args, targetOverride) + if err != nil { + return err + } + for _, target := range targets { + diff, err := inspectInstalledSkill(target.Path) + if err != nil { + return err + } + if diff.HasLocalChanges() && !force { + return fmt.Errorf("installed %s skill has local changes to %s; inspect them and re-run with --force to overwrite", target.Agent.Name, strings.Join(diff.LocalChangePaths(), ", ")) + } + if err := installSkill(target.Path); err != nil { + return fmt.Errorf("update %s skill: %w", target.Agent.Name, err) + } + fmt.Fprintf(os.Stdout, "Skill updated for %s at %s\n", target.Agent.Name, filepath.Join(target.Path, "skills", "dci-cli")) + } + return nil + }, + } + command.Flags().StringVar(&targetOverride, "dir", "", "Override the agent configuration directory") + command.Flags().BoolVar(&force, "force", false, "Overwrite locally edited skill files") + return command +} + +func skillUpdateTargets(args []string, targetOverride string) ([]struct { + Agent skillAgent + Path string +}, error) { + if len(args) == 0 { + if targetOverride != "" { + return nil, errorsForInvalidAgentArgument("--dir requires an agent argument") + } + targets, err := detectedSkillTargets(true) + if err != nil { + return nil, err + } + if len(targets) == 0 { + return nil, errorsForNoDetectedAgents() + } + return targets, nil + } + agent, exists := skillAgentByName(args[0]) + if !exists { + return nil, errorsForInvalidAgentArgument(fmt.Sprintf("unknown agent %q", args[0])) + } + target, err := resolvedSkillTarget(agent, targetOverride) + if err != nil { + return nil, err + } + if _, statErr := os.Stat(filepath.Join(target, "skills", "dci-cli")); statErr != nil { + if os.IsNotExist(statErr) { + return nil, fmt.Errorf("skill is not installed for %s; run dci skill %s first", agent.Name, agent.Name) + } + return nil, statErr + } + return []struct { + Agent skillAgent + Path string + }{{Agent: agent, Path: target}}, nil +} + +func errorsForNoDetectedAgents() error { + return errorsForInvalidAgentArgument("no supported agent directories were detected") +} + +func errorsForInvalidAgentArgument(message string) error { + return fmt.Errorf("invalid argument: %s", message) +} diff --git a/skill_management_test.go b/skill_management_test.go new file mode 100644 index 0000000..39adda2 --- /dev/null +++ b/skill_management_test.go @@ -0,0 +1,179 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEmbeddedSkillFilesIncludesTokenEstimates(t *testing.T) { + files, err := embeddedSkillFiles() + if err != nil { + t.Fatal(err) + } + if len(files) == 0 { + t.Fatal("expected embedded skill files") + } + for _, file := range files { + if file.Bytes <= 0 || file.EstimatedTokens <= 0 { + t.Fatalf("invalid file estimate: %+v", file) + } + if file.EstimatedTokens != (file.Bytes+3)/4 { + t.Fatalf("estimate for %s = %d", file.Path, file.EstimatedTokens) + } + } +} + +func TestInspectInstalledSkillDetectsLocalChanges(t *testing.T) { + target := t.TempDir() + if err := installSkill(target); err != nil { + t.Fatal(err) + } + + clean, err := inspectInstalledSkill(target) + if err != nil { + t.Fatal(err) + } + if clean.HasLocalChanges() || len(clean.Missing) != 0 { + t.Fatalf("fresh install is dirty: %+v", clean) + } + + changedPath := filepath.Join(target, "skills", "dci-cli", "SKILL.md") + if err := os.WriteFile(changedPath, []byte("locally edited\n"), 0o644); err != nil { + t.Fatal(err) + } + extraPath := filepath.Join(target, "skills", "dci-cli", "local.md") + if err := os.WriteFile(extraPath, []byte("local\n"), 0o644); err != nil { + t.Fatal(err) + } + + diff, err := inspectInstalledSkill(target) + if err != nil { + t.Fatal(err) + } + if !diff.HasLocalChanges() { + t.Fatal("expected local changes") + } + if !containsString(diff.Changed, "SKILL.md") || !containsString(diff.Extra, "local.md") { + t.Fatalf("unexpected diff: %+v", diff) + } +} + +func TestInspectInstalledSkillTreatsMissingFileAsLocalChange(t *testing.T) { + target := t.TempDir() + if err := installSkill(target); err != nil { + t.Fatal(err) + } + + missingPath := filepath.Join(target, "skills", "dci-cli", "SKILL.md") + if err := os.Remove(missingPath); err != nil { + t.Fatal(err) + } + + diff, err := inspectInstalledSkill(target) + if err != nil { + t.Fatal(err) + } + if !diff.HasLocalChanges() { + t.Fatal("missing embedded file was not treated as a local change") + } + if !containsString(diff.Missing, "SKILL.md") || !containsString(diff.LocalChangePaths(), "SKILL.md") { + t.Fatalf("unexpected diff: %+v", diff) + } +} + +func TestInspectInstalledSkillAcceptsFilesFromPreviousManifest(t *testing.T) { + target := t.TempDir() + if err := installSkill(target); err != nil { + t.Fatal(err) + } + root := filepath.Join(target, "skills", "dci-cli") + manifest, exists, err := readSkillManifest(root) + if err != nil { + t.Fatal(err) + } + if !exists { + t.Fatal("skill manifest was not installed") + } + + previous := []byte("previous release\n") + if err := os.WriteFile(filepath.Join(root, "SKILL.md"), previous, 0o644); err != nil { + t.Fatal(err) + } + manifest.Files["SKILL.md"] = skillFileDigest(previous) + if err := writeSkillManifest(root, manifest); err != nil { + t.Fatal(err) + } + + diff, err := inspectInstalledSkill(target) + if err != nil { + t.Fatal(err) + } + if diff.HasLocalChanges() { + t.Fatalf("previous installed version was treated as a local edit: %+v", diff) + } + if err := installSkill(target); err != nil { + t.Fatal(err) + } + updated, err := inspectInstalledSkill(target) + if err != nil { + t.Fatal(err) + } + if updated.HasLocalChanges() { + t.Fatalf("updated skill is dirty: %+v", updated) + } +} + +func TestInspectInstalledSkillDoesNotBlockOnExtraFiles(t *testing.T) { + target := t.TempDir() + if err := installSkill(target); err != nil { + t.Fatal(err) + } + extraPath := filepath.Join(target, "skills", "dci-cli", ".DS_Store") + if err := os.WriteFile(extraPath, []byte("metadata"), 0o644); err != nil { + t.Fatal(err) + } + + diff, err := inspectInstalledSkill(target) + if err != nil { + t.Fatal(err) + } + if !containsString(diff.Extra, ".DS_Store") { + t.Fatalf("extra file was not reported: %+v", diff) + } + if diff.HasLocalChanges() { + t.Fatalf("non-overwritten extra file blocked update: %+v", diff) + } +} + +func TestSkillUpdateTargetsSupportsCustomDirectory(t *testing.T) { + target := t.TempDir() + if err := installSkill(target); err != nil { + t.Fatal(err) + } + + targets, err := skillUpdateTargets([]string{"codex"}, target) + if err != nil { + t.Fatal(err) + } + if len(targets) != 1 || targets[0].Agent.Name != "codex" || targets[0].Path != target { + t.Fatalf("unexpected targets: %+v", targets) + } +} + +func TestSkillUpdateTargetsRejectsUnknownAgent(t *testing.T) { + _, err := skillUpdateTargets([]string{"unknown"}, "") + if err == nil || !strings.Contains(err.Error(), "invalid argument") { + t.Fatalf("error = %v", err) + } +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/skills/dci-cli/SKILL.md b/skills/dci-cli/SKILL.md index 4cfb1eb..4821362 100644 --- a/skills/dci-cli/SKILL.md +++ b/skills/dci-cli/SKILL.md @@ -24,6 +24,8 @@ Use `--fields id,name` to project list or detail responses before output, and us 3. Discover command shape before drafting or running commands: `dci --help` and `dci --help` 4. Prefer `list-*`, `get-*`, `get-report`, and `query` before `create-*`, `update-*`, or `delete-*` +Use `dci skill list` to inspect the files embedded in the installed CLI. Use `dci skill update ` to refresh one installed copy, or omit the agent to update every detected installation; locally edited managed files require an explicit `--force` overwrite. + ## Query Modes Use `dci query` in two modes: