detached bfc7b11

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 {
218218 if err != nil {
219219 return err
220220 }
221+ reportSkillDiagnostics(os.Stderr, resolved.SkillDiagnostics)
221222
222223 if ok, cerr := spec.configured(ZotHome()); cerr != nil {
223224 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 err219 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 cerr224 return cerr
M packages/agent/build.go +18 -5
@@ -53,6 +53,10 @@ type Resolved struct {
5353 // discovered. Exposed so the tui can list / preview skills.
5454 SkillTool *skills.Tool
5555
56+ // SkillDiagnostics records non-fatal skill discovery failures and
57+ // shadowing decisions so each host mode can report them appropriately.
58+ SkillDiagnostics []string
59+
5660 // ContextFiles records the AGENTS.md files appended to SystemPrompt,
5761 // in effective load order. Interactive mode uses this metadata to make
5862 // otherwise invisible startup context inspectable without adding fake
@@ -590,9 +594,10 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
590594 // system prompt, no `skill` tool in the registry. Useful for a
591595 // clean-room run with zero extra context biasing the model.
592596 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
596601 )
597602 if !args.NoSkill {
598603 homeDir, _ := os.UserHomeDir()
@@ -603,10 +608,17 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
603608 }
604609 }
605610 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)
607612 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())
608621 }
609- discovered, _ = skills.DiscoverSources(sources, true)
610622 if len(discovered) > 0 {
611623 skillTool = skills.NewTool(discovered)
612624 reg[skillTool.Name()] = skillTool
@@ -678,6 +690,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
678690 MaxOutput: resolvedModel.MaxOutput,
679691 Sandbox: sandbox,
680692 SkillTool: skillTool,
693+ SkillDiagnostics: skillDiagnostics,
681694 ContextFiles: contextFiles,
682695 systemAppend: append_,
683696 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.Tool54 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 make61 // in effective load order. Interactive mode uses this metadata to make
58 // otherwise invisible startup context inspectable without adding fake62 // 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 a594 // 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.Skill597+ discovered []*skills.Skill
594- skillTool *skills.Tool598+ skillTool *skills.Tool
595- skillAddendum string599+ 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()] = skillTool624 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) {
219219 }
220220 }
221221
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+
222243 func TestNewAgentInjectsOpenRouterServerToolsWhenSettingOn(t *testing.T) {
223244 t.Setenv("ZOT_HOME", t.TempDir())
224245 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)
296296 // wire tools into the resolved registry, and a cleanup closure to
297297 // defer. Mirrors the interactive-mode setup minus the TUI hooks.
298298 func setupNonInteractiveExtensions(ctx context.Context, args Args, r *Resolved, version string) (*extensions.Manager, func()) {
299+ reportSkillDiagnostics(os.Stderr, r.SkillDiagnostics)
299300 extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, nonInteractiveExtHooks{})
300301 for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
301302 fmt.Fprintln(os.Stderr, "extension load:", e)
@@ -311,6 +312,12 @@ func setupNonInteractiveExtensions(ctx context.Context, args Args, r *Resolved,
311312 return extMgr, func() { extMgr.Stop(2 * time.Second) }
312313 }
313314
315+func reportSkillDiagnostics(w io.Writer, diagnostics []string) {
316+ for _, diagnostic := range diagnostics {
317+ fmt.Fprintln(w, "skill discovery:", diagnostic)
318+ }
319+}
320+
314321 // wireNonInteractiveAgentExtHooks installs the same BeforeToolExecute
315322 // / BeforeTurn / BeforeAssistantMessage / OnEvent hooks the
316323 // interactive path wires up, so extensions get their normal
@@ -658,7 +665,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
658665 var iv *modes.Interactive
659666 extHooks := &interactiveExtHooks{ivPtr: &iv}
660667 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+ }
662672 // --ext paths first so they win against installed extensions of
663673 // the same name (loadOne's first-write-wins semantics).
664674 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 to296 // 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 BeforeToolExecute321 // wireNonInteractiveAgentExtHooks installs the same BeforeToolExecute
315 // / BeforeTurn / BeforeAssistantMessage / OnEvent hooks the322 // / BeforeTurn / BeforeAssistantMessage / OnEvent hooks the
316 // interactive path wires up, so extensions get their normal323 // 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.Interactive665 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 []string668+ 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 of672 // --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 {
150150 Dir string
151151 Exec string
152152 Theme bool
153+ Skills bool
153154 Manifest string
154155 Error string
155156 Shadowed bool
@@ -239,10 +240,11 @@ func scanExtDoctorStatic() []extDoctorStaticRow {
239240 continue
240241 }
241242 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"`
246248 }
247249 if err := json.Unmarshal(raw, &m); err != nil {
248250 row.Error = "parse manifest: " + err.Error()
@@ -256,11 +258,12 @@ func scanExtDoctorStatic() []extDoctorStaticRow {
256258 }
257259 row.Version = m.Version
258260 row.Exec = m.Exec
261+ row.Skills = len(m.Skills) > 0
259262 if m.Enabled != nil {
260263 row.Enabled = *m.Enabled
261264 }
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"
264267 }
265268 rows = append(rows, row)
266269 }
@@ -277,8 +280,12 @@ func printExtDoctorRow(w io.Writer, row extDoctorStaticRow, diag extensions.Exte
277280 status = "shadowed"
278281 case !row.Enabled:
279282 status = "disabled"
280- case row.Exec == "" && row.Theme:
283+ case row.Exec == "" && row.Theme && !row.Skills:
281284 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"
282289 case diag.Name == "":
283290 status = "not loaded"
284291 case diag.ReadyTimedOut:
@@ -309,8 +316,8 @@ func printExtDoctorRow(w io.Writer, row extDoctorStaticRow, diag extensions.Exte
309316 fmt.Fprintln(w, " note: disabled in extension.json")
310317 return
311318 }
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)
314321 return
315322 }
316323 if row.Exec != "" {
@@ -150,6 +150,7 @@ type extDoctorStaticRow struct {
150 Dir string150 Dir string
151 Exec string151 Exec string
152 Theme bool152 Theme bool
153+ Skills bool
153 Manifest string154 Manifest string
154 Error string155 Error string
155 Shadowed bool156 Shadowed bool
@@ -239,10 +240,11 @@ func scanExtDoctorStatic() []extDoctorStaticRow {
239 continue240 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.Version259 row.Version = m.Version
258 row.Exec = m.Exec260 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.Enabled263 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 return317 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 return321 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) {
155155 mustWrite(filepath.Join(cwd, ".zot", "extensions", "disabled", "extension.json"), `{"name":"disabled","exec":"./run.sh","enabled":false}`)
156156 mustWrite(filepath.Join(cwd, ".zot", "extensions", "theme", "extension.json"), `{"name":"theme"}`)
157157 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"]}`)
158159 mustWrite(filepath.Join(cwd, ".zot", "extensions", "bad", "extension.json"), `{bad json`)
159160 mustWrite(filepath.Join(cwd, ".zot", "extensions", "dup", "extension.json"), `{"name":"dup","exec":"./project.sh"}`)
160161 mustWrite(filepath.Join(home, "extensions", "dup", "extension.json"), `{"name":"dup","exec":"./global.sh"}`)
161162
162163 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)
165166 }
166167
167168 var out bytes.Buffer
@@ -172,6 +173,7 @@ func TestExtDoctorStaticScanAndRender(t *testing.T) {
172173 for _, want := range []string{
173174 "disabled [project] disabled",
174175 "theme [project] theme-only",
176+ "skills [project] skill-only",
175177 "bad [project] error",
176178 "dup [global] shadowed",
177179 "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.Buffer168 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) {
277277 // returns their filesystem-backed skill roots in precedence order. Explicit
278278 // extensions win over project and global installations; project wins global.
279279 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
281286 for _, p := range explicit {
282287 abs, err := filepath.Abs(p)
283288 if err != nil {
289+ errs = append(errs, fmt.Errorf("%s: %w", p, err))
284290 continue
285291 }
286- dirs = append(dirs, abs)
292+ roots = append(roots, root{path: abs, explicit: true})
287293 }
288294 if includeImplicit {
289295 if cwd != "" {
290- dirs = append(dirs, filepath.Join(cwd, ".zot", "extensions"))
296+ roots = append(roots, root{path: filepath.Join(cwd, ".zot", "extensions")})
291297 }
292298 if zotHome != "" {
293- dirs = append(dirs, filepath.Join(zotHome, "extensions"))
299+ roots = append(roots, root{path: filepath.Join(zotHome, "extensions")})
294300 }
295301 }
296302 seen := map[string]bool{}
297303 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))
303316 continue
304317 }
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}
311318 }
312319 for _, dir := range candidates {
313320 mf, err := readManifest(dir)
314321 if err != nil {
315- if !os.IsNotExist(err) {
322+ if root.explicit || !os.IsNotExist(err) {
316323 errs = append(errs, fmt.Errorf("%s: %w", dir, err))
317324 }
318325 continue
@@ -277,42 +277,49 @@ func extensionDirs(root string) ([]string, error) {
277 // returns their filesystem-backed skill roots in precedence order. Explicit277 // 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 []string280+ 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 continue290 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.Source303 var out []skills.Source
298- var errs []error304+ 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 continue316 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 continue325 continue
M packages/agent/extensions/manager_test.go +20 -0
@@ -277,6 +277,26 @@ func TestPlanSkillSources(t *testing.T) {
277277 }
278278 }
279279
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+
280300 func TestPlanSkillSourcesRejectsTraversal(t *testing.T) {
281301 tmp := t.TempDir()
282302 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 {
4545 StartupExtensionNames []string
4646 StartupSkillNames []string
4747
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.
5051 StartupExtensionErrors []string
5152
5253 // ShowInstructionsAtStartup controls whether startup resources are visible.
@@ -543,8 +544,8 @@ type Interactive struct {
543544 // transcript) until cleared by /clear or another reset.
544545 extNotes []string
545546
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.
548549 reloadErrors []string
549550
550551 // shellRunning is true while a !command is executing. It shares
@@ -45,8 +45,9 @@ type InteractiveConfig struct {
45 StartupExtensionNames []string45 StartupExtensionNames []string
46 StartupSkillNames []string46 StartupSkillNames []string
47 47
48- // StartupExtensionErrors are non-fatal extension load failures collected48+ // 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 []string51 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 []string545 extNotes []string
545 546
546- // reloadErrors are host-only extension reload failures. They stay in547+ // 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 []string549 reloadErrors []string
549 550
550 // shellRunning is true while a !command is executing. It shares551 // 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 {
4848 if err != nil {
4949 return err
5050 }
51+ reportSkillDiagnostics(os.Stderr, r.SkillDiagnostics)
5152
5253 // Extensions: same lifecycle as interactive mode, minus the
5354 // 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 err49 return err
50 }50 }
51+ reportSkillDiagnostics(os.Stderr, r.SkillDiagnostics)
51 52
52 // Extensions: same lifecycle as interactive mode, minus the53 // Extensions: same lifecycle as interactive mode, minus the
53 // host-hooks integration. Notify/Display calls from extensions54 // 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
116116 sources = append(sources, Source{Root: loc.dir, Label: loc.label})
117117 }
118118 }
119- discovered, errs := DiscoverSources(sources, includeUser)
119+ discovered, errs := DiscoverSources(sources, true)
120120 // Preserve the historical Discover contract: duplicate ordinary skills
121121 // were silently ignored. New callers using DiscoverSources receive the
122122 // richer shadowing diagnostics.
@@ -148,9 +148,11 @@ func DiscoverSources(sources []Source, includeBuiltins bool) ([]*Skill, []error)
148148 }
149149 if includeBuiltins {
150150 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
153154 }
155+ seen[s.Name] = s
154156 }
155157 }
156158 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 skills120 // Preserve the historical Discover contract: duplicate ordinary skills
121 // were silently ignored. New callers using DiscoverSources receive the121 // 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] = s152+ 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) {
9696 }
9797 }
9898
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+
99111 func TestKebabCase(t *testing.T) {
100112 cases := map[string]string{
101113 "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",

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close