Skip to content
Merged
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
30 changes: 25 additions & 5 deletions cmd/install_skill.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,43 @@ 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")

return installSkillFiles(SkillFS, "skills/gsuite-manager", targetDir)
}

sub, err := fs.Sub(SkillFS, embeddedRoot)
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)
}
Expand Down
126 changes: 126 additions & 0 deletions cmd/install_skill_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading