feat(extensions): --ext PATH (short -e) for ad-hoc loading
Loads an extension from any directory for one zot session without needing to copy / install it under $ZOT_HOME. Repeatable. Resolved to absolute before spawn so paths like "." survive a later cwd change. Loaded BEFORE the installed-extension scan so explicit paths win on name conflicts, letting you shadow an installed copy with a work-in-progress version. zot --ext ./my-extension # one extension zot -e ./a -e ./b # multiple zot --ext . # the cwd is itself an extension Manager.LoadExplicit is the public entry point. Spawns happen in parallel like the regular Discover path, with per-path errors returned so a typo in one --ext doesn't break the others. Wired into both interactive (cli.go) and rpc (rpc.go) modes. Help text + docs/extensions.md updated. Verified end-to-end: disabling the installed scratchpad, running `zot rpc --ext .` from its directory, and asking the model to list its tools shows read_notes available again.
- commit
- 7e94b0776b9d735cf4beaa9b54b4084714b6e663
- author
- patriceckhart · · 2026-04-19 13:20 UTC
- parent
- 83b64e2
- signature
- unsigned
5 files · +79 -0
M docs/extensions.md +15 -0
| @@ -333,6 +333,21 @@ zot ext logs <name> [-f] cat / tail the extension's stderr | |||
| 333 | 333 | shallow clone. Both validate that the destination contains an | |
| 334 | 334 | `extension.json` and roll back if not. | |
| 335 | 335 | ||
| 336 | + | ## Loading an extension for one run | |
| 337 | + | ||
| 338 | + | For iteration on a working copy, skip the install + reload cycle | |
| 339 | + | and load straight from disk for one zot session: | |
| 340 | + | ||
| 341 | + | ``` | |
| 342 | + | zot --ext ./my-extension # short form: -e ./my-extension | |
| 343 | + | zot --ext ./a -e ./b # repeatable | |
| 344 | + | ``` | |
| 345 | + | ||
| 346 | + | `--ext` paths take precedence over installed extensions of the same | |
| 347 | + | name, so you can shadow an installed copy with a work-in-progress | |
| 348 | + | version without uninstalling first. Nothing is copied or persisted; | |
| 349 | + | the extension dies with zot like any other subprocess. | |
| 350 | + | ||
| 336 | 351 | ## SDKs | |
| 337 | 352 | ||
| 338 | 353 | Writing the wire protocol by hand is fine for one-off scripts, but | |
| @@ -333,6 +333,21 @@ zot ext logs <name> [-f] cat / tail the extension's stderr | |||||
| 333 | shallow clone. Both validate that the destination contains an | 333 | shallow clone. Both validate that the destination contains an | ||
| 334 | `extension.json` and roll back if not. | 334 | `extension.json` and roll back if not. | ||
| 335 | 335 | ||||
| 336 | + | ## Loading an extension for one run | |||
| 337 | + | ||||
| 338 | + | For iteration on a working copy, skip the install + reload cycle | |||
| 339 | + | and load straight from disk for one zot session: | |||
| 340 | + | ||||
| 341 | + | ``` | |||
| 342 | + | zot --ext ./my-extension # short form: -e ./my-extension | |||
| 343 | + | zot --ext ./a -e ./b # repeatable | |||
| 344 | + | ``` | |||
| 345 | + | ||||
| 346 | + | `--ext` paths take precedence over installed extensions of the same | |||
| 347 | + | name, so you can shadow an installed copy with a work-in-progress | |||
| 348 | + | version without uninstalling first. Nothing is copied or persisted; | |||
| 349 | + | the extension dies with zot like any other subprocess. | |||
| 350 | + | ||||
| 336 | ## SDKs | 351 | ## SDKs | ||
| 337 | 352 | ||||
| 338 | Writing the wire protocol by hand is fine for one-off scripts, but | 353 | Writing the wire protocol by hand is fine for one-off scripts, but | ||
M internal/agent/args.go +18 -0
| @@ -38,6 +38,12 @@ type Args struct { | |||
| 38 | 38 | Tools []string | |
| 39 | 39 | MaxSteps int | |
| 40 | 40 | ||
| 41 | + | // Exts is a list of directory paths the user passed via --ext. | |
| 42 | + | // Each must contain an extension.json. Loaded for one session | |
| 43 | + | // only; never persisted. Take precedence over installed exts of | |
| 44 | + | // the same name. | |
| 45 | + | Exts []string | |
| 46 | + | ||
| 41 | 47 | ListModels bool | |
| 42 | 48 | Help bool | |
| 43 | 49 | Version bool | |
| @@ -120,6 +126,15 @@ func ParseArgs(in []string) (Args, error) { | |||
| 120 | 126 | return a, err | |
| 121 | 127 | } | |
| 122 | 128 | a.AppendSystemPrompt = append(a.AppendSystemPrompt, v) | |
| 129 | + | case "--ext", "-e": | |
| 130 | + | v, err := want(&i, arg) | |
| 131 | + | if err != nil { | |
| 132 | + | return a, err | |
| 133 | + | } | |
| 134 | + | // Repeatable; each value is a directory containing an | |
| 135 | + | // extension.json. Resolved to absolute later so paths like | |
| 136 | + | // "." survive a later cwd change. | |
| 137 | + | a.Exts = append(a.Exts, v) | |
| 123 | 138 | case "--reasoning": | |
| 124 | 139 | v, err := want(&i, arg) | |
| 125 | 140 | if err != nil { | |
| @@ -219,6 +234,9 @@ flags: | |||
| 219 | 234 | --cwd PATH treat PATH as the working directory | |
| 220 | 235 | --no-tools disable all tools | |
| 221 | 236 | --tools csv only enable the listed tools | |
| 237 | + | -e, --ext PATH load an extension from PATH for this run | |
| 238 | + | (repeatable; takes precedence over | |
| 239 | + | installed extensions of the same name) | |
| 222 | 240 | ||
| 223 | 241 | --max-steps N agent loop iteration cap (default 50) | |
| 224 | 242 | --list-models print known models and exit | |
| @@ -38,6 +38,12 @@ type Args struct { | |||||
| 38 | Tools []string | 38 | Tools []string | ||
| 39 | MaxSteps int | 39 | MaxSteps int | ||
| 40 | 40 | ||||
| 41 | + | // Exts is a list of directory paths the user passed via --ext. | |||
| 42 | + | // Each must contain an extension.json. Loaded for one session | |||
| 43 | + | // only; never persisted. Take precedence over installed exts of | |||
| 44 | + | // the same name. | |||
| 45 | + | Exts []string | |||
| 46 | + | ||||
| 41 | ListModels bool | 47 | ListModels bool | ||
| 42 | Help bool | 48 | Help bool | ||
| 43 | Version bool | 49 | Version bool | ||
| @@ -120,6 +126,15 @@ func ParseArgs(in []string) (Args, error) { | |||||
| 120 | return a, err | 126 | return a, err | ||
| 121 | } | 127 | } | ||
| 122 | a.AppendSystemPrompt = append(a.AppendSystemPrompt, v) | 128 | a.AppendSystemPrompt = append(a.AppendSystemPrompt, v) | ||
| 129 | + | case "--ext", "-e": | |||
| 130 | + | v, err := want(&i, arg) | |||
| 131 | + | if err != nil { | |||
| 132 | + | return a, err | |||
| 133 | + | } | |||
| 134 | + | // Repeatable; each value is a directory containing an | |||
| 135 | + | // extension.json. Resolved to absolute later so paths like | |||
| 136 | + | // "." survive a later cwd change. | |||
| 137 | + | a.Exts = append(a.Exts, v) | |||
| 123 | case "--reasoning": | 138 | case "--reasoning": | ||
| 124 | v, err := want(&i, arg) | 139 | v, err := want(&i, arg) | ||
| 125 | if err != nil { | 140 | if err != nil { | ||
| @@ -219,6 +234,9 @@ flags: | |||||
| 219 | --cwd PATH treat PATH as the working directory | 234 | --cwd PATH treat PATH as the working directory | ||
| 220 | --no-tools disable all tools | 235 | --no-tools disable all tools | ||
| 221 | --tools csv only enable the listed tools | 236 | --tools csv only enable the listed tools | ||
| 237 | + | -e, --ext PATH load an extension from PATH for this run | |||
| 238 | + | (repeatable; takes precedence over | |||
| 239 | + | installed extensions of the same name) | |||
| 222 | 240 | ||||
| 223 | --max-steps N agent loop iteration cap (default 50) | 241 | --max-steps N agent loop iteration cap (default 50) | ||
| 224 | --list-models print known models and exit | 242 | --list-models print known models and exit | ||
M internal/agent/cli.go +5 -0
| @@ -254,6 +254,11 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 254 | 254 | var iv *modes.Interactive | |
| 255 | 255 | extHooks := &interactiveExtHooks{ivPtr: &iv} | |
| 256 | 256 | extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks) | |
| 257 | + | // --ext paths first so they win against installed extensions of | |
| 258 | + | // the same name (loadOne's first-write-wins semantics). | |
| 259 | + | for _, e := range extMgr.LoadExplicit(ctx, args.Exts) { | |
| 260 | + | fmt.Fprintln(os.Stderr, "extension load:", e) | |
| 261 | + | } | |
| 257 | 262 | discoveryErrs := extMgr.Discover(ctx) | |
| 258 | 263 | for _, e := range discoveryErrs { | |
| 259 | 264 | fmt.Fprintln(os.Stderr, "extension load:", e) | |
| @@ -254,6 +254,11 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 254 | var iv *modes.Interactive | 254 | var iv *modes.Interactive | ||
| 255 | extHooks := &interactiveExtHooks{ivPtr: &iv} | 255 | extHooks := &interactiveExtHooks{ivPtr: &iv} | ||
| 256 | extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks) | 256 | extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks) | ||
| 257 | + | // --ext paths first so they win against installed extensions of | |||
| 258 | + | // the same name (loadOne's first-write-wins semantics). | |||
| 259 | + | for _, e := range extMgr.LoadExplicit(ctx, args.Exts) { | |||
| 260 | + | fmt.Fprintln(os.Stderr, "extension load:", e) | |||
| 261 | + | } | |||
| 257 | discoveryErrs := extMgr.Discover(ctx) | 262 | discoveryErrs := extMgr.Discover(ctx) | ||
| 258 | for _, e := range discoveryErrs { | 263 | for _, e := range discoveryErrs { | ||
| 259 | fmt.Fprintln(os.Stderr, "extension load:", e) | 264 | fmt.Fprintln(os.Stderr, "extension load:", e) | ||
M internal/agent/extensions/manager.go +38 -0
| @@ -269,6 +269,44 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error { | |||
| 269 | 269 | return nil | |
| 270 | 270 | } | |
| 271 | 271 | ||
| 272 | + | // LoadExplicit loads each path as an ad-hoc extension. Used for | |
| 273 | + | // `zot --ext <path>` so extension authors can iterate on a working | |
| 274 | + | // copy without having to `zot ext install` after every change. | |
| 275 | + | // | |
| 276 | + | // Loaded BEFORE Discover so explicit paths win on name conflicts | |
| 277 | + | // against installed extensions. Spawns happen in parallel like the | |
| 278 | + | // regular discovery path; errors are returned per path. | |
| 279 | + | func (m *Manager) LoadExplicit(ctx context.Context, paths []string) []error { | |
| 280 | + | if len(paths) == 0 { | |
| 281 | + | return nil | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | var wg sync.WaitGroup | |
| 285 | + | errCh := make(chan error, len(paths)) | |
| 286 | + | for _, p := range paths { | |
| 287 | + | abs, err := filepath.Abs(p) | |
| 288 | + | if err != nil { | |
| 289 | + | errCh <- fmt.Errorf("%s: %w", p, err) | |
| 290 | + | continue | |
| 291 | + | } | |
| 292 | + | wg.Add(1) | |
| 293 | + | go func(extDir string) { | |
| 294 | + | defer wg.Done() | |
| 295 | + | if err := m.loadOne(ctx, extDir); err != nil { | |
| 296 | + | errCh <- fmt.Errorf("%s: %w", extDir, err) | |
| 297 | + | } | |
| 298 | + | }(abs) | |
| 299 | + | } | |
| 300 | + | wg.Wait() | |
| 301 | + | close(errCh) | |
| 302 | + | ||
| 303 | + | var errs []error | |
| 304 | + | for e := range errCh { | |
| 305 | + | errs = append(errs, e) | |
| 306 | + | } | |
| 307 | + | return errs | |
| 308 | + | } | |
| 309 | + | ||
| 272 | 310 | // WaitForReady blocks until every loaded extension has signalled | |
| 273 | 311 | // ReadyFromExt, or the grace period expires for the slowest one. | |
| 274 | 312 | // | |
| @@ -269,6 +269,44 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error { | |||||
| 269 | return nil | 269 | return nil | ||
| 270 | } | 270 | } | ||
| 271 | 271 | ||||
| 272 | + | // LoadExplicit loads each path as an ad-hoc extension. Used for | |||
| 273 | + | // `zot --ext <path>` so extension authors can iterate on a working | |||
| 274 | + | // copy without having to `zot ext install` after every change. | |||
| 275 | + | // | |||
| 276 | + | // Loaded BEFORE Discover so explicit paths win on name conflicts | |||
| 277 | + | // against installed extensions. Spawns happen in parallel like the | |||
| 278 | + | // regular discovery path; errors are returned per path. | |||
| 279 | + | func (m *Manager) LoadExplicit(ctx context.Context, paths []string) []error { | |||
| 280 | + | if len(paths) == 0 { | |||
| 281 | + | return nil | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | var wg sync.WaitGroup | |||
| 285 | + | errCh := make(chan error, len(paths)) | |||
| 286 | + | for _, p := range paths { | |||
| 287 | + | abs, err := filepath.Abs(p) | |||
| 288 | + | if err != nil { | |||
| 289 | + | errCh <- fmt.Errorf("%s: %w", p, err) | |||
| 290 | + | continue | |||
| 291 | + | } | |||
| 292 | + | wg.Add(1) | |||
| 293 | + | go func(extDir string) { | |||
| 294 | + | defer wg.Done() | |||
| 295 | + | if err := m.loadOne(ctx, extDir); err != nil { | |||
| 296 | + | errCh <- fmt.Errorf("%s: %w", extDir, err) | |||
| 297 | + | } | |||
| 298 | + | }(abs) | |||
| 299 | + | } | |||
| 300 | + | wg.Wait() | |||
| 301 | + | close(errCh) | |||
| 302 | + | ||||
| 303 | + | var errs []error | |||
| 304 | + | for e := range errCh { | |||
| 305 | + | errs = append(errs, e) | |||
| 306 | + | } | |||
| 307 | + | return errs | |||
| 308 | + | } | |||
| 309 | + | ||||
| 272 | // WaitForReady blocks until every loaded extension has signalled | 310 | // WaitForReady blocks until every loaded extension has signalled | ||
| 273 | // ReadyFromExt, or the grace period expires for the slowest one. | 311 | // ReadyFromExt, or the grace period expires for the slowest one. | ||
| 274 | // | 312 | // | ||
M internal/agent/rpc.go +3 -0
| @@ -50,6 +50,9 @@ func runRPCMode(ctx context.Context, args Args, version string) error { | |||
| 50 | 50 | // emit RPC events instead of TUI lines so any consumer can react. | |
| 51 | 51 | extHooks := &rpcExtHooks{} | |
| 52 | 52 | extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks) | |
| 53 | + | for _, e := range extMgr.LoadExplicit(ctx, args.Exts) { | |
| 54 | + | fmt.Fprintln(os.Stderr, "extension load:", e) | |
| 55 | + | } | |
| 53 | 56 | discoveryErrs := extMgr.Discover(ctx) | |
| 54 | 57 | for _, e := range discoveryErrs { | |
| 55 | 58 | fmt.Fprintln(os.Stderr, "extension load:", e) | |
| @@ -50,6 +50,9 @@ func runRPCMode(ctx context.Context, args Args, version string) error { | |||||
| 50 | // emit RPC events instead of TUI lines so any consumer can react. | 50 | // emit RPC events instead of TUI lines so any consumer can react. | ||
| 51 | extHooks := &rpcExtHooks{} | 51 | extHooks := &rpcExtHooks{} | ||
| 52 | extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks) | 52 | extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks) | ||
| 53 | + | for _, e := range extMgr.LoadExplicit(ctx, args.Exts) { | |||
| 54 | + | fmt.Fprintln(os.Stderr, "extension load:", e) | |||
| 55 | + | } | |||
| 53 | discoveryErrs := extMgr.Discover(ctx) | 56 | discoveryErrs := extMgr.Discover(ctx) | ||
| 54 | for _, e := range discoveryErrs { | 57 | for _, e := range discoveryErrs { | ||
| 55 | fmt.Fprintln(os.Stderr, "extension load:", e) | 58 | fmt.Fprintln(os.Stderr, "extension load:", e) | ||