diff --git a/docs/extensions.md b/docs/extensions.md index 22303b2..ed2f365 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -549,6 +549,8 @@ See: - `examples/extensions/weather/` — LLM-callable tool - `examples/extensions/guard/` — event subscriptions + tool-call interception (refuses dangerous bash patterns) +- `examples/extensions/todo/` — interactive persistent panel + tool +- `examples/extensions/scratchpad/` — source-run TypeScript commands + tool ### Hot reload diff --git a/examples/extensions/clock/README.md b/examples/extensions/clock/README.md index 095bfa8..50857b6 100644 --- a/examples/extensions/clock/README.md +++ b/examples/extensions/clock/README.md @@ -39,8 +39,8 @@ authoring works too — rename `index.js` → `index.ts`, install ```json { - "exec": "tsx", - "args": ["index.ts"] + "exec": "npx", + "args": ["-y", "tsx", "./index.ts"] } ``` diff --git a/examples/extensions/clock/extension.json b/examples/extensions/clock/extension.json index df4f066..6447349 100644 --- a/examples/extensions/clock/extension.json +++ b/examples/extensions/clock/extension.json @@ -3,7 +3,7 @@ "version": "1.0.0", "exec": "node", "args": ["index.js"], - "language": "typescript", + "language": "javascript", "description": "registers /now and /uptime — a tiny time toolkit", "enabled": true } diff --git a/examples/extensions/scratchpad/README.md b/examples/extensions/scratchpad/README.md index f961bdb..f21d019 100644 --- a/examples/extensions/scratchpad/README.md +++ b/examples/extensions/scratchpad/README.md @@ -1,8 +1,8 @@ -# scratchpad — TypeScript extension example +# scratchpad — example zot extension (TypeScript, source-run) -Real `.ts` (not `.js`), no build step, no SDK. Runs via `npx tsx`, -which downloads itself into the npm cache on first invocation and -runs from cache on every subsequent call. +Real `.ts` (not `.js`), no build step, no SDK. Runs via `npx -y tsx +./index.ts`, which downloads `tsx` into npm's cache on first +invocation and then reuses the cached copy on subsequent runs. Demonstrates: @@ -13,16 +13,7 @@ Demonstrates: ## Requirements -Node 18+ and `tsx` on `$PATH`: - -```bash -npm install -g tsx -``` - -This is what `extension.json` invokes (`exec: "tsx"`). Without the -global install, swap to `"exec": "npx"` + `"args": ["--yes", "tsx", -"index.ts"]` — functional but adds ~1 second to each zot startup -because npm checks the registry on every invocation. +Node 18+ and `npx` (bundled with npm). ## Install @@ -32,6 +23,8 @@ From this directory: zot ext install . ``` +This copies the manifest + source into `$ZOT_HOME/extensions/scratchpad/`. + ## Use In zot: @@ -65,8 +58,9 @@ without any infrastructure beyond `tsx`. If you want richer ergonomics ## See also -- `examples/extensions/clock` — JS sibling (no tsx required) -- `examples/extensions/hello` — Go SDK -- `examples/extensions/weather` — Go SDK, exposes one tool -- `examples/extensions/guard` — Go SDK, demonstrates intercepts +- `examples/extensions/clock` — JavaScript sibling (no build step) +- `examples/extensions/hello` — Go SDK slash commands +- `examples/extensions/weather` — Go SDK tool example +- `examples/extensions/guard` — Go SDK intercept example +- `examples/extensions/todo` — interactive panel example - `docs/extensions.md` — full protocol reference diff --git a/examples/extensions/scratchpad/extension.json b/examples/extensions/scratchpad/extension.json index e86b074..9f8bea2 100644 --- a/examples/extensions/scratchpad/extension.json +++ b/examples/extensions/scratchpad/extension.json @@ -1,8 +1,8 @@ { "name": "scratchpad", "version": "1.0.0", - "exec": "tsx", - "args": ["index.ts"], + "exec": "npx", + "args": ["-y", "tsx", "./index.ts"], "language": "typescript", "description": "scratchpad notes (commands) + read_notes (tool) — written in plain .ts via tsx", "enabled": true diff --git a/examples/extensions/todo/README.md b/examples/extensions/todo/README.md new file mode 100644 index 0000000..35129e9 --- /dev/null +++ b/examples/extensions/todo/README.md @@ -0,0 +1,84 @@ +# todo — example zot extension (Go, interactive panel) + +Demonstrates the interactive panel API plus a companion tool the model +can call. The slash command opens a persistent todo panel; the tool lets +zot read and update the same todo list. + +## Requirements + +Go 1.22+. + +## Install + +From this directory: + +```bash +zot ext install . +``` + +The example is configured to run directly from source: + +```json +{ + "exec": "go", + "args": ["run", "."] +} +``` + +That avoids architecture-specific binaries when sharing the example, as +long as Go is installed. + +## Optional local build + +```bash +cd examples/extensions/todo +go build -o todo-panel . +``` + +If you do build it, change `extension.json` back to: + +```json +{ + "exec": "./todo-panel" +} +``` + +## Use + +In zot: + +- `/todo` opens the panel + +## Features + +- `/todo` opens the panel +- panel keys: + - up/down - move + - a - add with typed text + - e - edit selected todo + - x - toggle done + - d - delete + - r - redraw + - esc - close panel +- persistent storage in the extension data directory as `todos.json` +- LLM tool: `todo_manage` + - list + - add + - complete + - edit + - remove + +## Natural-language examples + +- Create a new entry named "Call Georg" on my to-do list. +- Complete task "Call Georg". +- Edit task "Call Georg" to "Call Georg tomorrow". +- Remove task "Call Georg". + +## See also + +- `examples/extensions/hello` — Go SDK slash commands +- `examples/extensions/weather` — Go SDK tool example +- `examples/extensions/guard` — Go SDK intercept example +- `examples/extensions/scratchpad` — TypeScript commands + tool +- `docs/extensions.md` — full protocol reference diff --git a/examples/extensions/todo/extension.json b/examples/extensions/todo/extension.json new file mode 100644 index 0000000..0dbe613 --- /dev/null +++ b/examples/extensions/todo/extension.json @@ -0,0 +1,9 @@ +{ + "name": "todo-panel", + "version": "0.3.0", + "exec": "go", + "args": ["run", "."], + "language": "go", + "description": "persistent interactive todo panel", + "enabled": true +} diff --git a/examples/extensions/todo/go.mod b/examples/extensions/todo/go.mod new file mode 100644 index 0000000..36daf0e --- /dev/null +++ b/examples/extensions/todo/go.mod @@ -0,0 +1,7 @@ +module zot-todo-extension + +go 1.22 + +require github.com/patriceckhart/zot v0.0.0 + +replace github.com/patriceckhart/zot => /Users/pat/Developer/zot diff --git a/examples/extensions/todo/main.go b/examples/extensions/todo/main.go new file mode 100644 index 0000000..ee82c9a --- /dev/null +++ b/examples/extensions/todo/main.go @@ -0,0 +1,336 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/patriceckhart/zot/pkg/zotext" +) + +type todoItem struct { + Text string `json:"text"` + Done bool `json:"done"` +} + +type store struct { + Items []todoItem `json:"items"` + Cursor int `json:"cursor"` +} + +type toolArgs struct { + Action string `json:"action"` + Title string `json:"title"` + NewTitle string `json:"new_title"` +} + +type app struct { + ext *zotext.Extension + mu sync.Mutex + path string + st store + mode string + edit string +} + +const panelID = "todos-main" + +func main() { + ext := zotext.New("todo-panel", "0.3.0") + a := &app{ext: ext} + ext.Command("todo", "open a persistent todo panel", func(args string) zotext.Response { + if err := a.ensureLoaded(); err != nil { + return zotext.Errorf("load todos: %v", err) + } + a.mu.Lock() + defer a.mu.Unlock() + return zotext.OpenPanel(panelID, a.title(), a.renderLines(), a.footer()) + }) + schema, _ := json.Marshal(map[string]any{ + "type": "object", + "properties": map[string]any{ + "action": map[string]any{"type": "string", "enum": []string{"list", "add", "complete", "edit", "remove"}}, + "title": map[string]any{"type": "string"}, + "new_title": map[string]any{"type": "string"}, + }, + "required": []string{"action"}, + }) + ext.Tool("todo_manage", "List, add, complete, edit, or remove todos by title.", schema, a.handleTool) + ext.OnPanelKey(panelID, a.handleKey, nil) + if err := ext.Run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func (a *app) ensureLoaded() error { + a.mu.Lock() + defer a.mu.Unlock() + if a.path != "" { + return nil + } + dir := a.ext.Host().DataDir + if dir == "" { + dir = a.ext.Host().ExtensionDir + } + if dir == "" { + return fmt.Errorf("host did not provide extension_dir/data_dir") + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + a.path = filepath.Join(dir, "todos.json") + b, err := os.ReadFile(a.path) + if err != nil { + if os.IsNotExist(err) { + a.st = store{Items: []todoItem{{Text: "Build something fun", Done: false}}} + return a.saveLocked() + } + return err + } + if len(b) == 0 { + a.st = store{} + return nil + } + if err := json.Unmarshal(b, &a.st); err != nil { + return err + } + a.clampLocked() + return nil +} + +func (a *app) saveLocked() error { + a.clampLocked() + b, err := json.MarshalIndent(a.st, "", " ") + if err != nil { + return err + } + return os.WriteFile(a.path, b, 0o644) +} + +func (a *app) clampLocked() { + if len(a.st.Items) == 0 { + a.st.Cursor = 0 + return + } + if a.st.Cursor < 0 { + a.st.Cursor = 0 + } + if a.st.Cursor >= len(a.st.Items) { + a.st.Cursor = len(a.st.Items) - 1 + } +} + +func (a *app) title() string { + done := 0 + for _, it := range a.st.Items { + if it.Done { + done++ + } + } + return fmt.Sprintf("Todos (%d/%d done)", done, len(a.st.Items)) +} + +func (a *app) renderLines() []string { + if a.mode == "add" || a.mode == "edit" { + label := "Add todo" + if a.mode == "edit" { + label = "Edit todo" + } + return []string{ + " " + label, + "", + " " + a.edit + "▌", + "", + " Enter saves, Esc cancels", + } + } + lines := []string{} + if len(a.st.Items) == 0 { + return []string{" No todos yet.", "", " Press a to add one."} + } + for i, it := range a.st.Items { + cursor := " " + if i == a.st.Cursor { + cursor = "› " + } + box := "□" + if it.Done { + box = "✓" + } + text := it.Text + if it.Done { + text += " (done)" + } + lines = append(lines, cursor+box+" "+text) + } + return lines +} + +func (a *app) footer() string { + if a.mode == "add" || a.mode == "edit" { + return "type text - enter save - esc cancel - backspace delete" + } + return "↑/↓ navigate - x toggle - a add - e edit - d delete - r refresh - esc close" +} + +func (a *app) rerenderLocked() { + a.ext.RenderPanel(panelID, a.title(), a.renderLines(), a.footer()) +} + +func (a *app) handleKey(key, text string) { + if err := a.ensureLoaded(); err != nil { + a.ext.Notify("error", fmt.Sprintf("todo: %v", err)) + return + } + a.mu.Lock() + defer a.mu.Unlock() + if a.mode == "add" || a.mode == "edit" { + a.handleEditModeLocked(key, text) + return + } + switch key { + case "up": + a.st.Cursor-- + case "down": + a.st.Cursor++ + case "rune": + switch strings.ToLower(text) { + case "x": + if len(a.st.Items) > 0 { + a.st.Items[a.st.Cursor].Done = !a.st.Items[a.st.Cursor].Done + } + case "d": + if len(a.st.Items) > 0 { + a.st.Items = append(a.st.Items[:a.st.Cursor], a.st.Items[a.st.Cursor+1:]...) + } + case "a": + a.mode = "add" + a.edit = "" + case "e": + if len(a.st.Items) > 0 { + a.mode = "edit" + a.edit = a.st.Items[a.st.Cursor].Text + } + case "r": + } + } + a.clampLocked() + if err := a.saveLocked(); err != nil { + a.ext.Notify("error", fmt.Sprintf("save todos: %v", err)) + } + a.rerenderLocked() +} + +func (a *app) handleEditModeLocked(key, text string) { + switch key { + case "enter": + trimmed := strings.TrimSpace(a.edit) + if trimmed != "" { + if a.mode == "add" { + a.st.Items = append(a.st.Items, todoItem{Text: trimmed}) + a.st.Cursor = len(a.st.Items) - 1 + } else if a.mode == "edit" && len(a.st.Items) > 0 { + a.st.Items[a.st.Cursor].Text = trimmed + } + } + a.mode = "" + a.edit = "" + case "backspace": + if len(a.edit) > 0 { + r := []rune(a.edit) + a.edit = string(r[:len(r)-1]) + } + case "esc": + a.mode = "" + a.edit = "" + case "rune": + if text != "" { + a.edit += text + } + } + if err := a.saveLocked(); err != nil { + a.ext.Notify("error", fmt.Sprintf("save todos: %v", err)) + } + a.rerenderLocked() +} + +func (a *app) handleTool(args json.RawMessage) zotext.ToolResult { + if err := a.ensureLoaded(); err != nil { + return zotext.TextErrorResult("load todos: " + err.Error()) + } + var in toolArgs + if err := json.Unmarshal(args, &in); err != nil { + return zotext.TextErrorResult("invalid args: " + err.Error()) + } + a.mu.Lock() + defer a.mu.Unlock() + switch in.Action { + case "list": + if len(a.st.Items) == 0 { + return zotext.TextResult("No todos.") + } + var lines []string + for _, it := range a.st.Items { + mark := "[ ]" + if it.Done { + mark = "[x]" + } + lines = append(lines, mark+" "+it.Text) + } + return zotext.TextResult(strings.Join(lines, "\n")) + case "add": + if strings.TrimSpace(in.Title) == "" { + return zotext.TextErrorResult("title is required for add") + } + a.st.Items = append(a.st.Items, todoItem{Text: strings.TrimSpace(in.Title)}) + a.st.Cursor = len(a.st.Items) - 1 + case "complete": + idx := a.findByTitleLocked(in.Title) + if idx < 0 { + return zotext.TextErrorResult("todo not found: " + in.Title) + } + a.st.Items[idx].Done = true + a.st.Cursor = idx + case "edit": + idx := a.findByTitleLocked(in.Title) + if idx < 0 { + return zotext.TextErrorResult("todo not found: " + in.Title) + } + if strings.TrimSpace(in.NewTitle) == "" { + return zotext.TextErrorResult("new_title is required for edit") + } + a.st.Items[idx].Text = strings.TrimSpace(in.NewTitle) + a.st.Cursor = idx + case "remove": + idx := a.findByTitleLocked(in.Title) + if idx < 0 { + return zotext.TextErrorResult("todo not found: " + in.Title) + } + a.st.Items = append(a.st.Items[:idx], a.st.Items[idx+1:]...) + case "": + return zotext.TextErrorResult("action is required") + default: + return zotext.TextErrorResult("unsupported action: " + in.Action) + } + if err := a.saveLocked(); err != nil { + return zotext.TextErrorResult("save todos: " + err.Error()) + } + if a.mode == "" { + a.rerenderLocked() + } + return zotext.TextResult("ok") +} + +func (a *app) findByTitleLocked(title string) int { + needle := strings.TrimSpace(strings.ToLower(title)) + for i, it := range a.st.Items { + if strings.TrimSpace(strings.ToLower(it.Text)) == needle { + return i + } + } + return -1 +} diff --git a/examples/extensions/todo/zot-todo-extension b/examples/extensions/todo/zot-todo-extension new file mode 100755 index 0000000..1ed8973 Binary files /dev/null and b/examples/extensions/todo/zot-todo-extension differ diff --git a/internal/agent/args.go b/internal/agent/args.go index d1b4f54..ac252e2 100644 --- a/internal/agent/args.go +++ b/internal/agent/args.go @@ -248,6 +248,9 @@ usage: zot -p "prompt" print final text, exit zot --json "prompt" newline-delimited json events, exit zot rpc json-rpc loop on stdin/stdout (see docs/rpc.md) + zot ext help extension manager help + zot ext list list installed extensions + zot ext install install an extension into $ZOT_HOME/extensions/ zot telegram-bot setup configure a telegram bot (from BotFather) zot telegram-bot run foreground bridge (ctrl+c to stop) zot telegram-bot start background bridge (detached) @@ -297,5 +300,11 @@ flags: --list-models print known models and exit -h, --help this message -v, --version version info + +extensions: + zot ext install ./path/to/ext install a local extension + zot ext install https://...git clone + install from git + zot --ext ./path/to/ext load an extension for this run only + zot ext help show all extension subcommands `, version) }