From ba7034a90ae65eff174823b5d0e0d20337a30e10 Mon Sep 17 00:00:00 2001 From: Khang Nguyen Date: Tue, 10 Feb 2026 20:40:56 -0500 Subject: [PATCH 1/2] feat: add --client flag to install-skill command Allow targeting different AI coding clients when installing skills. Defaults to "claude" (.claude/skills/) but supports "openclaw-workspace" (skills/) for installing directly into the current directory's skills folder. Co-Authored-By: Claude Opus 4.6 --- cmd/install_skill.go | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/cmd/install_skill.go b/cmd/install_skill.go index 05460f2..e8af190 100644 --- a/cmd/install_skill.go +++ b/cmd/install_skill.go @@ -13,21 +13,39 @@ import ( // SkillFS holds the embedded skill files, set by main.go before Execute(). var SkillFS embed.FS +// clientSkillDirs maps client names to their skills directory prefix. +var clientSkillDirs = map[string]string{ + "claude": filepath.Join(".claude", "skills"), + "openclaw-workspace": "skills", +} + var installSkillCmd = &cobra.Command{ Use: "install-skill", Short: "Install the Claude Code skill for Gmail management", - Long: `Writes the bundled gsuite-manager skill files into .claude/skills/gsuite-manager/ -in the current working directory. Existing files are overwritten.`, + Long: `Writes the bundled gsuite-manager skill files into the appropriate skills directory. + +By default, installs to .claude/skills/gsuite-manager/ (Claude Code). +Use --client to target a different client: + --client openclaw-workspace → skills/gsuite-manager/ + +Existing files are overwritten.`, RunE: runInstallSkill, } func init() { + installSkillCmd.Flags().String("client", "claude", "target client (claude, openclaw-workspace)") rootCmd.AddCommand(installSkillCmd) } func runInstallSkill(cmd *cobra.Command, args []string) error { const embeddedRoot = "skills/gsuite-manager" - targetDir := filepath.Join(".claude", "skills", "gsuite-manager") + + client, _ := cmd.Flags().GetString("client") + skillsDir, ok := clientSkillDirs[client] + if !ok { + return fmt.Errorf("unknown client %q (supported: claude, openclaw-workspace)", client) + } + targetDir := filepath.Join(skillsDir, "gsuite-manager") sub, err := fs.Sub(SkillFS, embeddedRoot) if err != nil { From 853a3bc49981acfb55c4204b4920596accc372c9 Mon Sep 17 00:00:00 2001 From: Khang Nguyen Date: Tue, 10 Feb 2026 20:43:42 -0500 Subject: [PATCH 2/2] test: add tests for install-skill --client flag Extract installSkillFiles() to accept fs.FS for testability. Tests cover both client targets, file overwrite behavior, unknown client error, and clientSkillDirs map completeness. Co-Authored-By: Claude Opus 4.6 --- cmd/install_skill.go | 8 ++- cmd/install_skill_test.go | 126 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 cmd/install_skill_test.go diff --git a/cmd/install_skill.go b/cmd/install_skill.go index e8af190..42ece95 100644 --- a/cmd/install_skill.go +++ b/cmd/install_skill.go @@ -38,8 +38,6 @@ func init() { } func runInstallSkill(cmd *cobra.Command, args []string) error { - const embeddedRoot = "skills/gsuite-manager" - client, _ := cmd.Flags().GetString("client") skillsDir, ok := clientSkillDirs[client] if !ok { @@ -47,7 +45,11 @@ func runInstallSkill(cmd *cobra.Command, args []string) error { } targetDir := filepath.Join(skillsDir, "gsuite-manager") - sub, err := fs.Sub(SkillFS, embeddedRoot) + return installSkillFiles(SkillFS, "skills/gsuite-manager", targetDir) +} + +func installSkillFiles(source fs.FS, embeddedRoot, targetDir string) error { + sub, err := fs.Sub(source, embeddedRoot) if err != nil { return fmt.Errorf("reading embedded skill files: %w", err) } diff --git a/cmd/install_skill_test.go b/cmd/install_skill_test.go new file mode 100644 index 0000000..15deee7 --- /dev/null +++ b/cmd/install_skill_test.go @@ -0,0 +1,126 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" +) + +func TestInstallSkillFiles(t *testing.T) { + t.Parallel() + + fakeFS := fstest.MapFS{ + "skills/gsuite-manager/SKILL.md": {Data: []byte("# Skill")}, + "skills/gsuite-manager/references/commands.md": {Data: []byte("# Commands")}, + } + + tests := []struct { + name string + client string + wantBase string + }{ + { + name: "claude client writes to .claude/skills", + client: "claude", + wantBase: filepath.Join(".claude", "skills", "gsuite-manager"), + }, + { + name: "openclaw-workspace writes to skills", + client: "openclaw-workspace", + wantBase: filepath.Join("skills", "gsuite-manager"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + targetDir := filepath.Join(tmpDir, tt.wantBase) + + err := installSkillFiles(fakeFS, "skills/gsuite-manager", targetDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + wantFiles := []struct { + rel string + content string + }{ + {"SKILL.md", "# Skill"}, + {filepath.Join("references", "commands.md"), "# Commands"}, + } + for _, wf := range wantFiles { + path := filepath.Join(targetDir, wf.rel) + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("expected file %s: %v", wf.rel, err) + } + if string(data) != wf.content { + t.Errorf("file %s = %q, want %q", wf.rel, string(data), wf.content) + } + } + }) + } +} + +func TestInstallSkillFilesOverwritesExisting(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + targetDir := filepath.Join(tmpDir, "skills", "gsuite-manager") + + // Write an old file that should be overwritten. + if err := os.MkdirAll(targetDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(targetDir, "SKILL.md"), []byte("old content"), 0o644); err != nil { + t.Fatal(err) + } + + fakeFS := fstest.MapFS{ + "skills/gsuite-manager/SKILL.md": {Data: []byte("new content")}, + } + + if err := installSkillFiles(fakeFS, "skills/gsuite-manager", targetDir); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(targetDir, "SKILL.md")) + if err != nil { + t.Fatal(err) + } + if string(data) != "new content" { + t.Errorf("file content = %q, want %q", string(data), "new content") + } +} + +func TestRunInstallSkillUnknownClient(t *testing.T) { + t.Parallel() + + cmd := installSkillCmd + if err := cmd.Flags().Set("client", "nonexistent"); err != nil { + t.Fatalf("setting flag: %v", err) + } + t.Cleanup(func() { cmd.Flags().Set("client", "claude") }) + + err := cmd.RunE(cmd, []string{}) + if err == nil { + t.Fatal("expected error for unknown client, got nil") + } + if !strings.Contains(err.Error(), `unknown client "nonexistent"`) { + t.Fatalf("error = %q, want it to contain unknown client message", err.Error()) + } +} + +func TestClientSkillDirsContainsExpectedEntries(t *testing.T) { + t.Parallel() + + expected := []string{"claude", "openclaw-workspace"} + for _, name := range expected { + if _, ok := clientSkillDirs[name]; !ok { + t.Errorf("clientSkillDirs missing entry for %q", name) + } + } +}