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.
This commit is contained in:
patriceckhart 2026-04-19 15:20:56 +02:00
parent 83b64e2562
commit 7e94b0776b
5 changed files with 79 additions and 0 deletions

View file

@ -333,6 +333,21 @@ zot ext logs <name> [-f] cat / tail the extension's stderr
shallow clone. Both validate that the destination contains an
`extension.json` and roll back if not.
## Loading an extension for one run
For iteration on a working copy, skip the install + reload cycle
and load straight from disk for one zot session:
```
zot --ext ./my-extension # short form: -e ./my-extension
zot --ext ./a -e ./b # repeatable
```
`--ext` paths take precedence over installed extensions of the same
name, so you can shadow an installed copy with a work-in-progress
version without uninstalling first. Nothing is copied or persisted;
the extension dies with zot like any other subprocess.
## SDKs
Writing the wire protocol by hand is fine for one-off scripts, but

View file

@ -38,6 +38,12 @@ type Args struct {
Tools []string
MaxSteps int
// Exts is a list of directory paths the user passed via --ext.
// Each must contain an extension.json. Loaded for one session
// only; never persisted. Take precedence over installed exts of
// the same name.
Exts []string
ListModels bool
Help bool
Version bool
@ -120,6 +126,15 @@ func ParseArgs(in []string) (Args, error) {
return a, err
}
a.AppendSystemPrompt = append(a.AppendSystemPrompt, v)
case "--ext", "-e":
v, err := want(&i, arg)
if err != nil {
return a, err
}
// Repeatable; each value is a directory containing an
// extension.json. Resolved to absolute later so paths like
// "." survive a later cwd change.
a.Exts = append(a.Exts, v)
case "--reasoning":
v, err := want(&i, arg)
if err != nil {
@ -219,6 +234,9 @@ flags:
--cwd PATH treat PATH as the working directory
--no-tools disable all tools
--tools csv only enable the listed tools
-e, --ext PATH load an extension from PATH for this run
(repeatable; takes precedence over
installed extensions of the same name)
--max-steps N agent loop iteration cap (default 50)
--list-models print known models and exit

View file

@ -254,6 +254,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
var iv *modes.Interactive
extHooks := &interactiveExtHooks{ivPtr: &iv}
extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks)
// --ext paths first so they win against installed extensions of
// the same name (loadOne's first-write-wins semantics).
for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
fmt.Fprintln(os.Stderr, "extension load:", e)
}
discoveryErrs := extMgr.Discover(ctx)
for _, e := range discoveryErrs {
fmt.Fprintln(os.Stderr, "extension load:", e)

View file

@ -269,6 +269,44 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error {
return nil
}
// LoadExplicit loads each path as an ad-hoc extension. Used for
// `zot --ext <path>` so extension authors can iterate on a working
// copy without having to `zot ext install` after every change.
//
// Loaded BEFORE Discover so explicit paths win on name conflicts
// against installed extensions. Spawns happen in parallel like the
// regular discovery path; errors are returned per path.
func (m *Manager) LoadExplicit(ctx context.Context, paths []string) []error {
if len(paths) == 0 {
return nil
}
var wg sync.WaitGroup
errCh := make(chan error, len(paths))
for _, p := range paths {
abs, err := filepath.Abs(p)
if err != nil {
errCh <- fmt.Errorf("%s: %w", p, err)
continue
}
wg.Add(1)
go func(extDir string) {
defer wg.Done()
if err := m.loadOne(ctx, extDir); err != nil {
errCh <- fmt.Errorf("%s: %w", extDir, err)
}
}(abs)
}
wg.Wait()
close(errCh)
var errs []error
for e := range errCh {
errs = append(errs, e)
}
return errs
}
// WaitForReady blocks until every loaded extension has signalled
// ReadyFromExt, or the grace period expires for the slowest one.
//

View file

@ -50,6 +50,9 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
// emit RPC events instead of TUI lines so any consumer can react.
extHooks := &rpcExtHooks{}
extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks)
for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
fmt.Fprintln(os.Stderr, "extension load:", e)
}
discoveryErrs := extMgr.Discover(ctx)
for _, e := range discoveryErrs {
fmt.Fprintln(os.Stderr, "extension load:", e)