detached 7e94b07

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
333333 shallow clone. Both validate that the destination contains an
334334 `extension.json` and roll back if not.
335335
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+
336351 ## SDKs
337352
338353 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 an333 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 ## SDKs351 ## SDKs
337 352
338 Writing the wire protocol by hand is fine for one-off scripts, but353 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 {
3838 Tools []string
3939 MaxSteps int
4040
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+
4147 ListModels bool
4248 Help bool
4349 Version bool
@@ -120,6 +126,15 @@ func ParseArgs(in []string) (Args, error) {
120126 return a, err
121127 }
122128 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)
123138 case "--reasoning":
124139 v, err := want(&i, arg)
125140 if err != nil {
@@ -219,6 +234,9 @@ flags:
219234 --cwd PATH treat PATH as the working directory
220235 --no-tools disable all tools
221236 --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)
222240
223241 --max-steps N agent loop iteration cap (default 50)
224242 --list-models print known models and exit
@@ -38,6 +38,12 @@ type Args struct {
38 Tools []string38 Tools []string
39 MaxSteps int39 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 bool47 ListModels bool
42 Help bool48 Help bool
43 Version bool49 Version bool
@@ -120,6 +126,15 @@ func ParseArgs(in []string) (Args, error) {
120 return a, err126 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 directory234 --cwd PATH treat PATH as the working directory
220 --no-tools disable all tools235 --no-tools disable all tools
221 --tools csv only enable the listed tools236 --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 exit242 --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 {
254254 var iv *modes.Interactive
255255 extHooks := &interactiveExtHooks{ivPtr: &iv}
256256 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+ }
257262 discoveryErrs := extMgr.Discover(ctx)
258263 for _, e := range discoveryErrs {
259264 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.Interactive254 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 {
269269 return nil
270270 }
271271
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+
272310 // WaitForReady blocks until every loaded extension has signalled
273311 // ReadyFromExt, or the grace period expires for the slowest one.
274312 //
@@ -269,6 +269,44 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error {
269 return nil269 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 signalled310 // 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 {
5050 // emit RPC events instead of TUI lines so any consumer can react.
5151 extHooks := &rpcExtHooks{}
5252 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+ }
5356 discoveryErrs := extMgr.Discover(ctx)
5457 for _, e := range discoveryErrs {
5558 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)

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