diff --git a/internal/runtime/claude.go b/internal/runtime/claude.go index ee09e4e407..9a87dc21a9 100644 --- a/internal/runtime/claude.go +++ b/internal/runtime/claude.go @@ -12,6 +12,7 @@ import ( "github.com/fullsend-ai/fullsend/internal/sandbox" "github.com/fullsend-ai/fullsend/internal/security" + "github.com/fullsend-ai/fullsend/internal/skill" "github.com/fullsend-ai/fullsend/internal/ui" ) @@ -58,7 +59,7 @@ func (r ClaudeRuntime) Bootstrap(input BootstrapInput) error { fmt.Sprintf("%s/skills/", configDir)); err != nil { return fmt.Errorf("copying skill %q: %w", skillPath, err) } - fmt.Fprintf(os.Stderr, "Skill %q: uploaded to sandbox\n", filepath.Base(skillPath)) + fmt.Fprintf(os.Stderr, "Skill %q: uploaded to sandbox\n", resolveSkillDisplayName(skillPath)) } var pluginDirs []string @@ -192,6 +193,22 @@ func (ClaudeRuntime) EmitTranscriptErrors(w io.Writer, summaries []TranscriptErr emitTranscriptErrors(w, summaries) } +// resolveSkillDisplayName returns a human-friendly name for a skill directory. +// It reads the SKILL.md frontmatter name if available, falling back to +// filepath.Base for local skills where the directory name is already correct. +func resolveSkillDisplayName(skillPath string) string { + base := filepath.Base(skillPath) + data, err := os.ReadFile(filepath.Join(skillPath, "SKILL.md")) + if err != nil { + return base + } + meta, err := skill.ParseFrontmatter(data) + if err != nil || meta == nil || meta.Name == "" { + return base + } + return meta.Name +} + func buildRunCommand(params RunParams) string { envFile := sandbox.SandboxWorkspace + "/.env" safe := strings.ReplaceAll(params.AgentBaseName, "'", "'\\''") diff --git a/internal/runtime/claude_test.go b/internal/runtime/claude_test.go index 06cdce57bc..07c0e68bd9 100644 --- a/internal/runtime/claude_test.go +++ b/internal/runtime/claude_test.go @@ -334,3 +334,58 @@ func TestClaudeRuntime_ExtractTranscripts_OpenshellNotInPath(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "finding transcripts") } + +func TestResolveSkillDisplayName(t *testing.T) { + tests := []struct { + name string + dirName string + skillMD string // empty means no SKILL.md + expected string + }{ + { + name: "frontmatter name overrides directory name", + dirName: "tree", + skillMD: "---\nname: architecture\n---\n# Architecture skill", + expected: "architecture", + }, + { + name: "falls back to filepath.Base when no SKILL.md", + dirName: "my-skill", + skillMD: "", + expected: "my-skill", + }, + { + name: "falls back when frontmatter has no name field", + dirName: "tree", + skillMD: "---\ndescription: some skill\n---\n# Content", + expected: "tree", + }, + { + name: "falls back when SKILL.md has no frontmatter", + dirName: "tree", + skillMD: "# Just a heading\nNo frontmatter here.", + expected: "tree", + }, + { + name: "local skill with matching directory name", + dirName: "public-research", + skillMD: "---\nname: public-research\n---\n# Public Research", + expected: "public-research", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), tc.dirName) + require.NoError(t, os.MkdirAll(dir, 0o755)) + if tc.skillMD != "" { + require.NoError(t, os.WriteFile( + filepath.Join(dir, "SKILL.md"), + []byte(tc.skillMD), 0o644)) + } + + got := resolveSkillDisplayName(dir) + assert.Equal(t, tc.expected, got) + }) + } +}