Fix extension skill integration edge cases
- commit
- bfc7b115f2ca280a30dce33535b496468f8761e2
- author
- patriceckhart · · 2026-09-04 14:09 UTC
- parent
- 93ad671
- signature
- unsigned
- refs
- tag: v0.3.60
12 files · +137 -40
M packages/agent/botcmd.go +1 -0
| @@ -218,6 +218,7 @@ func botRun(spec *botSpec, rawTail []string, version string) error { | |||
| 218 | 218 | if err != nil { | |
| 219 | 219 | return err | |
| 220 | 220 | } | |
| 221 | + | reportSkillDiagnostics(os.Stderr, resolved.SkillDiagnostics) | |
| 221 | 222 | ||
| 222 | 223 | if ok, cerr := spec.configured(ZotHome()); cerr != nil { | |
| 223 | 224 | return cerr | |
| @@ -218,6 +218,7 @@ func botRun(spec *botSpec, rawTail []string, version string) error { | |||||
| 218 | if err != nil { | 218 | if err != nil { | ||
| 219 | return err | 219 | return err | ||
| 220 | } | 220 | } | ||
| 221 | + | reportSkillDiagnostics(os.Stderr, resolved.SkillDiagnostics) | |||
| 221 | 222 | ||||
| 222 | if ok, cerr := spec.configured(ZotHome()); cerr != nil { | 223 | if ok, cerr := spec.configured(ZotHome()); cerr != nil { | ||
| 223 | return cerr | 224 | return cerr | ||
M packages/agent/build.go +18 -5
| @@ -53,6 +53,10 @@ type Resolved struct { | |||
| 53 | 53 | // discovered. Exposed so the tui can list / preview skills. | |
| 54 | 54 | SkillTool *skills.Tool | |
| 55 | 55 | ||
| 56 | + | // SkillDiagnostics records non-fatal skill discovery failures and | |
| 57 | + | // shadowing decisions so each host mode can report them appropriately. | |
| 58 | + | SkillDiagnostics []string | |
| 59 | + | ||
| 56 | 60 | // ContextFiles records the AGENTS.md files appended to SystemPrompt, | |
| 57 | 61 | // in effective load order. Interactive mode uses this metadata to make | |
| 58 | 62 | // otherwise invisible startup context inspectable without adding fake | |
| @@ -590,9 +594,10 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 590 | 594 | // system prompt, no `skill` tool in the registry. Useful for a | |
| 591 | 595 | // clean-room run with zero extra context biasing the model. | |
| 592 | 596 | var ( | |
| 593 | - | discovered []*skills.Skill | |
| 594 | - | skillTool *skills.Tool | |
| 595 | - | skillAddendum string | |
| 597 | + | discovered []*skills.Skill | |
| 598 | + | skillTool *skills.Tool | |
| 599 | + | skillAddendum string | |
| 600 | + | skillDiagnostics []string | |
| 596 | 601 | ) | |
| 597 | 602 | if !args.NoSkill { | |
| 598 | 603 | homeDir, _ := os.UserHomeDir() | |
| @@ -603,10 +608,17 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 603 | 608 | } | |
| 604 | 609 | } | |
| 605 | 610 | if !args.NoExt || len(args.Exts) > 0 { | |
| 606 | - | extSources, _ := extensions.PlanSkillSources(ZotHome(), args.CWD, args.Exts, !args.NoExt) | |
| 611 | + | extSources, errs := extensions.PlanSkillSources(ZotHome(), args.CWD, args.Exts, !args.NoExt) | |
| 607 | 612 | sources = append(extSources, sources...) | |
| 613 | + | for _, err := range errs { | |
| 614 | + | skillDiagnostics = append(skillDiagnostics, err.Error()) | |
| 615 | + | } | |
| 616 | + | } | |
| 617 | + | var errs []error | |
| 618 | + | discovered, errs = skills.DiscoverSources(sources, true) | |
| 619 | + | for _, err := range errs { | |
| 620 | + | skillDiagnostics = append(skillDiagnostics, err.Error()) | |
| 608 | 621 | } | |
| 609 | - | discovered, _ = skills.DiscoverSources(sources, true) | |
| 610 | 622 | if len(discovered) > 0 { | |
| 611 | 623 | skillTool = skills.NewTool(discovered) | |
| 612 | 624 | reg[skillTool.Name()] = skillTool | |
| @@ -678,6 +690,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 678 | 690 | MaxOutput: resolvedModel.MaxOutput, | |
| 679 | 691 | Sandbox: sandbox, | |
| 680 | 692 | SkillTool: skillTool, | |
| 693 | + | SkillDiagnostics: skillDiagnostics, | |
| 681 | 694 | ContextFiles: contextFiles, | |
| 682 | 695 | systemAppend: append_, | |
| 683 | 696 | systemCustom: custom, | |
| @@ -53,6 +53,10 @@ type Resolved struct { | |||||
| 53 | // discovered. Exposed so the tui can list / preview skills. | 53 | // discovered. Exposed so the tui can list / preview skills. | ||
| 54 | SkillTool *skills.Tool | 54 | SkillTool *skills.Tool | ||
| 55 | 55 | ||||
| 56 | + | // SkillDiagnostics records non-fatal skill discovery failures and | |||
| 57 | + | // shadowing decisions so each host mode can report them appropriately. | |||
| 58 | + | SkillDiagnostics []string | |||
| 59 | + | ||||
| 56 | // ContextFiles records the AGENTS.md files appended to SystemPrompt, | 60 | // ContextFiles records the AGENTS.md files appended to SystemPrompt, | ||
| 57 | // in effective load order. Interactive mode uses this metadata to make | 61 | // in effective load order. Interactive mode uses this metadata to make | ||
| 58 | // otherwise invisible startup context inspectable without adding fake | 62 | // otherwise invisible startup context inspectable without adding fake | ||
| @@ -590,9 +594,10 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 590 | // system prompt, no `skill` tool in the registry. Useful for a | 594 | // system prompt, no `skill` tool in the registry. Useful for a | ||
| 591 | // clean-room run with zero extra context biasing the model. | 595 | // clean-room run with zero extra context biasing the model. | ||
| 592 | var ( | 596 | var ( | ||
| 593 | - | discovered []*skills.Skill | 597 | + | discovered []*skills.Skill |
| 594 | - | skillTool *skills.Tool | 598 | + | skillTool *skills.Tool |
| 595 | - | skillAddendum string | 599 | + | skillAddendum string |
| 600 | + | skillDiagnostics []string | |||
| 596 | ) | 601 | ) | ||
| 597 | if !args.NoSkill { | 602 | if !args.NoSkill { | ||
| 598 | homeDir, _ := os.UserHomeDir() | 603 | homeDir, _ := os.UserHomeDir() | ||
| @@ -603,10 +608,17 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 603 | } | 608 | } | ||
| 604 | } | 609 | } | ||
| 605 | if !args.NoExt || len(args.Exts) > 0 { | 610 | if !args.NoExt || len(args.Exts) > 0 { | ||
| 606 | - | extSources, _ := extensions.PlanSkillSources(ZotHome(), args.CWD, args.Exts, !args.NoExt) | 611 | + | extSources, errs := extensions.PlanSkillSources(ZotHome(), args.CWD, args.Exts, !args.NoExt) |
| 607 | sources = append(extSources, sources...) | 612 | sources = append(extSources, sources...) | ||
| 613 | + | for _, err := range errs { | |||
| 614 | + | skillDiagnostics = append(skillDiagnostics, err.Error()) | |||
| 615 | + | } | |||
| 616 | + | } | |||
| 617 | + | var errs []error | |||
| 618 | + | discovered, errs = skills.DiscoverSources(sources, true) | |||
| 619 | + | for _, err := range errs { | |||
| 620 | + | skillDiagnostics = append(skillDiagnostics, err.Error()) | |||
| 608 | } | 621 | } | ||
| 609 | - | discovered, _ = skills.DiscoverSources(sources, true) | |||
| 610 | if len(discovered) > 0 { | 622 | if len(discovered) > 0 { | ||
| 611 | skillTool = skills.NewTool(discovered) | 623 | skillTool = skills.NewTool(discovered) | ||
| 612 | reg[skillTool.Name()] = skillTool | 624 | reg[skillTool.Name()] = skillTool | ||
| @@ -678,6 +690,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 678 | MaxOutput: resolvedModel.MaxOutput, | 690 | MaxOutput: resolvedModel.MaxOutput, | ||
| 679 | Sandbox: sandbox, | 691 | Sandbox: sandbox, | ||
| 680 | SkillTool: skillTool, | 692 | SkillTool: skillTool, | ||
| 693 | + | SkillDiagnostics: skillDiagnostics, | |||
| 681 | ContextFiles: contextFiles, | 694 | ContextFiles: contextFiles, | ||
| 682 | systemAppend: append_, | 695 | systemAppend: append_, | ||
| 683 | systemCustom: custom, | 696 | systemCustom: custom, | ||
M packages/agent/build_test.go +21 -0
| @@ -219,6 +219,27 @@ func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) { | |||
| 219 | 219 | } | |
| 220 | 220 | } | |
| 221 | 221 | ||
| 222 | + | func TestResolveReportsExtensionSkillDiagnostics(t *testing.T) { | |
| 223 | + | home := t.TempDir() | |
| 224 | + | project := t.TempDir() | |
| 225 | + | t.Setenv("ZOT_HOME", home) | |
| 226 | + | extDir := filepath.Join(project, ".zot", "extensions", "bad-skills") | |
| 227 | + | if err := os.MkdirAll(extDir, 0o755); err != nil { | |
| 228 | + | t.Fatal(err) | |
| 229 | + | } | |
| 230 | + | if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(`{"name":"bad-skills","skills":["../outside"]}`), 0o644); err != nil { | |
| 231 | + | t.Fatal(err) | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | r, err := Resolve(Args{CWD: project, WithSkills: true}, false) | |
| 235 | + | if err != nil { | |
| 236 | + | t.Fatal(err) | |
| 237 | + | } | |
| 238 | + | if len(r.SkillDiagnostics) != 1 || !strings.Contains(r.SkillDiagnostics[0], "escapes extension directory") { | |
| 239 | + | t.Fatalf("SkillDiagnostics = %v", r.SkillDiagnostics) | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | ||
| 222 | 243 | func TestNewAgentInjectsOpenRouterServerToolsWhenSettingOn(t *testing.T) { | |
| 223 | 244 | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 224 | 245 | t.Setenv("OPENROUTER_API_KEY", "test-key") | |
| @@ -219,6 +219,27 @@ func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) { | |||||
| 219 | } | 219 | } | ||
| 220 | } | 220 | } | ||
| 221 | 221 | ||||
| 222 | + | func TestResolveReportsExtensionSkillDiagnostics(t *testing.T) { | |||
| 223 | + | home := t.TempDir() | |||
| 224 | + | project := t.TempDir() | |||
| 225 | + | t.Setenv("ZOT_HOME", home) | |||
| 226 | + | extDir := filepath.Join(project, ".zot", "extensions", "bad-skills") | |||
| 227 | + | if err := os.MkdirAll(extDir, 0o755); err != nil { | |||
| 228 | + | t.Fatal(err) | |||
| 229 | + | } | |||
| 230 | + | if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(`{"name":"bad-skills","skills":["../outside"]}`), 0o644); err != nil { | |||
| 231 | + | t.Fatal(err) | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | r, err := Resolve(Args{CWD: project, WithSkills: true}, false) | |||
| 235 | + | if err != nil { | |||
| 236 | + | t.Fatal(err) | |||
| 237 | + | } | |||
| 238 | + | if len(r.SkillDiagnostics) != 1 || !strings.Contains(r.SkillDiagnostics[0], "escapes extension directory") { | |||
| 239 | + | t.Fatalf("SkillDiagnostics = %v", r.SkillDiagnostics) | |||
| 240 | + | } | |||
| 241 | + | } | |||
| 242 | + | ||||
| 222 | func TestNewAgentInjectsOpenRouterServerToolsWhenSettingOn(t *testing.T) { | 243 | func TestNewAgentInjectsOpenRouterServerToolsWhenSettingOn(t *testing.T) { | ||
| 223 | t.Setenv("ZOT_HOME", t.TempDir()) | 244 | t.Setenv("ZOT_HOME", t.TempDir()) | ||
| 224 | t.Setenv("OPENROUTER_API_KEY", "test-key") | 245 | t.Setenv("OPENROUTER_API_KEY", "test-key") | ||
M packages/agent/cli.go +11 -1
| @@ -296,6 +296,7 @@ func (nonInteractiveExtHooks) ClosePanel(string, string) | |||
| 296 | 296 | // wire tools into the resolved registry, and a cleanup closure to | |
| 297 | 297 | // defer. Mirrors the interactive-mode setup minus the TUI hooks. | |
| 298 | 298 | func setupNonInteractiveExtensions(ctx context.Context, args Args, r *Resolved, version string) (*extensions.Manager, func()) { | |
| 299 | + | reportSkillDiagnostics(os.Stderr, r.SkillDiagnostics) | |
| 299 | 300 | extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, nonInteractiveExtHooks{}) | |
| 300 | 301 | for _, e := range extMgr.LoadExplicit(ctx, args.Exts) { | |
| 301 | 302 | fmt.Fprintln(os.Stderr, "extension load:", e) | |
| @@ -311,6 +312,12 @@ func setupNonInteractiveExtensions(ctx context.Context, args Args, r *Resolved, | |||
| 311 | 312 | return extMgr, func() { extMgr.Stop(2 * time.Second) } | |
| 312 | 313 | } | |
| 313 | 314 | ||
| 315 | + | func reportSkillDiagnostics(w io.Writer, diagnostics []string) { | |
| 316 | + | for _, diagnostic := range diagnostics { | |
| 317 | + | fmt.Fprintln(w, "skill discovery:", diagnostic) | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | ||
| 314 | 321 | // wireNonInteractiveAgentExtHooks installs the same BeforeToolExecute | |
| 315 | 322 | // / BeforeTurn / BeforeAssistantMessage / OnEvent hooks the | |
| 316 | 323 | // interactive path wires up, so extensions get their normal | |
| @@ -658,7 +665,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 658 | 665 | var iv *modes.Interactive | |
| 659 | 666 | extHooks := &interactiveExtHooks{ivPtr: &iv} | |
| 660 | 667 | extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks) | |
| 661 | - | var startupExtensionErrors []string | |
| 668 | + | startupExtensionErrors := make([]string, 0, len(r.SkillDiagnostics)) | |
| 669 | + | for _, diagnostic := range r.SkillDiagnostics { | |
| 670 | + | startupExtensionErrors = append(startupExtensionErrors, "skill discovery: "+diagnostic) | |
| 671 | + | } | |
| 662 | 672 | // --ext paths first so they win against installed extensions of | |
| 663 | 673 | // the same name (loadOne's first-write-wins semantics). | |
| 664 | 674 | for _, e := range extMgr.LoadExplicit(ctx, args.Exts) { | |
| @@ -296,6 +296,7 @@ func (nonInteractiveExtHooks) ClosePanel(string, string) | |||||
| 296 | // wire tools into the resolved registry, and a cleanup closure to | 296 | // wire tools into the resolved registry, and a cleanup closure to | ||
| 297 | // defer. Mirrors the interactive-mode setup minus the TUI hooks. | 297 | // defer. Mirrors the interactive-mode setup minus the TUI hooks. | ||
| 298 | func setupNonInteractiveExtensions(ctx context.Context, args Args, r *Resolved, version string) (*extensions.Manager, func()) { | 298 | func setupNonInteractiveExtensions(ctx context.Context, args Args, r *Resolved, version string) (*extensions.Manager, func()) { | ||
| 299 | + | reportSkillDiagnostics(os.Stderr, r.SkillDiagnostics) | |||
| 299 | extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, nonInteractiveExtHooks{}) | 300 | extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, nonInteractiveExtHooks{}) | ||
| 300 | for _, e := range extMgr.LoadExplicit(ctx, args.Exts) { | 301 | for _, e := range extMgr.LoadExplicit(ctx, args.Exts) { | ||
| 301 | fmt.Fprintln(os.Stderr, "extension load:", e) | 302 | fmt.Fprintln(os.Stderr, "extension load:", e) | ||
| @@ -311,6 +312,12 @@ func setupNonInteractiveExtensions(ctx context.Context, args Args, r *Resolved, | |||||
| 311 | return extMgr, func() { extMgr.Stop(2 * time.Second) } | 312 | return extMgr, func() { extMgr.Stop(2 * time.Second) } | ||
| 312 | } | 313 | } | ||
| 313 | 314 | ||||
| 315 | + | func reportSkillDiagnostics(w io.Writer, diagnostics []string) { | |||
| 316 | + | for _, diagnostic := range diagnostics { | |||
| 317 | + | fmt.Fprintln(w, "skill discovery:", diagnostic) | |||
| 318 | + | } | |||
| 319 | + | } | |||
| 320 | + | ||||
| 314 | // wireNonInteractiveAgentExtHooks installs the same BeforeToolExecute | 321 | // wireNonInteractiveAgentExtHooks installs the same BeforeToolExecute | ||
| 315 | // / BeforeTurn / BeforeAssistantMessage / OnEvent hooks the | 322 | // / BeforeTurn / BeforeAssistantMessage / OnEvent hooks the | ||
| 316 | // interactive path wires up, so extensions get their normal | 323 | // interactive path wires up, so extensions get their normal | ||
| @@ -658,7 +665,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 658 | var iv *modes.Interactive | 665 | var iv *modes.Interactive | ||
| 659 | extHooks := &interactiveExtHooks{ivPtr: &iv} | 666 | extHooks := &interactiveExtHooks{ivPtr: &iv} | ||
| 660 | extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks) | 667 | extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks) | ||
| 661 | - | var startupExtensionErrors []string | 668 | + | startupExtensionErrors := make([]string, 0, len(r.SkillDiagnostics)) |
| 669 | + | for _, diagnostic := range r.SkillDiagnostics { | |||
| 670 | + | startupExtensionErrors = append(startupExtensionErrors, "skill discovery: "+diagnostic) | |||
| 671 | + | } | |||
| 662 | // --ext paths first so they win against installed extensions of | 672 | // --ext paths first so they win against installed extensions of | ||
| 663 | // the same name (loadOne's first-write-wins semantics). | 673 | // the same name (loadOne's first-write-wins semantics). | ||
| 664 | for _, e := range extMgr.LoadExplicit(ctx, args.Exts) { | 674 | for _, e := range extMgr.LoadExplicit(ctx, args.Exts) { | ||
M packages/agent/extcmd.go +16 -9
| @@ -150,6 +150,7 @@ type extDoctorStaticRow struct { | |||
| 150 | 150 | Dir string | |
| 151 | 151 | Exec string | |
| 152 | 152 | Theme bool | |
| 153 | + | Skills bool | |
| 153 | 154 | Manifest string | |
| 154 | 155 | Error string | |
| 155 | 156 | Shadowed bool | |
| @@ -239,10 +240,11 @@ func scanExtDoctorStatic() []extDoctorStaticRow { | |||
| 239 | 240 | continue | |
| 240 | 241 | } | |
| 241 | 242 | var m struct { | |
| 242 | - | Name string `json:"name"` | |
| 243 | - | Version string `json:"version"` | |
| 244 | - | Exec string `json:"exec"` | |
| 245 | - | Enabled *bool `json:"enabled"` | |
| 243 | + | Name string `json:"name"` | |
| 244 | + | Version string `json:"version"` | |
| 245 | + | Exec string `json:"exec"` | |
| 246 | + | Skills []string `json:"skills"` | |
| 247 | + | Enabled *bool `json:"enabled"` | |
| 246 | 248 | } | |
| 247 | 249 | if err := json.Unmarshal(raw, &m); err != nil { | |
| 248 | 250 | row.Error = "parse manifest: " + err.Error() | |
| @@ -256,11 +258,12 @@ func scanExtDoctorStatic() []extDoctorStaticRow { | |||
| 256 | 258 | } | |
| 257 | 259 | row.Version = m.Version | |
| 258 | 260 | row.Exec = m.Exec | |
| 261 | + | row.Skills = len(m.Skills) > 0 | |
| 259 | 262 | if m.Enabled != nil { | |
| 260 | 263 | row.Enabled = *m.Enabled | |
| 261 | 264 | } | |
| 262 | - | if row.Exec == "" && !row.Theme && row.Error == "" { | |
| 263 | - | row.Error = "manifest: exec is required" | |
| 265 | + | if row.Exec == "" && !row.Theme && !row.Skills && row.Error == "" { | |
| 266 | + | row.Error = "manifest: exec, theme, or skills is required" | |
| 264 | 267 | } | |
| 265 | 268 | rows = append(rows, row) | |
| 266 | 269 | } | |
| @@ -277,8 +280,12 @@ func printExtDoctorRow(w io.Writer, row extDoctorStaticRow, diag extensions.Exte | |||
| 277 | 280 | status = "shadowed" | |
| 278 | 281 | case !row.Enabled: | |
| 279 | 282 | status = "disabled" | |
| 280 | - | case row.Exec == "" && row.Theme: | |
| 283 | + | case row.Exec == "" && row.Theme && !row.Skills: | |
| 281 | 284 | status = "theme-only" | |
| 285 | + | case row.Exec == "" && row.Skills && !row.Theme: | |
| 286 | + | status = "skill-only" | |
| 287 | + | case row.Exec == "" && row.Theme && row.Skills: | |
| 288 | + | status = "static-only" | |
| 282 | 289 | case diag.Name == "": | |
| 283 | 290 | status = "not loaded" | |
| 284 | 291 | case diag.ReadyTimedOut: | |
| @@ -309,8 +316,8 @@ func printExtDoctorRow(w io.Writer, row extDoctorStaticRow, diag extensions.Exte | |||
| 309 | 316 | fmt.Fprintln(w, " note: disabled in extension.json") | |
| 310 | 317 | return | |
| 311 | 318 | } | |
| 312 | - | if row.Exec == "" && row.Theme { | |
| 313 | - | fmt.Fprintln(w, " type: theme-only") | |
| 319 | + | if row.Exec == "" && (row.Theme || row.Skills) { | |
| 320 | + | fmt.Fprintf(w, " type: %s\n", status) | |
| 314 | 321 | return | |
| 315 | 322 | } | |
| 316 | 323 | if row.Exec != "" { | |
| @@ -150,6 +150,7 @@ type extDoctorStaticRow struct { | |||||
| 150 | Dir string | 150 | Dir string | ||
| 151 | Exec string | 151 | Exec string | ||
| 152 | Theme bool | 152 | Theme bool | ||
| 153 | + | Skills bool | |||
| 153 | Manifest string | 154 | Manifest string | ||
| 154 | Error string | 155 | Error string | ||
| 155 | Shadowed bool | 156 | Shadowed bool | ||
| @@ -239,10 +240,11 @@ func scanExtDoctorStatic() []extDoctorStaticRow { | |||||
| 239 | continue | 240 | continue | ||
| 240 | } | 241 | } | ||
| 241 | var m struct { | 242 | var m struct { | ||
| 242 | - | Name string `json:"name"` | 243 | + | Name string `json:"name"` |
| 243 | - | Version string `json:"version"` | 244 | + | Version string `json:"version"` |
| 244 | - | Exec string `json:"exec"` | 245 | + | Exec string `json:"exec"` |
| 245 | - | Enabled *bool `json:"enabled"` | 246 | + | Skills []string `json:"skills"` |
| 247 | + | Enabled *bool `json:"enabled"` | |||
| 246 | } | 248 | } | ||
| 247 | if err := json.Unmarshal(raw, &m); err != nil { | 249 | if err := json.Unmarshal(raw, &m); err != nil { | ||
| 248 | row.Error = "parse manifest: " + err.Error() | 250 | row.Error = "parse manifest: " + err.Error() | ||
| @@ -256,11 +258,12 @@ func scanExtDoctorStatic() []extDoctorStaticRow { | |||||
| 256 | } | 258 | } | ||
| 257 | row.Version = m.Version | 259 | row.Version = m.Version | ||
| 258 | row.Exec = m.Exec | 260 | row.Exec = m.Exec | ||
| 261 | + | row.Skills = len(m.Skills) > 0 | |||
| 259 | if m.Enabled != nil { | 262 | if m.Enabled != nil { | ||
| 260 | row.Enabled = *m.Enabled | 263 | row.Enabled = *m.Enabled | ||
| 261 | } | 264 | } | ||
| 262 | - | if row.Exec == "" && !row.Theme && row.Error == "" { | 265 | + | if row.Exec == "" && !row.Theme && !row.Skills && row.Error == "" { |
| 263 | - | row.Error = "manifest: exec is required" | 266 | + | row.Error = "manifest: exec, theme, or skills is required" |
| 264 | } | 267 | } | ||
| 265 | rows = append(rows, row) | 268 | rows = append(rows, row) | ||
| 266 | } | 269 | } | ||
| @@ -277,8 +280,12 @@ func printExtDoctorRow(w io.Writer, row extDoctorStaticRow, diag extensions.Exte | |||||
| 277 | status = "shadowed" | 280 | status = "shadowed" | ||
| 278 | case !row.Enabled: | 281 | case !row.Enabled: | ||
| 279 | status = "disabled" | 282 | status = "disabled" | ||
| 280 | - | case row.Exec == "" && row.Theme: | 283 | + | case row.Exec == "" && row.Theme && !row.Skills: |
| 281 | status = "theme-only" | 284 | status = "theme-only" | ||
| 285 | + | case row.Exec == "" && row.Skills && !row.Theme: | |||
| 286 | + | status = "skill-only" | |||
| 287 | + | case row.Exec == "" && row.Theme && row.Skills: | |||
| 288 | + | status = "static-only" | |||
| 282 | case diag.Name == "": | 289 | case diag.Name == "": | ||
| 283 | status = "not loaded" | 290 | status = "not loaded" | ||
| 284 | case diag.ReadyTimedOut: | 291 | case diag.ReadyTimedOut: | ||
| @@ -309,8 +316,8 @@ func printExtDoctorRow(w io.Writer, row extDoctorStaticRow, diag extensions.Exte | |||||
| 309 | fmt.Fprintln(w, " note: disabled in extension.json") | 316 | fmt.Fprintln(w, " note: disabled in extension.json") | ||
| 310 | return | 317 | return | ||
| 311 | } | 318 | } | ||
| 312 | - | if row.Exec == "" && row.Theme { | 319 | + | if row.Exec == "" && (row.Theme || row.Skills) { |
| 313 | - | fmt.Fprintln(w, " type: theme-only") | 320 | + | fmt.Fprintf(w, " type: %s\n", status) |
| 314 | return | 321 | return | ||
| 315 | } | 322 | } | ||
| 316 | if row.Exec != "" { | 323 | if row.Exec != "" { | ||
M packages/agent/extcmd_test.go +4 -2
| @@ -155,13 +155,14 @@ func TestExtDoctorStaticScanAndRender(t *testing.T) { | |||
| 155 | 155 | mustWrite(filepath.Join(cwd, ".zot", "extensions", "disabled", "extension.json"), `{"name":"disabled","exec":"./run.sh","enabled":false}`) | |
| 156 | 156 | mustWrite(filepath.Join(cwd, ".zot", "extensions", "theme", "extension.json"), `{"name":"theme"}`) | |
| 157 | 157 | mustWrite(filepath.Join(cwd, ".zot", "extensions", "theme", "theme.json"), `{"name":"Theme"}`) | |
| 158 | + | mustWrite(filepath.Join(cwd, ".zot", "extensions", "skills", "extension.json"), `{"name":"skills","skills":["./skills"]}`) | |
| 158 | 159 | mustWrite(filepath.Join(cwd, ".zot", "extensions", "bad", "extension.json"), `{bad json`) | |
| 159 | 160 | mustWrite(filepath.Join(cwd, ".zot", "extensions", "dup", "extension.json"), `{"name":"dup","exec":"./project.sh"}`) | |
| 160 | 161 | mustWrite(filepath.Join(home, "extensions", "dup", "extension.json"), `{"name":"dup","exec":"./global.sh"}`) | |
| 161 | 162 | ||
| 162 | 163 | rows := scanExtDoctorStatic() | |
| 163 | - | if len(rows) != 5 { | |
| 164 | - | t.Fatalf("rows = %d, want 5: %#v", len(rows), rows) | |
| 164 | + | if len(rows) != 6 { | |
| 165 | + | t.Fatalf("rows = %d, want 6: %#v", len(rows), rows) | |
| 165 | 166 | } | |
| 166 | 167 | ||
| 167 | 168 | var out bytes.Buffer | |
| @@ -172,6 +173,7 @@ func TestExtDoctorStaticScanAndRender(t *testing.T) { | |||
| 172 | 173 | for _, want := range []string{ | |
| 173 | 174 | "disabled [project] disabled", | |
| 174 | 175 | "theme [project] theme-only", | |
| 176 | + | "skills [project] skill-only", | |
| 175 | 177 | "bad [project] error", | |
| 176 | 178 | "dup [global] shadowed", | |
| 177 | 179 | "parse manifest:", | |
| @@ -155,13 +155,14 @@ func TestExtDoctorStaticScanAndRender(t *testing.T) { | |||||
| 155 | mustWrite(filepath.Join(cwd, ".zot", "extensions", "disabled", "extension.json"), `{"name":"disabled","exec":"./run.sh","enabled":false}`) | 155 | mustWrite(filepath.Join(cwd, ".zot", "extensions", "disabled", "extension.json"), `{"name":"disabled","exec":"./run.sh","enabled":false}`) | ||
| 156 | mustWrite(filepath.Join(cwd, ".zot", "extensions", "theme", "extension.json"), `{"name":"theme"}`) | 156 | mustWrite(filepath.Join(cwd, ".zot", "extensions", "theme", "extension.json"), `{"name":"theme"}`) | ||
| 157 | mustWrite(filepath.Join(cwd, ".zot", "extensions", "theme", "theme.json"), `{"name":"Theme"}`) | 157 | mustWrite(filepath.Join(cwd, ".zot", "extensions", "theme", "theme.json"), `{"name":"Theme"}`) | ||
| 158 | + | mustWrite(filepath.Join(cwd, ".zot", "extensions", "skills", "extension.json"), `{"name":"skills","skills":["./skills"]}`) | |||
| 158 | mustWrite(filepath.Join(cwd, ".zot", "extensions", "bad", "extension.json"), `{bad json`) | 159 | mustWrite(filepath.Join(cwd, ".zot", "extensions", "bad", "extension.json"), `{bad json`) | ||
| 159 | mustWrite(filepath.Join(cwd, ".zot", "extensions", "dup", "extension.json"), `{"name":"dup","exec":"./project.sh"}`) | 160 | mustWrite(filepath.Join(cwd, ".zot", "extensions", "dup", "extension.json"), `{"name":"dup","exec":"./project.sh"}`) | ||
| 160 | mustWrite(filepath.Join(home, "extensions", "dup", "extension.json"), `{"name":"dup","exec":"./global.sh"}`) | 161 | mustWrite(filepath.Join(home, "extensions", "dup", "extension.json"), `{"name":"dup","exec":"./global.sh"}`) | ||
| 161 | 162 | ||||
| 162 | rows := scanExtDoctorStatic() | 163 | rows := scanExtDoctorStatic() | ||
| 163 | - | if len(rows) != 5 { | 164 | + | if len(rows) != 6 { |
| 164 | - | t.Fatalf("rows = %d, want 5: %#v", len(rows), rows) | 165 | + | t.Fatalf("rows = %d, want 6: %#v", len(rows), rows) |
| 165 | } | 166 | } | ||
| 166 | 167 | ||||
| 167 | var out bytes.Buffer | 168 | var out bytes.Buffer | ||
| @@ -172,6 +173,7 @@ func TestExtDoctorStaticScanAndRender(t *testing.T) { | |||||
| 172 | for _, want := range []string{ | 173 | for _, want := range []string{ | ||
| 173 | "disabled [project] disabled", | 174 | "disabled [project] disabled", | ||
| 174 | "theme [project] theme-only", | 175 | "theme [project] theme-only", | ||
| 176 | + | "skills [project] skill-only", | |||
| 175 | "bad [project] error", | 177 | "bad [project] error", | ||
| 176 | "dup [global] shadowed", | 178 | "dup [global] shadowed", | ||
| 177 | "parse manifest:", | 179 | "parse manifest:", | ||
M packages/agent/extensions/manager.go +23 -16
| @@ -277,42 +277,49 @@ func extensionDirs(root string) ([]string, error) { | |||
| 277 | 277 | // returns their filesystem-backed skill roots in precedence order. Explicit | |
| 278 | 278 | // extensions win over project and global installations; project wins global. | |
| 279 | 279 | func PlanSkillSources(zotHome, cwd string, explicit []string, includeImplicit bool) ([]skills.Source, []error) { | |
| 280 | - | var dirs []string | |
| 280 | + | type root struct { | |
| 281 | + | path string | |
| 282 | + | explicit bool | |
| 283 | + | } | |
| 284 | + | var roots []root | |
| 285 | + | var errs []error | |
| 281 | 286 | for _, p := range explicit { | |
| 282 | 287 | abs, err := filepath.Abs(p) | |
| 283 | 288 | if err != nil { | |
| 289 | + | errs = append(errs, fmt.Errorf("%s: %w", p, err)) | |
| 284 | 290 | continue | |
| 285 | 291 | } | |
| 286 | - | dirs = append(dirs, abs) | |
| 292 | + | roots = append(roots, root{path: abs, explicit: true}) | |
| 287 | 293 | } | |
| 288 | 294 | if includeImplicit { | |
| 289 | 295 | if cwd != "" { | |
| 290 | - | dirs = append(dirs, filepath.Join(cwd, ".zot", "extensions")) | |
| 296 | + | roots = append(roots, root{path: filepath.Join(cwd, ".zot", "extensions")}) | |
| 291 | 297 | } | |
| 292 | 298 | if zotHome != "" { | |
| 293 | - | dirs = append(dirs, filepath.Join(zotHome, "extensions")) | |
| 299 | + | roots = append(roots, root{path: filepath.Join(zotHome, "extensions")}) | |
| 294 | 300 | } | |
| 295 | 301 | } | |
| 296 | 302 | seen := map[string]bool{} | |
| 297 | 303 | var out []skills.Source | |
| 298 | - | var errs []error | |
| 299 | - | for _, root := range dirs { | |
| 300 | - | candidates, err := extensionDirs(root) | |
| 301 | - | if err != nil { | |
| 302 | - | if os.IsNotExist(err) { | |
| 304 | + | for _, root := range roots { | |
| 305 | + | var candidates []string | |
| 306 | + | if root.explicit { | |
| 307 | + | candidates = []string{root.path} | |
| 308 | + | } else { | |
| 309 | + | var err error | |
| 310 | + | candidates, err = extensionDirs(root.path) | |
| 311 | + | if err != nil { | |
| 312 | + | if os.IsNotExist(err) { | |
| 313 | + | continue | |
| 314 | + | } | |
| 315 | + | errs = append(errs, fmt.Errorf("%s: %w", root.path, err)) | |
| 303 | 316 | continue | |
| 304 | 317 | } | |
| 305 | - | errs = append(errs, fmt.Errorf("%s: %w", root, err)) | |
| 306 | - | continue | |
| 307 | - | } | |
| 308 | - | // An explicit --ext points at the extension itself, not its parent. | |
| 309 | - | if filepath.Base(root) != "extensions" && filepath.Base(root) != ".zot" { | |
| 310 | - | candidates = []string{root} | |
| 311 | 318 | } | |
| 312 | 319 | for _, dir := range candidates { | |
| 313 | 320 | mf, err := readManifest(dir) | |
| 314 | 321 | if err != nil { | |
| 315 | - | if !os.IsNotExist(err) { | |
| 322 | + | if root.explicit || !os.IsNotExist(err) { | |
| 316 | 323 | errs = append(errs, fmt.Errorf("%s: %w", dir, err)) | |
| 317 | 324 | } | |
| 318 | 325 | continue | |
| @@ -277,42 +277,49 @@ func extensionDirs(root string) ([]string, error) { | |||||
| 277 | // returns their filesystem-backed skill roots in precedence order. Explicit | 277 | // returns their filesystem-backed skill roots in precedence order. Explicit | ||
| 278 | // extensions win over project and global installations; project wins global. | 278 | // extensions win over project and global installations; project wins global. | ||
| 279 | func PlanSkillSources(zotHome, cwd string, explicit []string, includeImplicit bool) ([]skills.Source, []error) { | 279 | func PlanSkillSources(zotHome, cwd string, explicit []string, includeImplicit bool) ([]skills.Source, []error) { | ||
| 280 | - | var dirs []string | 280 | + | type root struct { |
| 281 | + | path string | |||
| 282 | + | explicit bool | |||
| 283 | + | } | |||
| 284 | + | var roots []root | |||
| 285 | + | var errs []error | |||
| 281 | for _, p := range explicit { | 286 | for _, p := range explicit { | ||
| 282 | abs, err := filepath.Abs(p) | 287 | abs, err := filepath.Abs(p) | ||
| 283 | if err != nil { | 288 | if err != nil { | ||
| 289 | + | errs = append(errs, fmt.Errorf("%s: %w", p, err)) | |||
| 284 | continue | 290 | continue | ||
| 285 | } | 291 | } | ||
| 286 | - | dirs = append(dirs, abs) | 292 | + | roots = append(roots, root{path: abs, explicit: true}) |
| 287 | } | 293 | } | ||
| 288 | if includeImplicit { | 294 | if includeImplicit { | ||
| 289 | if cwd != "" { | 295 | if cwd != "" { | ||
| 290 | - | dirs = append(dirs, filepath.Join(cwd, ".zot", "extensions")) | 296 | + | roots = append(roots, root{path: filepath.Join(cwd, ".zot", "extensions")}) |
| 291 | } | 297 | } | ||
| 292 | if zotHome != "" { | 298 | if zotHome != "" { | ||
| 293 | - | dirs = append(dirs, filepath.Join(zotHome, "extensions")) | 299 | + | roots = append(roots, root{path: filepath.Join(zotHome, "extensions")}) |
| 294 | } | 300 | } | ||
| 295 | } | 301 | } | ||
| 296 | seen := map[string]bool{} | 302 | seen := map[string]bool{} | ||
| 297 | var out []skills.Source | 303 | var out []skills.Source | ||
| 298 | - | var errs []error | 304 | + | for _, root := range roots { |
| 299 | - | for _, root := range dirs { | 305 | + | var candidates []string |
| 300 | - | candidates, err := extensionDirs(root) | 306 | + | if root.explicit { |
| 301 | - | if err != nil { | 307 | + | candidates = []string{root.path} |
| 302 | - | if os.IsNotExist(err) { | 308 | + | } else { |
| 309 | + | var err error | |||
| 310 | + | candidates, err = extensionDirs(root.path) | |||
| 311 | + | if err != nil { | |||
| 312 | + | if os.IsNotExist(err) { | |||
| 313 | + | continue | |||
| 314 | + | } | |||
| 315 | + | errs = append(errs, fmt.Errorf("%s: %w", root.path, err)) | |||
| 303 | continue | 316 | continue | ||
| 304 | } | 317 | } | ||
| 305 | - | errs = append(errs, fmt.Errorf("%s: %w", root, err)) | |||
| 306 | - | continue | |||
| 307 | - | } | |||
| 308 | - | // An explicit --ext points at the extension itself, not its parent. | |||
| 309 | - | if filepath.Base(root) != "extensions" && filepath.Base(root) != ".zot" { | |||
| 310 | - | candidates = []string{root} | |||
| 311 | } | 318 | } | ||
| 312 | for _, dir := range candidates { | 319 | for _, dir := range candidates { | ||
| 313 | mf, err := readManifest(dir) | 320 | mf, err := readManifest(dir) | ||
| 314 | if err != nil { | 321 | if err != nil { | ||
| 315 | - | if !os.IsNotExist(err) { | 322 | + | if root.explicit || !os.IsNotExist(err) { |
| 316 | errs = append(errs, fmt.Errorf("%s: %w", dir, err)) | 323 | errs = append(errs, fmt.Errorf("%s: %w", dir, err)) | ||
| 317 | } | 324 | } | ||
| 318 | continue | 325 | continue | ||
M packages/agent/extensions/manager_test.go +20 -0
| @@ -277,6 +277,26 @@ func TestPlanSkillSources(t *testing.T) { | |||
| 277 | 277 | } | |
| 278 | 278 | } | |
| 279 | 279 | ||
| 280 | + | func TestPlanSkillSourcesExplicitBundleNamedExtensions(t *testing.T) { | |
| 281 | + | tmp := t.TempDir() | |
| 282 | + | extDir := filepath.Join(tmp, "extensions") | |
| 283 | + | skillDir := filepath.Join(extDir, "skills", "review") | |
| 284 | + | if err := os.MkdirAll(skillDir, 0o755); err != nil { | |
| 285 | + | t.Fatal(err) | |
| 286 | + | } | |
| 287 | + | if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(`{"name":"named-extensions","skills":["./skills"]}`), 0o644); err != nil { | |
| 288 | + | t.Fatal(err) | |
| 289 | + | } | |
| 290 | + | if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\ndescription: review\n---\nbody"), 0o644); err != nil { | |
| 291 | + | t.Fatal(err) | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | sources, errs := PlanSkillSources("", "", []string{extDir}, false) | |
| 295 | + | if len(errs) != 0 || len(sources) != 1 { | |
| 296 | + | t.Fatalf("sources=%#v errs=%v", sources, errs) | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | ||
| 280 | 300 | func TestPlanSkillSourcesRejectsTraversal(t *testing.T) { | |
| 281 | 301 | tmp := t.TempDir() | |
| 282 | 302 | extDir := filepath.Join(tmp, "bundle") | |
| @@ -277,6 +277,26 @@ func TestPlanSkillSources(t *testing.T) { | |||||
| 277 | } | 277 | } | ||
| 278 | } | 278 | } | ||
| 279 | 279 | ||||
| 280 | + | func TestPlanSkillSourcesExplicitBundleNamedExtensions(t *testing.T) { | |||
| 281 | + | tmp := t.TempDir() | |||
| 282 | + | extDir := filepath.Join(tmp, "extensions") | |||
| 283 | + | skillDir := filepath.Join(extDir, "skills", "review") | |||
| 284 | + | if err := os.MkdirAll(skillDir, 0o755); err != nil { | |||
| 285 | + | t.Fatal(err) | |||
| 286 | + | } | |||
| 287 | + | if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(`{"name":"named-extensions","skills":["./skills"]}`), 0o644); err != nil { | |||
| 288 | + | t.Fatal(err) | |||
| 289 | + | } | |||
| 290 | + | if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\ndescription: review\n---\nbody"), 0o644); err != nil { | |||
| 291 | + | t.Fatal(err) | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | sources, errs := PlanSkillSources("", "", []string{extDir}, false) | |||
| 295 | + | if len(errs) != 0 || len(sources) != 1 { | |||
| 296 | + | t.Fatalf("sources=%#v errs=%v", sources, errs) | |||
| 297 | + | } | |||
| 298 | + | } | |||
| 299 | + | ||||
| 280 | func TestPlanSkillSourcesRejectsTraversal(t *testing.T) { | 300 | func TestPlanSkillSourcesRejectsTraversal(t *testing.T) { | ||
| 281 | tmp := t.TempDir() | 301 | tmp := t.TempDir() | ||
| 282 | extDir := filepath.Join(tmp, "bundle") | 302 | extDir := filepath.Join(tmp, "bundle") | ||
M packages/agent/modes/interactive.go +5 -4
| @@ -45,8 +45,9 @@ type InteractiveConfig struct { | |||
| 45 | 45 | StartupExtensionNames []string | |
| 46 | 46 | StartupSkillNames []string | |
| 47 | 47 | ||
| 48 | - | // StartupExtensionErrors are non-fatal extension load failures collected | |
| 49 | - | // before the TUI starts. They are display-only and never enter the transcript. | |
| 48 | + | // StartupExtensionErrors contains non-fatal extension and skill discovery | |
| 49 | + | // failures collected before the TUI starts. It is display-only and never | |
| 50 | + | // enters the transcript. The historical field name is retained for callers. | |
| 50 | 51 | StartupExtensionErrors []string | |
| 51 | 52 | ||
| 52 | 53 | // ShowInstructionsAtStartup controls whether startup resources are visible. | |
| @@ -543,8 +544,8 @@ type Interactive struct { | |||
| 543 | 544 | // transcript) until cleared by /clear or another reset. | |
| 544 | 545 | extNotes []string | |
| 545 | 546 | ||
| 546 | - | // reloadErrors are host-only extension reload failures. They stay in | |
| 547 | - | // the scrolling chat until /clear without entering the agent transcript. | |
| 547 | + | // reloadErrors are host-only resource discovery and reload failures. They | |
| 548 | + | // stay in the scrolling chat until /clear without entering the transcript. | |
| 548 | 549 | reloadErrors []string | |
| 549 | 550 | ||
| 550 | 551 | // shellRunning is true while a !command is executing. It shares | |
| @@ -45,8 +45,9 @@ type InteractiveConfig struct { | |||||
| 45 | StartupExtensionNames []string | 45 | StartupExtensionNames []string | ||
| 46 | StartupSkillNames []string | 46 | StartupSkillNames []string | ||
| 47 | 47 | ||||
| 48 | - | // StartupExtensionErrors are non-fatal extension load failures collected | 48 | + | // StartupExtensionErrors contains non-fatal extension and skill discovery |
| 49 | - | // before the TUI starts. They are display-only and never enter the transcript. | 49 | + | // failures collected before the TUI starts. It is display-only and never |
| 50 | + | // enters the transcript. The historical field name is retained for callers. | |||
| 50 | StartupExtensionErrors []string | 51 | StartupExtensionErrors []string | ||
| 51 | 52 | ||||
| 52 | // ShowInstructionsAtStartup controls whether startup resources are visible. | 53 | // ShowInstructionsAtStartup controls whether startup resources are visible. | ||
| @@ -543,8 +544,8 @@ type Interactive struct { | |||||
| 543 | // transcript) until cleared by /clear or another reset. | 544 | // transcript) until cleared by /clear or another reset. | ||
| 544 | extNotes []string | 545 | extNotes []string | ||
| 545 | 546 | ||||
| 546 | - | // reloadErrors are host-only extension reload failures. They stay in | 547 | + | // reloadErrors are host-only resource discovery and reload failures. They |
| 547 | - | // the scrolling chat until /clear without entering the agent transcript. | 548 | + | // stay in the scrolling chat until /clear without entering the transcript. |
| 548 | reloadErrors []string | 549 | reloadErrors []string | ||
| 549 | 550 | ||||
| 550 | // shellRunning is true while a !command is executing. It shares | 551 | // shellRunning is true while a !command is executing. It shares | ||
M packages/agent/rpc.go +1 -0
| @@ -48,6 +48,7 @@ func runRPCMode(ctx context.Context, args Args, version string) error { | |||
| 48 | 48 | if err != nil { | |
| 49 | 49 | return err | |
| 50 | 50 | } | |
| 51 | + | reportSkillDiagnostics(os.Stderr, r.SkillDiagnostics) | |
| 51 | 52 | ||
| 52 | 53 | // Extensions: same lifecycle as interactive mode, minus the | |
| 53 | 54 | // host-hooks integration. Notify/Display calls from extensions | |
| @@ -48,6 +48,7 @@ func runRPCMode(ctx context.Context, args Args, version string) error { | |||||
| 48 | if err != nil { | 48 | if err != nil { | ||
| 49 | return err | 49 | return err | ||
| 50 | } | 50 | } | ||
| 51 | + | reportSkillDiagnostics(os.Stderr, r.SkillDiagnostics) | |||
| 51 | 52 | ||||
| 52 | // Extensions: same lifecycle as interactive mode, minus the | 53 | // Extensions: same lifecycle as interactive mode, minus the | ||
| 53 | // host-hooks integration. Notify/Display calls from extensions | 54 | // host-hooks integration. Notify/Display calls from extensions | ||
M packages/agent/skills/skills.go +5 -3
| @@ -116,7 +116,7 @@ func Discover(zotHome, cwd, userHome string, includeUser bool) ([]*Skill, []erro | |||
| 116 | 116 | sources = append(sources, Source{Root: loc.dir, Label: loc.label}) | |
| 117 | 117 | } | |
| 118 | 118 | } | |
| 119 | - | discovered, errs := DiscoverSources(sources, includeUser) | |
| 119 | + | discovered, errs := DiscoverSources(sources, true) | |
| 120 | 120 | // Preserve the historical Discover contract: duplicate ordinary skills | |
| 121 | 121 | // were silently ignored. New callers using DiscoverSources receive the | |
| 122 | 122 | // richer shadowing diagnostics. | |
| @@ -148,9 +148,11 @@ func DiscoverSources(sources []Source, includeBuiltins bool) ([]*Skill, []error) | |||
| 148 | 148 | } | |
| 149 | 149 | if includeBuiltins { | |
| 150 | 150 | for _, s := range loadBuiltins() { | |
| 151 | - | if _, dup := seen[s.Name]; !dup { | |
| 152 | - | seen[s.Name] = s | |
| 151 | + | if prior, dup := seen[s.Name]; dup { | |
| 152 | + | errs = append(errs, fmt.Errorf("skill %q shadowed: selected %s; ignored %s", s.Name, prior.Path, s.Path)) | |
| 153 | + | continue | |
| 153 | 154 | } | |
| 155 | + | seen[s.Name] = s | |
| 154 | 156 | } | |
| 155 | 157 | } | |
| 156 | 158 | out := make([]*Skill, 0, len(seen)) | |
| @@ -116,7 +116,7 @@ func Discover(zotHome, cwd, userHome string, includeUser bool) ([]*Skill, []erro | |||||
| 116 | sources = append(sources, Source{Root: loc.dir, Label: loc.label}) | 116 | sources = append(sources, Source{Root: loc.dir, Label: loc.label}) | ||
| 117 | } | 117 | } | ||
| 118 | } | 118 | } | ||
| 119 | - | discovered, errs := DiscoverSources(sources, includeUser) | 119 | + | discovered, errs := DiscoverSources(sources, true) |
| 120 | // Preserve the historical Discover contract: duplicate ordinary skills | 120 | // Preserve the historical Discover contract: duplicate ordinary skills | ||
| 121 | // were silently ignored. New callers using DiscoverSources receive the | 121 | // were silently ignored. New callers using DiscoverSources receive the | ||
| 122 | // richer shadowing diagnostics. | 122 | // richer shadowing diagnostics. | ||
| @@ -148,9 +148,11 @@ func DiscoverSources(sources []Source, includeBuiltins bool) ([]*Skill, []error) | |||||
| 148 | } | 148 | } | ||
| 149 | if includeBuiltins { | 149 | if includeBuiltins { | ||
| 150 | for _, s := range loadBuiltins() { | 150 | for _, s := range loadBuiltins() { | ||
| 151 | - | if _, dup := seen[s.Name]; !dup { | 151 | + | if prior, dup := seen[s.Name]; dup { |
| 152 | - | seen[s.Name] = s | 152 | + | errs = append(errs, fmt.Errorf("skill %q shadowed: selected %s; ignored %s", s.Name, prior.Path, s.Path)) |
| 153 | + | continue | |||
| 153 | } | 154 | } | ||
| 155 | + | seen[s.Name] = s | |||
| 154 | } | 156 | } | ||
| 155 | } | 157 | } | ||
| 156 | out := make([]*Skill, 0, len(seen)) | 158 | out := make([]*Skill, 0, len(seen)) | ||
M packages/agent/skills/skills_test.go +12 -0
| @@ -96,6 +96,18 @@ func TestDiscoverProjectAndGlobalPriorityAndDedup(t *testing.T) { | |||
| 96 | 96 | } | |
| 97 | 97 | } | |
| 98 | 98 | ||
| 99 | + | func TestDiscoverWithoutUserSkillsStillIncludesBuiltins(t *testing.T) { | |
| 100 | + | got, errs := Discover("", "", "", false) | |
| 101 | + | if len(errs) != 0 { | |
| 102 | + | t.Fatalf("errs: %v", errs) | |
| 103 | + | } | |
| 104 | + | for _, builtin := range loadBuiltins() { | |
| 105 | + | if FindByName(got, builtin.Name) == nil { | |
| 106 | + | t.Errorf("built-in skill %q missing", builtin.Name) | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 99 | 111 | func TestKebabCase(t *testing.T) { | |
| 100 | 112 | cases := map[string]string{ | |
| 101 | 113 | "writing-git-commits": "writing-git-commits", | |
| @@ -96,6 +96,18 @@ func TestDiscoverProjectAndGlobalPriorityAndDedup(t *testing.T) { | |||||
| 96 | } | 96 | } | ||
| 97 | } | 97 | } | ||
| 98 | 98 | ||||
| 99 | + | func TestDiscoverWithoutUserSkillsStillIncludesBuiltins(t *testing.T) { | |||
| 100 | + | got, errs := Discover("", "", "", false) | |||
| 101 | + | if len(errs) != 0 { | |||
| 102 | + | t.Fatalf("errs: %v", errs) | |||
| 103 | + | } | |||
| 104 | + | for _, builtin := range loadBuiltins() { | |||
| 105 | + | if FindByName(got, builtin.Name) == nil { | |||
| 106 | + | t.Errorf("built-in skill %q missing", builtin.Name) | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 99 | func TestKebabCase(t *testing.T) { | 111 | func TestKebabCase(t *testing.T) { | ||
| 100 | cases := map[string]string{ | 112 | cases := map[string]string{ | ||
| 101 | "writing-git-commits": "writing-git-commits", | 113 | "writing-git-commits": "writing-git-commits", | ||