From 8bde6f4006d5d61d01a261b8dff6ce21a227a6f1 Mon Sep 17 00:00:00 2001 From: AaronZ345 <34849476+AaronZ345@users.noreply.github.com> Date: Sat, 20 Jun 2026 19:05:39 +0800 Subject: [PATCH 1/2] fix(skills): include plugin skill roots safely --- CHANGELOG.md | 1 + agent/claudecode/claudecode.go | 86 +++++++++++--------- agent/claudecode/skilldirs_test.go | 80 ++++++++++++++++++ agent/codex/codex.go | 17 ++-- agent/codex/session_test.go | 11 +++ agent/codex/skilldirs_test.go | 80 ++++++++++++++++++ agent/internal/skillroots/skillroots.go | 68 ++++++++++++++++ agent/internal/skillroots/skillroots_test.go | 66 +++++++++++++++ 8 files changed, 364 insertions(+), 45 deletions(-) create mode 100644 agent/internal/skillroots/skillroots.go create mode 100644 agent/internal/skillroots/skillroots_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index cb64b21eab..fbb318c2ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ - **kimi**: native Kimi Code CLI (Node.js `kimi-code`) dialect support (#1561). The newer CLI removed `--quiet` and `--resume`, moved the session resume hint from a plain-text line to a stdout JSON meta event (`{"role":"meta","type":"session.resume_hint",...}`), and emits assistant/tool `content` as a plain string instead of typed blocks — causing `error: unknown option`, silently dropped replies, and broken multi-turn continuity. The probe now also gates `--quiet` (emulated locally by suppressing thinking/tool events when unsupported), resume uses `-r ` on the modern dialect (the command the CLI's own hint prints), the meta resume-hint event restores the session ID, and the stream parser accepts both content shapes regardless of probed flavor. Session listing additionally scans `~/.kimi-code/sessions` with its `state.json` schema (`title`/`workDir`, honoring the workDir filter) and counts messages from `agents/main/wire.jsonl` so `/list` and `/delete` work for both CLI flavors. Legacy kimi-cli behavior is unchanged. - **core**: queue post-restart notification and dispatch on platform ready (#1383). Previously `/restart` sent the success notification immediately after engine startup, racing the platform's async connect window (Telegram: ~2.6s). On a not-yet-ready platform the send was silently dropped at debug log level. The notify is now queued on the engine and dispatched when the target platform reaches `OnPlatformReady`, with bounded retry (3 attempts, 0/500/1500 ms backoff) on transient send failure. Failed sends log at warn level. A 10s safety timeout drops the notify with a warning if the target platform never reaches ready, so startup is never blocked indefinitely. Also covers Discord / Weixin / Matrix (other AsyncRecoverablePlatform implementations) for free. - **core**: `SaveFilesToDisk` / `AppendFileRefs` always emit absolute paths (#1459). When a user configured a relative `work_dir` (e.g. `~/project` or `.cc-connect`), `SaveFilesToDisk` joined relative paths into the attachments directory and the resulting paths were passed verbatim into the agent's prompt. The spawned agent process — typically run from a different cwd by the platform adapter — could not resolve them and silently dropped every attachment. `SaveFilesToDisk` now calls `filepath.Abs(workDir)` up front and falls back to the raw value on error, and `AppendFileRefs` defensively absolutizes each entry. Both behaviors are covered by new tests for relative, absolute, and empty workDir; the empty-workDir case falls back to the process cwd so misconfigured deploys still get a writable attachments directory. +- **Plugin skill roots**: Codex and Claude Code static skill discovery now includes plugin-provided `skills` directories while preserving depth-1 registration, so bundled plugin skills are listed without reintroducing nested reference-template leaks. - **core**: prevent same-name file attachments from overwriting each other. `SaveFilesToDisk` now scopes files by message ID, keeps duplicate names distinct within one message, and uses an atomic no-overwrite fallback for legacy callers without a message ID (#1552). diff --git a/agent/claudecode/claudecode.go b/agent/claudecode/claudecode.go index 19b6c3c9c6..0f1d2e8b44 100644 --- a/agent/claudecode/claudecode.go +++ b/agent/claudecode/claudecode.go @@ -17,6 +17,7 @@ import ( "time" "unicode/utf8" + "github.com/chenhg5/cc-connect/agent/internal/skillroots" "github.com/chenhg5/cc-connect/core" ) @@ -48,9 +49,9 @@ type Agent struct { providers []core.ProviderConfig activeIdx int // -1 = no provider set sessionEnv []string - routerURL string // Claude Code Router URL (e.g., "http://127.0.0.1:3456") - routerAPIKey string // Claude Code Router API key (optional) - systemPrompt string // Custom system prompt to pass to Claude CLI + routerURL string // Claude Code Router URL (e.g., "http://127.0.0.1:3456") + routerAPIKey string // Claude Code Router API key (optional) + systemPrompt string // Custom system prompt to pass to Claude CLI pluginDirs []string // Plugin directories to load via --plugin-dir (repeatable) appendSystemPrompt string // Custom text appended to the system prompt (keeps Claude's default) @@ -83,41 +84,41 @@ type Agent struct { } var claudeProviderManagedEnvVars = map[string]struct{}{ - "CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST": {}, - "CLAUDE_CODE_USE_BEDROCK": {}, - "CLAUDE_CODE_USE_VERTEX": {}, - "CLAUDE_CODE_USE_FOUNDRY": {}, - "ANTHROPIC_BASE_URL": {}, - "ANTHROPIC_BEDROCK_BASE_URL": {}, - "ANTHROPIC_VERTEX_BASE_URL": {}, - "ANTHROPIC_FOUNDRY_BASE_URL": {}, - "ANTHROPIC_FOUNDRY_RESOURCE": {}, - "ANTHROPIC_VERTEX_PROJECT_ID": {}, - "CLOUD_ML_REGION": {}, - "ANTHROPIC_API_KEY": {}, - "ANTHROPIC_AUTH_TOKEN": {}, - "CLAUDE_CODE_OAUTH_TOKEN": {}, - "AWS_BEARER_TOKEN_BEDROCK": {}, - "ANTHROPIC_FOUNDRY_API_KEY": {}, - "CLAUDE_CODE_SKIP_BEDROCK_AUTH": {}, - "CLAUDE_CODE_SKIP_VERTEX_AUTH": {}, - "CLAUDE_CODE_SKIP_FOUNDRY_AUTH": {}, - "ANTHROPIC_MODEL": {}, - "ANTHROPIC_DEFAULT_HAIKU_MODEL": {}, - "ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION": {}, - "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME": {}, - "ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES": {}, - "ANTHROPIC_DEFAULT_OPUS_MODEL": {}, - "ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION": {}, - "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME": {}, - "ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES": {}, + "CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST": {}, + "CLAUDE_CODE_USE_BEDROCK": {}, + "CLAUDE_CODE_USE_VERTEX": {}, + "CLAUDE_CODE_USE_FOUNDRY": {}, + "ANTHROPIC_BASE_URL": {}, + "ANTHROPIC_BEDROCK_BASE_URL": {}, + "ANTHROPIC_VERTEX_BASE_URL": {}, + "ANTHROPIC_FOUNDRY_BASE_URL": {}, + "ANTHROPIC_FOUNDRY_RESOURCE": {}, + "ANTHROPIC_VERTEX_PROJECT_ID": {}, + "CLOUD_ML_REGION": {}, + "ANTHROPIC_API_KEY": {}, + "ANTHROPIC_AUTH_TOKEN": {}, + "CLAUDE_CODE_OAUTH_TOKEN": {}, + "AWS_BEARER_TOKEN_BEDROCK": {}, + "ANTHROPIC_FOUNDRY_API_KEY": {}, + "CLAUDE_CODE_SKIP_BEDROCK_AUTH": {}, + "CLAUDE_CODE_SKIP_VERTEX_AUTH": {}, + "CLAUDE_CODE_SKIP_FOUNDRY_AUTH": {}, + "ANTHROPIC_MODEL": {}, + "ANTHROPIC_DEFAULT_HAIKU_MODEL": {}, + "ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION": {}, + "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME": {}, + "ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES": {}, + "ANTHROPIC_DEFAULT_OPUS_MODEL": {}, + "ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION": {}, + "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME": {}, + "ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES": {}, // Provider-specific base URL env vars for thinking rewrite proxy routing. // These are set by cc-connect when thinking override is needed for // Bedrock/Vertex/Foundry providers that don't use base_url config. - "ANTHROPIC_BEDROCK_PROXY_BASE_URL": {}, - "ANTHROPIC_VERTEX_PROXY_BASE_URL": {}, - "ANTHROPIC_FOUNDRY_PROXY_BASE_URL": {}, + "ANTHROPIC_BEDROCK_PROXY_BASE_URL": {}, + "ANTHROPIC_VERTEX_PROXY_BASE_URL": {}, + "ANTHROPIC_FOUNDRY_PROXY_BASE_URL": {}, "ANTHROPIC_DEFAULT_SONNET_MODEL": {}, "ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION": {}, "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": {}, @@ -1020,12 +1021,13 @@ func (a *Agent) CommandDirs() []string { func (a *Agent) SkillDirs() []string { a.mu.RLock() workDir := a.workDir + pluginDirs := append([]string(nil), a.pluginDirs...) a.mu.RUnlock() absDir, err := filepath.Abs(workDir) if err != nil { absDir = workDir } - return appendProjectClaudeSkillDirs(absDir, claudeConfigHomeDir()) + return claudeSkillDirs(absDir, claudeConfigHomeDir(), pluginDirs) } // ── ContextCompressor implementation ────────────────────────── @@ -1043,13 +1045,17 @@ func claudeConfigHomeDir() string { return filepath.Join(home, ".claude") } -func appendProjectClaudeSkillDirs(workDir, configHome string) []string { +func claudeSkillDirs(workDir, configHome string, pluginDirs []string) []string { home, _ := os.UserHomeDir() - projectDirs := walkUpClaudeSkillDirs(workDir, home) - if configHome == "" { - return projectDirs + dirs := walkUpClaudeSkillDirs(workDir, home) + if configHome != "" { + dirs = append(dirs, filepath.Join(configHome, "skills")) + dirs = append(dirs, skillroots.Find(filepath.Join(configHome, "plugins"))...) } - return uniqueSkillDirs(append(projectDirs, filepath.Join(configHome, "skills"))) + for _, pluginDir := range pluginDirs { + dirs = append(dirs, skillroots.Find(pluginDir)...) + } + return uniqueSkillDirs(dirs) } func walkUpClaudeSkillDirs(workDir, home string) []string { diff --git a/agent/claudecode/skilldirs_test.go b/agent/claudecode/skilldirs_test.go index 5c6127101f..40ffbf6a96 100644 --- a/agent/claudecode/skilldirs_test.go +++ b/agent/claudecode/skilldirs_test.go @@ -3,6 +3,7 @@ package claudecode import ( "os" "path/filepath" + "runtime" "testing" ) @@ -65,3 +66,82 @@ func TestSkillDirs_FallsBackToHomeClaudeDir(t *testing.T) { t.Fatalf("last SkillDirs() = %q, want %q\nfull=%v", got[len(got)-1], wantLast, got) } } + +func TestSkillDirs_IncludesClaudePluginSkillRoots(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + configHome := filepath.Join(tmp, "profile-home") + workDir := filepath.Join(tmp, "workspace") + configPluginSkills := filepath.Join(configHome, "plugins", "cache", "claude-plugins-official", "notion", "0.1.0", "skills") + explicitPluginSkills := filepath.Join(tmp, "external-plugins", "vendor", "plugin-dev", "skills") + explicitSkillsRoot := filepath.Join(tmp, "direct", "skills") + if err := os.MkdirAll(workDir, 0o755); err != nil { + t.Fatalf("mkdir workdir: %v", err) + } + for _, dir := range []string{configPluginSkills, explicitPluginSkills, explicitSkillsRoot} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + } + if err := os.MkdirAll(filepath.Join(configPluginSkills, "notion", "references", "template", "skills"), 0o755); err != nil { + t.Fatalf("mkdir nested asset skills dir: %v", err) + } + t.Setenv("HOME", home) + t.Setenv("CLAUDE_CONFIG_DIR", configHome) + + a := &Agent{ + workDir: workDir, + pluginDirs: []string{filepath.Join(tmp, "external-plugins"), explicitSkillsRoot}, + } + got := map[string]bool{} + for _, dir := range a.SkillDirs() { + got[dir] = true + } + + want := []string{ + filepath.Join(configHome, "skills"), + configPluginSkills, + explicitPluginSkills, + explicitSkillsRoot, + } + for _, dir := range want { + if !got[dir] { + t.Fatalf("SkillDirs missing %q, dirs=%v", dir, a.SkillDirs()) + } + } + if got[filepath.Join(configPluginSkills, "notion", "references", "template", "skills")] { + t.Fatalf("SkillDirs must not include nested skills directories inside plugin skill roots: %v", a.SkillDirs()) + } +} + +func TestSkillDirs_FollowsClaudePluginSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation requires administrator on Windows") + } + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + configHome := filepath.Join(tmp, "profile-home") + workDir := filepath.Join(tmp, "workspace") + mainClaudePlugins := filepath.Join(home, ".claude", "plugins") + pluginSkillsDir := filepath.Join(configHome, "plugins", "cache", "claude-plugins-official", "notion", "0.1.0", "skills") + + t.Setenv("HOME", home) + t.Setenv("CLAUDE_CONFIG_DIR", configHome) + for _, dir := range []string{workDir, configHome, filepath.Join(mainClaudePlugins, "cache", "claude-plugins-official", "notion", "0.1.0", "skills")} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + } + if err := os.Symlink(mainClaudePlugins, filepath.Join(configHome, "plugins")); err != nil { + t.Fatalf("symlink plugins: %v", err) + } + + a := &Agent{workDir: workDir} + got := map[string]bool{} + for _, dir := range a.SkillDirs() { + got[dir] = true + } + if !got[pluginSkillsDir] { + t.Fatalf("SkillDirs() missing plugin root through symlink %q, dirs=%v", pluginSkillsDir, a.SkillDirs()) + } +} diff --git a/agent/codex/codex.go b/agent/codex/codex.go index 1312edb5f9..e0482fa954 100644 --- a/agent/codex/codex.go +++ b/agent/codex/codex.go @@ -15,6 +15,7 @@ import ( "github.com/BurntSushi/toml" + "github.com/chenhg5/cc-connect/agent/internal/skillroots" "github.com/chenhg5/cc-connect/core" ) @@ -353,7 +354,6 @@ func readCodexCachedModels() []core.ModelOption { return parseCodexModelsJSON(b) } - // parseCodexModelsJSON parses a Codex models JSON file (model_catalog.json // or models_cache.json) into a deduplicated, filtered slice of ModelOption. // It is shared by readCodexCachedModels and readCodexModelCatalog. @@ -399,7 +399,6 @@ func parseCodexModelsJSON(data []byte) []core.ModelOption { return models } - // readCodexModelCatalog reads $CODEX_HOME/config.toml to find the // model_catalog_json setting, then reads and parses that JSON file. // This is the authoritative source of model metadata for Codex CLI, @@ -607,12 +606,19 @@ func codexSkillDirs(workDir, explicitCodexHome string) []string { } projectDirs := walkUpCodexProjectSkillDirs(workDir, homeDir) - userDirs := make([]string, 0, 2) + userDirs := make([]string, 0, 4) if codexHome != "" { - userDirs = append(userDirs, filepath.Join(codexHome, "skills")) + userDirs = append(userDirs, + filepath.Join(codexHome, "skills"), + filepath.Join(codexHome, "superpowers", "skills"), + ) + userDirs = append(userDirs, skillroots.Find(filepath.Join(codexHome, "plugins"))...) } if homeDir != "" { - userDirs = append(userDirs, filepath.Join(homeDir, ".agents", "skills")) + userDirs = append(userDirs, + filepath.Join(homeDir, ".agents", "skills"), + filepath.Join(homeDir, ".claude", "skills"), + ) } return uniqueCodexSkillDirs(append(projectDirs, userDirs...)) } @@ -630,6 +636,7 @@ func walkUpCodexProjectSkillDirs(workDir, homeDir string) []string { dirs = append(dirs, filepath.Join(current, ".agents", "skills"), filepath.Join(current, ".codex", "skills"), + filepath.Join(current, ".claude", "skills"), ) if stopAt != "" && sameCodexPath(current, stopAt) { break diff --git a/agent/codex/session_test.go b/agent/codex/session_test.go index c789557e26..10799e748e 100644 --- a/agent/codex/session_test.go +++ b/agent/codex/session_test.go @@ -284,6 +284,11 @@ func TestGetModelAndReasoningEffort_FromRuntimeConfigWhenUnset(t *testing.T) { if err := os.MkdirAll(binDir, 0o755); err != nil { t.Fatalf("mkdir bin: %v", err) } + oldTimeout := codexRuntimeConfigTimeout + codexRuntimeConfigTimeout = 5 * time.Second + t.Cleanup(func() { + codexRuntimeConfigTimeout = oldTimeout + }) script := `#!/bin/sh while IFS= read -r line; do @@ -735,11 +740,17 @@ func writeFakeCodexScript(t *testing.T, dir, shellScript, powershellScript strin func waitForArgsFile(t *testing.T, path string) []string { t.Helper() deadline := time.Now().Add(5 * time.Second) + var last string for time.Now().Before(deadline) { data, err := os.ReadFile(path) if err == nil { text := strings.TrimSpace(string(data)) if text != "" { + if text != last { + last = text + time.Sleep(20 * time.Millisecond) + continue + } lines := strings.Split(text, "\n") args := make([]string, 0, len(lines)) for _, line := range lines { diff --git a/agent/codex/skilldirs_test.go b/agent/codex/skilldirs_test.go index 418adf6084..82c121cbd6 100644 --- a/agent/codex/skilldirs_test.go +++ b/agent/codex/skilldirs_test.go @@ -37,12 +37,17 @@ func TestSkillDirs_UsesProjectAgentAndCodexHomes(t *testing.T) { want := []string{ filepath.Join(workDir, ".agents", "skills"), filepath.Join(workDir, ".codex", "skills"), + filepath.Join(workDir, ".claude", "skills"), filepath.Join(repo, "nested", ".agents", "skills"), filepath.Join(repo, "nested", ".codex", "skills"), + filepath.Join(repo, "nested", ".claude", "skills"), filepath.Join(repo, ".agents", "skills"), filepath.Join(repo, ".codex", "skills"), + filepath.Join(repo, ".claude", "skills"), filepath.Join(codexHome, "skills"), + filepath.Join(codexHome, "superpowers", "skills"), filepath.Join(home, ".agents", "skills"), + filepath.Join(home, ".claude", "skills"), } if len(got) != len(want) { t.Fatalf("len(SkillDirs()) = %d, want %d\n got=%v", len(got), len(want), got) @@ -80,6 +85,81 @@ func TestSkillDirs_FallsBackToEnvCodexHome(t *testing.T) { } } +func TestSkillDirs_IncludesCodexPluginSkillRoots(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + workDir := filepath.Join(tmp, "workspace") + codexHome := filepath.Join(tmp, "codex-home") + pluginSkillsDir := filepath.Join(codexHome, "plugins", "cache", "openai-curated", "github", "hash", "skills") + + setTestHome(t, home) + t.Setenv("CODEX_HOME", codexHome) + if err := os.MkdirAll(workDir, 0o755); err != nil { + t.Fatalf("mkdir workdir: %v", err) + } + if err := os.MkdirAll(pluginSkillsDir, 0o755); err != nil { + t.Fatalf("mkdir plugin skills dir: %v", err) + } + if err := os.MkdirAll(filepath.Join(pluginSkillsDir, "github", "references", "template", "skills"), 0o755); err != nil { + t.Fatalf("mkdir nested asset skills dir: %v", err) + } + + a := &Agent{workDir: workDir} + got := map[string]bool{} + for _, dir := range a.SkillDirs() { + got[dir] = true + } + + want := []string{ + filepath.Join(workDir, ".codex", "skills"), + filepath.Join(workDir, ".claude", "skills"), + filepath.Join(codexHome, "skills"), + filepath.Join(codexHome, "superpowers", "skills"), + pluginSkillsDir, + filepath.Join(home, ".claude", "skills"), + } + for _, dir := range want { + if !got[dir] { + t.Fatalf("SkillDirs missing %q, dirs=%v", dir, a.SkillDirs()) + } + } + if got[filepath.Join(pluginSkillsDir, "github", "references", "template", "skills")] { + t.Fatalf("SkillDirs must not include nested skills directories inside plugin skill roots: %v", a.SkillDirs()) + } +} + +func TestSkillDirs_FollowsCodexPluginSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation requires administrator on Windows") + } + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + workDir := filepath.Join(tmp, "workspace") + codexHome := filepath.Join(tmp, "codex-agent-home") + mainCodexPlugins := filepath.Join(home, ".codex", "plugins") + pluginSkillsDir := filepath.Join(codexHome, "plugins", "cache", "openai-curated", "github", "hash", "skills") + + setTestHome(t, home) + t.Setenv("CODEX_HOME", "") + for _, dir := range []string{workDir, codexHome, filepath.Join(mainCodexPlugins, "cache", "openai-curated", "github", "hash", "skills")} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + } + if err := os.Symlink(mainCodexPlugins, filepath.Join(codexHome, "plugins")); err != nil { + t.Fatalf("symlink plugins: %v", err) + } + + a := &Agent{workDir: workDir, codexHome: codexHome} + got := map[string]bool{} + for _, dir := range a.SkillDirs() { + got[dir] = true + } + if !got[pluginSkillsDir] { + t.Fatalf("SkillDirs() missing plugin root through symlink %q, dirs=%v", pluginSkillsDir, a.SkillDirs()) + } +} + func setTestHome(t *testing.T, home string) { t.Helper() t.Setenv("HOME", home) diff --git a/agent/internal/skillroots/skillroots.go b/agent/internal/skillroots/skillroots.go new file mode 100644 index 0000000000..8b0ef2a97b --- /dev/null +++ b/agent/internal/skillroots/skillroots.go @@ -0,0 +1,68 @@ +package skillroots + +import ( + "os" + "path/filepath" +) + +// Find returns nested directories named "skills" under root. +// It treats each returned directory as a skill root; callers still rely on the +// core depth-1 SkillRegistry to register only //SKILL.md. +func Find(root string) []string { + root = filepath.Clean(root) + if filepath.Base(root) == "skills" && isDir(root) { + return []string{root} + } + var result []string + seenDirs := map[string]struct{}{} + seenRoots := map[string]struct{}{} + walk(root, seenDirs, seenRoots, &result) + return result +} + +func walk(dir string, seenDirs, seenRoots map[string]struct{}, result *[]string) { + real := realDir(dir) + if real == "" { + return + } + if _, ok := seenDirs[real]; ok { + return + } + seenDirs[real] = struct{}{} + + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, entry := range entries { + child := filepath.Join(dir, entry.Name()) + if !isDir(child) { + continue + } + if entry.Name() == "skills" { + clean := filepath.Clean(child) + if _, ok := seenRoots[clean]; !ok { + seenRoots[clean] = struct{}{} + *result = append(*result, clean) + } + continue + } + walk(child, seenDirs, seenRoots, result) + } +} + +func isDir(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + +func realDir(path string) string { + if !isDir(path) { + return "" + } + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return filepath.Clean(path) + } + return filepath.Clean(resolved) +} diff --git a/agent/internal/skillroots/skillroots_test.go b/agent/internal/skillroots/skillroots_test.go new file mode 100644 index 0000000000..8883f7fc6c --- /dev/null +++ b/agent/internal/skillroots/skillroots_test.go @@ -0,0 +1,66 @@ +package skillroots + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestFindDiscoversNestedSkillRoots(t *testing.T) { + root := t.TempDir() + want := []string{ + filepath.Join(root, "cache", "vendor", "plugin", "1.0.0", "skills"), + filepath.Join(root, "marketplaces", "official", "plugins", "frontend-design", "skills"), + } + for _, dir := range want { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + } + if err := os.MkdirAll(filepath.Join(want[0], "real-skill", "references", "template", "skills"), 0o755); err != nil { + t.Fatalf("mkdir nested asset dir: %v", err) + } + + got := Find(root) + + if len(got) != len(want) { + t.Fatalf("Find() = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("Find()[%d] = %q, want %q\nfull=%v", i, got[i], want[i], got) + } + } +} + +func TestFindAcceptsRootNamedSkills(t *testing.T) { + root := filepath.Join(t.TempDir(), "skills") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatalf("mkdir root: %v", err) + } + got := Find(root) + if len(got) != 1 || got[0] != root { + t.Fatalf("Find() = %v, want [%s]", got, root) + } +} + +func TestFindDoesNotLoopOnSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation requires administrator on Windows") + } + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "plugin", "skills"), 0o755); err != nil { + t.Fatalf("mkdir plugin skills: %v", err) + } + if err := os.Symlink(root, filepath.Join(root, "plugin", "loop")); err != nil { + t.Fatalf("symlink loop: %v", err) + } + + got := Find(root) + + want := filepath.Join(root, "plugin", "skills") + if len(got) != 1 || got[0] != want { + t.Fatalf("Find() = %v, want [%s]", got, want) + } +} From e0c13f7340b00344d21fa0fdb81d7e0873fa2617 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Mon, 17 Aug 2026 12:28:58 +0800 Subject: [PATCH 2/2] docs(codex): explain shared skill directories Clarify why Codex discovers Superpowers and Claude-format skill layouts so the cross-runtime behavior is visibly intentional. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- agent/codex/codex.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/agent/codex/codex.go b/agent/codex/codex.go index e0482fa954..07fdc6c4ec 100644 --- a/agent/codex/codex.go +++ b/agent/codex/codex.go @@ -610,6 +610,7 @@ func codexSkillDirs(workDir, explicitCodexHome string) []string { if codexHome != "" { userDirs = append(userDirs, filepath.Join(codexHome, "skills"), + // Superpowers installs Codex-compatible skills under this layout. filepath.Join(codexHome, "superpowers", "skills"), ) userDirs = append(userDirs, skillroots.Find(filepath.Join(codexHome, "plugins"))...) @@ -617,6 +618,7 @@ func codexSkillDirs(workDir, explicitCodexHome string) []string { if homeDir != "" { userDirs = append(userDirs, filepath.Join(homeDir, ".agents", "skills"), + // Codex deliberately shares Claude-format SKILL.md directories. filepath.Join(homeDir, ".claude", "skills"), ) } @@ -636,6 +638,7 @@ func walkUpCodexProjectSkillDirs(workDir, homeDir string) []string { dirs = append(dirs, filepath.Join(current, ".agents", "skills"), filepath.Join(current, ".codex", "skills"), + // Keep project-local Claude-format skills portable to Codex. filepath.Join(current, ".claude", "skills"), ) if stopAt != "" && sameCodexPath(current, stopAt) {