diff --git a/docs/extensions.md b/docs/extensions.md index 9ffbd26..1cd4e4f 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -333,6 +333,21 @@ zot ext logs [-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 diff --git a/internal/agent/args.go b/internal/agent/args.go index a38a112..b3b9967 100644 --- a/internal/agent/args.go +++ b/internal/agent/args.go @@ -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 diff --git a/internal/agent/cli.go b/internal/agent/cli.go index b5c0bb5..f0a5fe6 100644 --- a/internal/agent/cli.go +++ b/internal/agent/cli.go @@ -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) diff --git a/internal/agent/extensions/manager.go b/internal/agent/extensions/manager.go index 45e5f2b..ff77c7c 100644 --- a/internal/agent/extensions/manager.go +++ b/internal/agent/extensions/manager.go @@ -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 ` 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. // diff --git a/internal/agent/rpc.go b/internal/agent/rpc.go index e45c090..e8fd352 100644 --- a/internal/agent/rpc.go +++ b/internal/agent/rpc.go @@ -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)