diff --git a/cmd/late/main.go b/cmd/late/main.go index 90d9af6..37557ce 100644 --- a/cmd/late/main.go +++ b/cmd/late/main.go @@ -20,14 +20,51 @@ import ( "late/internal/client" appconfig "late/internal/config" "late/internal/mcp" + "late/internal/pathutil" + "late/internal/plugin" "late/internal/session" "late/internal/tool" "late/internal/tui" tea "charm.land/bubbletea/v2" "charm.land/glamour/v2" + "encoding/json" ) +// pluginInlineTool adapts a plugin.InlineTool (defined in internal/plugin/tools.go) +// into a common.Tool so the CLI's session registry can dispatch invocations to +// plugin-declared runners. It exists because upstream repurposed +// tool.ScriptTool for skill dispatch only; for arbitrary plugin-defined tools, +// we wrap them here. +// +// The wrapper synthesizes a client.ToolCall from the executor's (args +// json.RawMessage) payload by stitching in the registered name — args is +// strictly the JSON parameters (e.g. {"path": "/foo"}) the model emitted; +// the function name is provided by the registry at dispatch time, so we +// surface the wrapped name rather than re-parse it from args. +type pluginInlineTool struct { + name string + description string + parameters json.RawMessage + runner func(ctx context.Context, call client.ToolCall) (string, error) +} + +func (p pluginInlineTool) Name() string { return p.name } +func (p pluginInlineTool) Description() string { return p.description } +func (p pluginInlineTool) Parameters() json.RawMessage { return p.parameters } +func (p pluginInlineTool) RequiresConfirmation(args json.RawMessage) bool { + return false +} +func (p pluginInlineTool) Execute(ctx context.Context, args json.RawMessage) (string, error) { + return p.runner(ctx, client.ToolCall{ + Type: "function", + Function: client.FunctionCall{Name: p.name, Arguments: string(args)}, + }) +} +func (p pluginInlineTool) CallString(args json.RawMessage) string { + return fmt.Sprintf("Calling plugin tool %q...", p.name) +} + func main() { // Parse flags helpReq := flag.Bool("help", false, "Show help") @@ -46,16 +83,24 @@ func main() { enableImagesReq := flag.Bool("enable-images", false, "Force enable support for image attachments for unsupported servers.") continueReq := flag.Bool("continue", false, "Load and start the latest session") showCWDReq := flag.Bool("show-cwd", true, "Show current working directory in status bar") + themeReq := flag.String("theme", "", "Plugin theme id (':'); falls back to $LATE_THEME") flag.Usage = func() { fmt.Fprintf(os.Stderr, "Usage of late:\n") fmt.Fprintf(os.Stderr, " late [flags]\n") fmt.Fprintf(os.Stderr, " late session [args]\n") + fmt.Fprintf(os.Stderr, " late plugin [args]\n") fmt.Fprintf(os.Stderr, " late worktree [args]\n\n") fmt.Fprintf(os.Stderr, "Commands:\n") fmt.Fprintf(os.Stderr, " session list [-v] List all saved sessions (use -v for verbose/detailed view)\n") fmt.Fprintf(os.Stderr, " session load Load a session by ID\n") fmt.Fprintf(os.Stderr, " session delete Delete a session by ID\n") + fmt.Fprintf(os.Stderr, " plugin list, ls List installed plugins\n") + fmt.Fprintf(os.Stderr, " plugin install Install a plugin from npm/git/local\n") + fmt.Fprintf(os.Stderr, " plugin remove Remove a plugin\n") + fmt.Fprintf(os.Stderr, " plugin link Link a local plugin directory\n") + fmt.Fprintf(os.Stderr, " plugin enable Enable a plugin\n") + fmt.Fprintf(os.Stderr, " plugin disable Disable a plugin\n") fmt.Fprintf(os.Stderr, " worktree list List all worktrees\n") fmt.Fprintf(os.Stderr, " worktree create [branch] Create a new worktree\n") fmt.Fprintf(os.Stderr, " worktree remove Remove a worktree\n") @@ -106,6 +151,29 @@ func main() { } } + // Plugin command handler — dispatches before TUI startup + var pluginManager *plugin.PluginManager + cwd, _ := os.Getwd() + projectPluginsDir := filepath.Join(cwd, common.LateProjectPluginsDir()) + if flag.NArg() > 0 && flag.Arg(0) == "plugin" { + pluginsDir, err := common.LatePluginsDir() + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to get plugins directory: %v\n", err) + } else { + pm := plugin.NewPluginManager(pluginsDir) + if _, err := os.Stat(projectPluginsDir); err == nil { + pm.SetProjectDir(projectPluginsDir) + } + if err := pm.Discover(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to discover plugins: %v\n", err) + } + pluginManager = pm + if plugin.HandlePluginCommand(pm, flag.Args()[1:]) { + return + } + } + } + // Determine system prompt // Priority: --system-prompt-file > --system-prompt > LATE_SYSTEM_PROMPT env var var systemPrompt string @@ -196,6 +264,58 @@ func main() { } } + // Plugin discovery and surface registration + if pluginManager == nil { + pluginsDir, err := common.LatePluginsDir() + if err == nil { + pm := plugin.NewPluginManager(pluginsDir) + // Set project-local dir if it exists + if _, statErr := os.Stat(projectPluginsDir); statErr == nil { + pm.SetProjectDir(projectPluginsDir) + } + if err := pm.Discover(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to discover plugins: %v\n", err) + } else if pm.Count() > 0 { + fmt.Printf("Loading %d plugin(s)...\n", pm.Count()) + pluginManager = pm + + // Register plugin skills into the skills directory + skillsDir, skillsErr := pathutil.LateSkillsDir() + if skillsErr == nil { + if err := pm.RegisterPluginSkills(skillsDir); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to register plugin skills: %v\n", err) + } + } + + // Connect plugin MCP servers + pluginMCP := pm.BuildMCPConfigMap() + if len(pluginMCP) > 0 && config == nil { + config = &mcp.MCPConfig{McpServers: make(map[string]mcp.MCPServer)} + } + if len(pluginMCP) > 0 && config != nil { + fmt.Println("Connecting to plugin MCP servers...") + for name, srv := range pluginMCP { + config.McpServers[name] = mcp.MCPServer{ + Command: srv.Command, + Args: srv.Args, + Env: srv.Env, + URL: srv.URL, + TransportType: srv.TransportType, + Disabled: srv.Disabled, + Dir: srv.Dir, + } + } + if err := mcpClient.ConnectFromConfig(context.Background(), config); err != nil { + fmt.Fprintf(os.Stderr, "Warning: Failed to connect to plugin MCP servers: %v\n", err) + } + } + } else if pm.HasProjectDir() { + // No global plugins but we have project-local ones — still need the manager + pluginManager = pm + } + } + } + // Load App configuration appConfig, err := appconfig.LoadConfig() if err != nil { @@ -275,9 +395,54 @@ func main() { sess.Registry.Register(t) } + // Register inline plugin tools (declared in the manifest's `late.tools` + // field). Each inline tool is run as a local script via runHook and + // hooks into the same ToolMiddleware chain as MCP-backed tools so + // onToolCall hooks, confirmations, and tool-result reporting all work + // uniformly for plugin-declared tools. + if pluginManager != nil { + for _, t := range pluginManager.GetInlineTools() { + // Apply enabledTools both by namespaced name and by bare name. + enabled := true + if v, ok := enabledTools[t.Name]; ok { + enabled = v + } else if idx := strings.LastIndex(t.Name, ":"); idx >= 0 { + if v, ok := enabledTools[t.Name[idx+1:]]; ok { + enabled = v + } + } + if !enabled { + continue + } + sess.Registry.Register(pluginInlineTool{ + name: t.Name, + description: t.Description, + parameters: t.Parameters, + runner: t.Runner, + }) + } + } + + // Resolve theme: --theme flag > $LATE_THEME > bundled base. + themeID := *themeReq + if themeID == "" { + themeID = os.Getenv("LATE_THEME") + } + themeBytes := tui.LateTheme + if themeID != "" && pluginManager != nil { + if info, err := pluginManager.GetTheme(themeID); err == nil && info != nil { + if merged, mErr := tui.ResolveRenderTheme(info.ID, info.Glamour, info.Palette); mErr == nil { + themeBytes = merged + fmt.Fprintf(os.Stderr, "Applied plugin theme: %s\n", info.ID) + } + } else if err != nil { + fmt.Fprintf(os.Stderr, "Theme lookup failed for %q: %v\n", themeID, err) + } + } + // Initialize common renderer renderer, _ := glamour.NewTermRenderer( - glamour.WithStylesFromJSONBytes(tui.LateTheme), + glamour.WithStylesFromJSONBytes(themeBytes), glamour.WithWordWrap(80), glamour.WithPreservedNewLines(), ) @@ -290,6 +455,42 @@ func main() { model.ModelName = resolvedOpenAIConfig.Model model.ShowCWD = *showCWDReq + // Register plugin slash commands + message hook + theme catalog + command + // handler into the TUI so plugin commands actually fire when the user + // presses Enter. + if pluginManager != nil && pluginManager.Count() > 0 { + model.SetPluginCommands(pluginManager.PluginCommands()) + model.MessageHook = func(text string) string { + return pluginManager.HookedMessage(context.Background(), text) + } + model.CommandHandler = pluginManager.HandleCommand + model.SelectedTheme = themeID + + // Map plugin.ThemeInfo to tui.ThemeEntry so the /themes picker and + // inline `/themes ` can resolve plugin themes at runtime. + pluginThemes := pluginManager.AllThemes() + if len(pluginThemes) > 0 { + entries := make([]tui.ThemeEntry, len(pluginThemes)) + for i, info := range pluginThemes { + entries[i] = tui.ThemeEntry{ + ID: info.ID, + PluginName: info.PluginName, + ThemeName: info.ThemeName, + Glamour: info.Glamour, + Palette: info.Palette, + } + } + model.SetThemes(entries) + } + } + + // Fire OnSessionStart hooks for every enabled plugin in parallel. This + // runs once, before the orchestrator is dispatched, so plugin scripts + // can warm caches, register tools, or print startup announcements. + if pluginManager != nil { + pluginManager.CallOnSessionStartHooks() + } + // Detect if subagents use a different model/backend if resolvedSubagentConfig.BaseURL != resolvedOpenAIConfig.BaseURL || resolvedSubagentConfig.APIKey != resolvedOpenAIConfig.APIKey || @@ -299,6 +500,32 @@ func main() { p := tea.NewProgram(model) + // Start plugin filesystem watcher (if plugin manager exists) + if pluginManager != nil { + watcher := plugin.NewPollingWatcher(pluginManager) + // Also watch project-local dir if configured + if pluginManager.HasProjectDir() { + watcher.AddWatchDir(pluginManager.ProjectDir()) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go watcher.Start(ctx, func() { + cmds := pluginManager.PluginCommands() + pluginThemes := pluginManager.AllThemes() + entries := make([]tui.ThemeEntry, len(pluginThemes)) + for i, info := range pluginThemes { + entries[i] = tui.ThemeEntry{ + ID: info.ID, + PluginName: info.PluginName, + ThemeName: info.ThemeName, + Glamour: info.Glamour, + Palette: info.Palette, + } + } + p.Send(tui.PluginChangeMsg{Commands: cmds, Themes: entries}) + }) + } + // Wire TUI integration go func() { // Set messenger first @@ -311,10 +538,13 @@ func main() { } rootAgent.SetContext(ctx) - // Set middlewares (e.g. TUI confirmation) - rootAgent.SetMiddlewares([]common.ToolMiddleware{ - tui.TUIConfirmMiddleware(p, sess.Registry), - }) + // Set middlewares (e.g. TUI confirmation, plugin onToolCall hooks) + mws := []common.ToolMiddleware{tui.TUIConfirmMiddleware(p, sess.Registry)} + if pluginManager != nil { + mws = append(mws, pluginManager.BuildHookMiddlewares()...) + mws = append(mws, pluginManager.BuildToolResultMiddlewares()...) + } + rootAgent.SetMiddlewares(mws) // Start forwarding events from the root agent to the TUI ForwardOrchestratorEvents(p, rootAgent) diff --git a/docs/plugin-example.md b/docs/plugin-example.md new file mode 100644 index 0000000..b9619c7 --- /dev/null +++ b/docs/plugin-example.md @@ -0,0 +1,427 @@ +# Worked Example: `late-codestyle` + +This page is an end-to-end example of a plugin that exercises **every +extension surface** Late supports today: skills, MCP servers, slash +commands (both shapes), themes, lifecycle hooks (mutation + veto), +inline tools, and project-local install. + +The example is `late-codestyle`, a small developer-tooling plugin that +formats, lints, and looks up CLI syntax. It is intentionally tiny so +the contracts stay visible — every script is short. + +> See [plugin-sdk.md](./plugin-sdk.md) for the canonical field-by-field +> reference. This page is the "show me the whole thing" companion. + +--- + +## Directory Layout + +``` +late-codestyle/ +├── package.json +├── skills/ +│ └── codestyle.md +├── themes/ +│ └── ocean.json +├── hooks/ +│ ├── veto.sh # onToolCall — mutate or block dangerous calls +│ ├── emoji.sh # onInput — sequential text transform +│ ├── welcome.sh # onSessionStart +│ └── log-result.sh # onToolResult — observation only +└── scripts/ + ├── lint.sh # /lint handler + ├── lint-server.sh # MCP stdio server (mock for demo) + └── lookup.sh # inline `lookup` tool + /lookup command fallback +``` + +Run `chmod +x hooks/*.sh scripts/*.sh` after creating these files. + +--- + +## The Manifest (`package.json`) + +Every `late.*` field declared, with realistic values. This is the +complete `late` object — the surrounding `package.json` is the usual +`name`/`version`/`description` triple and otherwise vanilla. + +```json +{ + "name": "late-codestyle", + "version": "0.1.0", + "description": "Format, lint, lookup, and ocean theme for Late.", + "late": { + "skills": ["skills/"], + + "mcp": { + "servers": { + "live-lint": { + "command": "bash", + "args": ["scripts/lint-server.sh"] + } + } + }, + + "commands": [ + "/format", + { "name": "/lint", "handler": "scripts/lint.sh" } + ], + + "themes": ["themes/ocean.json"], + + "hooks": { + "onToolCall": ["hooks/veto.sh"], + "onInput": ["hooks/emoji.sh"], + "onSessionStart": ["hooks/welcome.sh"], + "onToolResult": ["hooks/log-result.sh"] + }, + + "tools": [ + { + "name": "lookup", + "description": "Look up CLI command syntax.", + "script": "scripts/lookup.sh", + "parameters": { + "type": "object", + "properties": { + "query": { "type": "string", "description": "The command or flag to look up." } + }, + "required": ["query"] + } + } + ] + } +} +``` + +Notes on the choices below: + +- **Commands**: declared in **both** shapes on purpose. `/format` is a + bare string — Late submits the input as a plain prompt and the agent + uses the plugin's skills/tools to handle it (legacy path). `/lint` + carries an explicit `handler` script — Late runs the script and shows + its stdout as a toast, no orchestration needed. +- **Hooks**: each one demonstrates a different part of the hook + contract (mutate/veto, sequential transform, lifecycle, observation). +- **Tools**: declared inline so the model can call `lookup` directly, + no MCP sandbox needed. Names are namespaced to `late-codestyle:lookup`. + +--- + +## Skills — `skills/codestyle.md` + +Skills are injected into the system prompt as instructions. The +`scripts:` list is informational — agent scripts called from skills +become `ScriptTool`s. + +```markdown +--- +name: codestyle +description: When the user asks about code formatting, lint summaries, or CLI syntax, prefer the codestyle plugin's tools and scripts over hand-rolled shell. +scripts: + - scripts/lookup.sh + - scripts/lint.sh +--- + +## Instructions + +- When the user asks about formatting, prefer invoking `format` via the + `bash` tool with the project's formatter, but first describe the change + to the user. +- When the user asks "what flags does X have?" or requests a CLI lookup, + prefer the `late-codestyle:lookup` tool so the result lands as a real + tool call trace. +- The plugin's `onToolCall` hook may redact or reject your proposed + command — if a veto comes back as an error, surface it to the user + rather than retrying with a workaround. +``` + +--- + +## Slash Commands + +### `/format` — legacy (plain-prompt fall-through) + +`/format` is declared as a bare string. When the user presses Enter, +Late submits `/format ...` as a regular user prompt and the agent +leans on the `codestyle` skill + generic `bash` tool to actually run +the formatter. There is no `handler` script. + +### `/lint` — explicit handler + +`/lint ` runs `scripts/lint.sh` with the trailing args (a JSON +string array) on stdin. Late surfaces the script's stdout as a toast +or surfaces errors as an error toast. + +#### `scripts/lint.sh` + +```bash +#!/usr/bin/env bash +# /lint handler — receives args JSON on stdin, e.g. ["README.md"] +set -e +args=$(cat) +file=$(printf '%s' "$args" | sed -n 's/.*"\([^"]*\)".*/\1/p' | head -1) + +if [ -z "$file" ]; then + echo "usage: /lint " + exit 2 +fi + +echo "linting $file" +if [ ! -f "$file" ]; then + echo "error: file not found: $file" + exit 1 +fi +wc -l "$file" +``` + +--- + +## MCP Server + +Servers must speak Model Context Protocol over stdio. The example is a +mock that always replies with a single tool — replace it with a real +MCP server (Python `@modelcontextprotocol/sdk`, Node +`@modelcontextprotocol/sdk`, etc.) for production. + +#### `scripts/lint-server.sh` + +```bash +#!/usr/bin/env bash +# Minimal mock MCP stdio server — one tool: live_lint +while IFS= read -r _; do + echo '{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"live_lint","description":"Lint the file at path.","inputSchema":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}}]}}' +done +``` + +The server is registered in the agent as the namespaced tool +`late-codestyle:live-lint`. The MCP `env` field supports `${VAR}` +expansion at launch time — wire secrets in via `env`, not args. + +--- + +## Inline Tool — `lookup` + +`late.tools[*]` registers a script-backed tool **without** an MCP +wrapper. The model calls `late-codestyle:lookup` and the script +receives the JSON-encoded arguments on stdin. + +#### `scripts/lookup.sh` + +```bash +#!/usr/bin/env bash +# Tool stdin is the arguments JSON, e.g. {"query":"grep -E"} +set -e +payload=$(cat) +query=$(printf '%s' "$payload" \ + | sed -n 's/.*"query"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') + +if [ -z "$query" ]; then + echo "error: lookup requires a 'query' argument" + exit 1 +fi + +echo "lookup results for: $query" +echo "- ${query} --help → standard help text applies" +echo "- ${query} -V → version output" +echo "- ${query}(1) → man page (if installed)" +``` + +The agent sees this as a normal tool. All the usual middleware +applies — `onToolCall` can still veto it, `enabledTools` still gates +it, and user confirmation still prompts the user. + +--- + +## Themes + +Themes are [Glamour](https://github.com/charmbracelet/glamour) JSON +files. Plug them in by listing their path under `late.themes`. The +TUI's `/themes` command exposes them in the picker. + +#### `themes/ocean.json` + +```json +{ + "name": "ocean", + "palette": { + "bg": "#001f33", + "fg": "#bce0ff", + "accent": "#5fc8ff" + }, + "glamour": { + "document": { "color": "#bce0ff" }, + "heading": { "color": "#5fc8ff", "bold": true }, + "link": { "color": "#5fc8ff", "underline": true }, + "strong": { "bold": true, "color": "#ffffff" }, + "bullet": { "foreground": "#5fc8ff" }, + "code": { "color": "#cdf7ff", "background": "#002b48" }, + "code_block":{ "color": "#cdf7ff", "background": "#002b48" } + } +} +``` + +Apply from the TUI: `/themes ocean` (by bare name) or +`/themes late-codestyle:ocean` (by namespaced ID). + +--- + +## Hooks + +Hooks are the must-tiny part of every plugin — they show whether you +understand the contract. Each of the four hooks below tests a +different part of it. + +### Hook contract recap + +| Hook | Read from stdin | Write to stdout | +| --------------- | ----------------------------------------------------- | -------------------------------------------------------- | +| `onSessionStart` | empty JSON | ignored (fire-and-forget) | +| `onTurnStart` | empty JSON | ignored | +| `onTurnEnd` | empty JSON | ignored | +| `onToolCall` | `{ "tool", "arguments", "timestamp" }` | JSON → mutate `arguments` · literal `"blocked"` → veto the call · empty/non-JSON → pass-through | +| `onToolResult` | `{ "tool", "result" }` | ignored (observation only) | +| `onMessageSend` | the current user message | replacement text (sequential) | +| `onInput` | the current user message (or previous hook's stdout) | replacement text (sequential) | + +### `hooks/veto.sh` — `onToolCall` (mutate OR veto) + +```bash +#!/usr/bin/env bash +# Read the ToolCall payload and either: +# 1. Block dangerous commands by returning the literal string "blocked". +# 2. Redact arguments by returning replacement JSON. +# 3. Pass through unchanged by returning empty. +set -e +payload=$(cat) + +# Block any bash call that asks for `rm -rf` of root or $HOME. +if printf '%s' "$payload" | grep -q 'rm[[:space:]]*-r[fR]\?[[:space:]]\+/\(\|[[:space:]]\)\|$HOME'; then + echo "blocked" + exit 0 +fi + +# Otherwise, redact long filesystem paths in bash arguments. +if printf '%s' "$payload" | grep -q '"tool"[[:space:]]*:[[:space:]]*"bash"'; then + redacted=$(printf '%s' "$payload" \ + | sed 's#/Users/[a-z0-9_]\+/#g' \ + | sed 's#/home/[a-z0-9_]\+/#g') + if [ "$redacted" != "$payload" ]; then + printf '%s' "$redacted" + exit 0 + fi +fi + +# Empty stdout → pass-through. +``` + +Returning the literal string `"blocked"` aborts the chain (next() +is skipped, the call returns an error to the agent). Returning any +other JSON-valued stdout replaces `call.Function.Arguments` and +continues the chain. + +### `hooks/emoji.sh` — `onInput` (sequential transform) + +```bash +#!/usr/bin/env bash +# Replace 🙂 → "happy face" in the user's pending message. +# Stdin = the current input (or previous hook's output, since these run +# in order across plugins). +sed 's/🙂/happy face/g' +``` + +Because it's the only `onInput` hook this week, it always sees the +original message. If another plugin registered an `onInput` first, +its stdout would be **this** script's stdin. + +### `hooks/welcome.sh` — `onSessionStart` (lifecycle) + +```bash +#!/usr/bin/env bash +# Fires once, when Late boots. Stdin is empty. +echo "[late-codestyle] loaded — /format, /lint, lookup, ocean theme ready" >&2 +``` + +Errors and stderr are forwarded; the user sees the message in the +TUI. + +### `hooks/log-result.sh` — `onToolResult` (observation) + +```bash +#!/usr/bin/env bash +# Best-effort one-liner per tool result, logged to stderr. +cat \ + | grep -o '"tool"[[:space:]]*:[[:space:]]*"[^"]*"' \ + | head -1 \ + | sed 's/^/[late-codestyle][result] /' >&2 +``` + +Observation only — `onToolResult` cannot mutate the result the agent +sees; it is purely a logging/auditing hook. + +--- + +## Install & Test + +### Global dev install (recommended while iterating) + +```bash +# from inside the late-codestyle directory +chmod +x hooks/*.sh scripts/*.sh +late plugin link ./late-codestyle +late plugin list +``` + +You should see `late-codestyle 0.1.0 local ✓` in the output. + +### Project-local install (recommended for team repos) + +```bash +mkdir -p .late/plugins +chmod +x hooks/*.sh scripts/*.sh +late plugin link --project ./late-codestyle +``` + +Now commit `.late/plugins/late-codestyle` (it's a symlink; the real +files live in the repo at `./late-codestyle`) so the whole team gets +the same plugin. + +### Smoke tests in the TUI + +1. `/help` — confirm `/format` and `/lint` appear in the command list + alongside the built-ins. +2. `/lint README.md` — should produce a "linting README.md" toast with + `wc -l` output. +3. `/themes` — open the picker, navigate to `late-codestyle:ocean`, + press Enter to apply. +4. In the chat, ask *"look up the flags for `grep -E`"* — the agent + should call `late-codestyle:lookup`. +5. Ask the agent to *"run `bash` with `rm -rf /tmp/cache`"* — the + hook should let it through (no leading slash, no `$HOME`). Try + `rm -rf /` on a sandbox and watch the hook veto the call. + +If something doesn't work, `late plugin disable late-codestyle` flips +it off without removing the install. + +--- + +## Why each shape? + +A short rationale for the design choices that aren't obvious. + +| Choice | Why | +| --------------------------------------- | -------------------------------------------------------------------- | +| Two commands, two shapes | Demonstrates both wire-level dispatch modes. | +| `onToolCall` **returns** JSON | Demonstrates the gate-via-mutate contract — the most powerful hook. | +| `onInput` does not read the args | Shows the chain is sequential: any subsequent hook sees my stdout. | +| `onToolResult` only writes to stderr | Confirms there is no path back to the agent from this hook. | +| `late.tools[*]` instead of MCP for `lookup` | Demonstrates the simpler "no-server" path inline tools support. | +| `themes` with both palette and glamour | Shows the two parts of a plugin theme (semantic colors + style overrides). | +| One global install, one `--project` install | Both scopes are first-class; pick whichever matches the rollout. | + +--- + +> **Tip:** once you're done iterating, run +> `late plugin disable late-codestyle` to turn it off, or +> `late plugin remove late-codestyle` to uninstall. The filesystem +> watcher picks up source edits within about two seconds — no restart +> needed. diff --git a/docs/plugin-sdk.md b/docs/plugin-sdk.md new file mode 100644 index 0000000..5f8f0dc --- /dev/null +++ b/docs/plugin-sdk.md @@ -0,0 +1,647 @@ +# Late Plugin SDK Guide + +Late's plugin system lets you bundle extension surfaces — skills, slash commands, MCP servers, themes, and hooks — into a single installable unit. Plugins can be installed from npm, a Git repository, a local directory, or a marketplace catalog. + +--- + +## Table of Contents + +- [Architecture Overview](#architecture-overview) +- [Manifest Format](#manifest-format) +- [Extension Surfaces](#extension-surfaces) + - [Skills](#skills) + - [Slash Commands](#slash-commands) + - [MCP Servers](#mcp-servers) + - [Themes](#themes) + - [Hooks](#hooks) +- [Installation Methods](#installation-methods) + - [From npm](#from-npm) + - [From Git](#from-git) + - [From local path](#from-local-path-development) + - [Project-local (per-project)](#project-local-per-project) +- [CLI Commands](#cli-commands) +- [Development Workflow](#development-workflow) +- [Sample Plugin: `git-pull`](#sample-plugin-git-pull) + +--- + +## Architecture Overview + +A plugin is a directory with a `package.json` that contains a `"late"` field. The `"late"` field declares which extension surfaces the plugin provides. + +Plugins can be installed in two scopes: + +- **Global** — `~/.config/late/plugins/` (Linux) or `~/Library/Application Support/late/plugins/` (macOS). Available in every project. +- **Project-local** — `.late/plugins/` relative to your project root. Only available when Late is running from that project. Overrides a global plugin with the same name. + +At startup, Late discovers plugins from both locations, registers their surfaces, and makes them available in the TUI. Project-local plugins take priority over global ones with the same name. A background filesystem watcher polls for changes every 2 seconds so plugin installs and enables/disables are picked up without restarting. + +``` +~/.config/late/plugins/ # Global plugins (all projects) +├── @late/plugin-graph-rag/ # npm scoped plugin +│ ├── package.json +│ └── skills/ +├── my-git-plugin/ # local or git plugin +│ ├── package.json +│ └── skills/ +└── node_modules/ # npm-managed dependencies (ignored) + +.my-project/.late/plugins/ # Project-local plugins (this project only) +├── project-helper/ # overrides any global "project-helper" +│ ├── package.json +│ └── skills/ +└── node_modules/ +``` + +> **Override behavior:** If a plugin with the same name exists in both the global and project-local directories, the project-local version takes precedence. This lets you pin a specific version per project without affecting others. + +--- + +## Manifest Format + +Every plugin must have a `package.json` at its root. The `"late"` field is the plugin manifest. All sub-fields are optional. + +```json +{ + "name": "my-plugin", + "version": "1.0.0", + "description": "A short description of what the plugin does", + "late": { + "skills": ["skills/"], + "commands": ["/my-command"], + "mcp": { + "servers": { + "my-server": { + "command": "node", + "args": ["server.js"], + "env": { + "API_KEY": "${API_KEY}" + } + } + } + }, + "themes": ["themes/my-theme.json"], + "hooks": { + "onToolCall": ["hooks/log-tool.sh"], + "onSessionStart": ["hooks/on-start.sh"] + } + } +} +``` + +| Field | Type | Description | +| --- | --- | --- | +| `name` | `string` | **Required.** Plugin name (must match the directory name unless it's an npm scoped package). | +| `version` | `string` | Plugin version (semver). | +| `description` | `string` | Short description shown in `late plugin list`. | +| `late.skills` | `string[]` | Relative paths to [Skill](#skills) directories. | +| `late.commands` | `string[]` | Slash command names (with or without leading `/`). | +| `late.mcp.servers` | `object` | Map of MCP server names to [MCP Server](#mcp-servers) configs. | +| `late.themes` | `string[]` | Relative paths to Glamour theme JSON files. | +| `late.hooks` | `object` | Hook type → script path mappings. | + +--- + +## Extension Surfaces + +### Skills + +[Agent Skills](https://agentskills.io/) are reusable sets of instructions that Late's orchestrator injects into the system prompt. Skills are discovered automatically from skill directories. + +Plugin skills are symlinked into `~/.config/late/skills/` at startup so the existing skill loader discovers them. + +A skill directory contains a `SKILL.md` with YAML frontmatter: + +```markdown +--- +name: git-helpers +description: Helper instructions for git operations +scripts: + - pull.sh +--- + +## Instructions + +When the user asks about git operations, use the `pull.sh` script +to perform safe git pulls with conflict handling. +``` + +Each script in a skill becomes a `ScriptTool` that the agent can invoke. + +**Directory structure:** +``` +my-plugin/ +├── package.json +└── skills/ + ├── SKILL.md + └── pull.sh +``` + +### Slash Commands + +Plugin slash commands appear in the TUI's autocomplete dropdown when the user types `/`. They are also shown in the `/help` overlay and the status bar. + +Commands are listed in the manifest as strings. When the user types a plugin slash command and presses Enter, it is dispatched as a regular user prompt to the agent. The plugin's skills, MCP servers, and tools handle the actual execution. + +```json +{ + "late": { + "commands": ["/pull", "/fetch", "/status"] + } +} +``` + +The leading `/` is optional — Late normalizes it automatically. + +### MCP Servers + +Model Context Protocol servers provide tools that agents can call. Plugin MCP servers are merged into Late's MCP configuration at startup. + +Server names are automatically namespaced as `plugin-name:server-name` to prevent collisions. + +```json +{ + "late": { + "mcp": { + "servers": { + "git-server": { + "command": "node", + "args": ["./mcp/server.js"], + "env": { + "GIT_REPO_PATH": "${GIT_REPO_PATH}" + } + } + } + } + } +} +``` + +| Field | Type | Description | +| --- | --- | --- | +| `command` | `string` | Executable path or name. | +| `args` | `string[]` | Command-line arguments. | +| `env` | `object` | Environment variables (supports `${VAR}` expansion). | +| `url` | `string` | URL for SSE/HTTP transport. | +| `transportType` | `string` | `"stdio"` (default), `"sse"`, or `"streamable-http"`. | +| `disabled` | `bool` | If `true`, the server is not started automatically. | + +### Themes + +Themes are [Glamour](https://github.com/charmbracelet/glamour) style JSON files that customize the TUI's markdown rendering. Plugin themes are resolved to absolute paths at registration time. + +```json +{ + "late": { + "themes": ["themes/dark-glow.json"] + } +} +``` + +### Hooks + +Hooks are scripts that run at specific lifecycle events. Each hook type accepts an array of paths to executable scripts. + +```json +{ + "late": { + "hooks": { + "onToolCall": ["hooks/log-tool.sh"], + "onSessionStart": ["hooks/on-start.sh"], + "onMessageSend": ["hooks/on-send.sh"] + } + } +} +``` + +| Hook | When it fires | Script receives | +| --- | --- | --- | +| `onToolCall` | Before a tool is executed | Tool name and arguments via stdin (JSON) | +| `onSessionStart` | When a new session begins | Session metadata via stdin (JSON) | +| `onMessageSend` | When a user sends a message | The message content via stdin | + +--- + +## Installation Methods + +### From npm + +```bash +late plugin install @late/plugin-git-helper +``` + +Runs `npm install` in the plugins directory and symlinks the installed package. + +### From Git + +```bash +late plugin install https://github.com/user/late-plugin-git.git +late plugin install github:user/late-plugin-git +``` + +Clones the repository with `--depth 1`, removes the `.git` directory, and loads the plugin. + +### From local path (development) + +```bash +late plugin link ./my-plugin +# or +late plugin install ./my-plugin +``` + +Creates a symlink from `~/.config/late/plugins/` to your local directory. Any changes you make are available immediately (the watcher picks them up within 2 seconds). + +### Project-local (per-project) + +```bash +# Install into .late/plugins/ (create it first if it doesn't exist) +mkdir -p .late/plugins + +late plugin install --project ./my-plugin +late plugin install --project github:user/late-plugin +late plugin install --project @late/plugin-git-helper + +# Link a local directory into the project +late plugin link --project ./my-plugin + +# Remove from project +late plugin remove --project my-plugin +``` + +The `--project` flag (or `--local`) installs the plugin into `.late/plugins/` instead of the global directory. Project-local plugins are scoped to the current project and are only active when Late is launched from that project directory. + +This is useful for: +- **Team-shared configurations** — check `.late/` into version control so everyone gets the same plugins +- **Pinning plugin versions** — override a global plugin with a specific version for a project +- **Isolation** — keep experimental plugins from affecting other projects + +Project-local plugins override global plugins with the same name. If both `~/.config/late/plugins/my-plugin` and `.late/plugins/my-plugin` exist, the project-local one wins. + +--- + +## CLI Commands + +``` +late plugin list, ls List installed plugins +late plugin install [--project] Install a plugin (npm, git, or local path) +late plugin remove [--project] Remove a plugin (use --project for project-local) +late plugin link [--project] Symlink a local directory for development +late plugin update [name] Update all or a specific plugin +late plugin enable Enable a plugin +late plugin disable Disable a plugin +``` + +The `--project` (or `--local`) flag can be used with `install`, `link`, and `remove` +to operate on the project-local `.late/plugins/` directory instead of the global store. +When omitted, these commands default to the global directory. + +--- + +## Development Workflow + +1. **Create your plugin directory** with a `package.json`: + +```bash +mkdir my-plugin && cd my-plugin +``` + +2. **Write your manifest:** + +```json +{ + "name": "my-plugin", + "version": "0.1.0", + "description": "My first Late plugin", + "late": { + "commands": ["/hello"], + "skills": ["skills/"] + } +} +``` + +3. **Add a skill** (optional): + +```bash +mkdir -p skills +``` + +Create `skills/SKILL.md` with instructions for the agent. + +4. **Link it for development:** + +```bash +late plugin link ./my-plugin +``` + +5. **Verify it's loaded:** + +```bash +late plugin list +``` + +You should see your plugin listed. The slash commands appear in autocomplete and the status bar shows the plugin count. + +6. **Iterate:** Edit your plugin files. The background watcher picks up changes within ~2 seconds. Run `late plugin list` again or toggle the help overlay to see your updates. + +7. **Use project-local scope (optional):** For plugins that should only apply to the current project, create `.late/plugins/` and use the `--project` flag: + +```bash +mkdir -p .late/plugins +late plugin link --project ./my-plugin +``` + +Project-local plugins override global ones. This is great for team repos where you want to check in plugin configurations. + +8. **Package for distribution:** Publish to npm or push to a Git repository. + +--- + +## Sample Plugin: `git-pull` + +This sample plugin adds a `/pull` slash command and a `git-helpers` skill that teaches the agent how to perform safe git pulls. + +### Directory structure + +``` +git-pull/ +├── package.json +└── skills/ + ├── SKILL.md + └── safe-pull.sh +``` + +### `package.json` + +```json +{ + "name": "git-pull", + "version": "1.0.0", + "description": "Safe git pull command and helpers", + "late": { + "commands": ["/pull"], + "skills": ["skills/"] + } +} +``` + +### `skills/SKILL.md` + +```markdown +--- +name: git-helpers +description: Safe git pull instructions +scripts: + - safe-pull.sh +--- + +## Safe Git Pull Procedure + +When the user asks to pull changes or uses the `/pull` command: + +1. First check for uncommitted changes with `git status --porcelain` +2. If there are uncommitted changes, stash them with `git stash push -m "auto-stash before pull"` +3. Run `git pull --ff-only` to ensure a fast-forward merge +4. If the pull succeeds and stashed changes exist, pop them with `git stash pop` +5. If the pull fails, restore the stash with `git stash pop` and report the error + +Use the `safe-pull.sh` script tool for automated execution. +``` + +### `skills/safe-pull.sh` + +```bash +#!/bin/bash +set -e + +echo "=== Safe Git Pull ===" + +# Check for uncommitted changes +if [ -n "$(git status --porcelain)" ]; then + echo "Uncommitted changes detected. Stashing..." + git stash push -m "auto-stash before pull" + STASHED=true +fi + +# Pull with --ff-only for safety +echo "Pulling..." +if git pull --ff-only; then + echo "Pull successful." + if [ "$STASHED" = true ]; then + echo "Restoring stashed changes..." + git stash pop + fi +else + echo "Pull failed. Restoring stashed changes..." + if [ "$STASHED" = true ]; then + git stash pop + fi + exit 1 +fi +``` + +Make the script executable: + +```bash +chmod +x skills/safe-pull.sh +``` + +### Install and test + +```bash +# Link locally for development +late plugin link ./git-pull + +# Verify +late plugin list +# Expected output: +# Name Version Source Enabled Path +# ---- ------- ------ ------- ---- +# git-pull 1.0.0 local ✓ ~/.config/late/plugins/git-pull + +# Type /pull in the chat and press Enter +# The agent uses the git-helpers skill to run a safe git pull +``` + +### Extending the plugin + +Add more commands, skills, or MCP servers: + +```json +{ + "name": "git-pull", + "version": "2.0.0", + "description": "Git tools for Late", + "late": { + "commands": ["/pull", "/fetch", "/status", "/log"], + "skills": ["skills/"], + "mcp": { + "servers": { + "git-server": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"] + } + } + } + } +} +``` + +--- + +> **Tip:** Plugins are discovered at startup and watched for changes every 2 seconds. You can enable/disable plugins with `late plugin enable ` and `late plugin disable ` without restarting Late. + +--- + +## Plugin Surfaces (Reference) + +A plugin manifest's `late` field can declare any of the following surfaces. +All surfaces are optional and may be combined in any package.json. + +### `late.commands` — slash commands + +`late.commands` accepts either a flat array of strings (legacy form, the +command falls through to plain-prompt dispatch) or an array of objects that +can attach a `handler` script (messages are dispatched to your script, stdout +becomes a toast, errors become error toasts). + +```json +{ + "late": { + "commands": [ + "/weather", + { "name": "/git-pull", "handler": "scripts/pull.sh" } + ] + } +} +``` + +When the handler returns non-empty stdout, the TUI shows a toast like: +`/git-pull → "Already up to date."`. When it exits non-zero, the toast +reads: `/git-pull failed: `. + +### `late.tools` — inline agent-callable tools + +Tools declared this way are exposed to the model without an MCP wrapper. +The script receives the tool's argument JSON on stdin and must return the +result on stdout. + +```json +{ + "late": { + "tools": [ + { + "name": "summarize", + "description": "Summarize a file the user references.", + "script": "scripts/summarize.sh", + "parameters": { + "type": "object", + "properties": { "path": { "type": "string" } }, + "required": ["path"] + } + } + ] + } +} +``` + +The tool is registered under the namespaced name `:` (e.g. +`weather:summarize`). All the usual onToolCall / middleware pipeline rules +apply, so plugin tools respect user confirmation, hook mutation, and +enabledTools gating. + +### `late.hooks` — lifecycle hooks + +| Hook | Trigger | Input (stdin) | +| --------------- | ------------------------------------------------------------- | ------------------------------------------------------ | +| `onSessionStart` | Once, when Late starts. | empty | +| `onTurnStart` | Before each response cycle begins. | empty | +| `onTurnEnd` | After each response cycle ends. | empty | +| `onToolCall` | Before every tool runs. May mutate or veto (return `"blocked"`). | `{ "tool": "...", "arguments": {...}, "timestamp": "..." }` | +| `onToolResult` | After every tool runs. Observation only by default; can **mutate** the result before the LLM sees it (see [Tool-result mutation](#tool-result-mutation) below). | `{ "tool": "...", "result": "..." }` | +| `onMessageSend` | Sequential transform of outgoing user messages. | the current message text | +| `onInput` | Sequential transform of the user's intended message. | the current message text | + +Hooks are run inside the plugin's directory, so relative paths in the +manifest are resolved against the package root. Paths that escape the +plugin directory are rejected. + +### `--project` — project-local plugins + +Install into `~/.config/late/.late/plugins/` (your project's local copy) +instead of the global plugins directory: + +```bash +late plugin install --project ./my-plugin +late plugin link --project ./my-plugin +``` + +Project-local plugins override global plugins with the same `name`, so +teams can ship a plugin with their repo without forcing every developer +to install it. Path: `$CWD/.late/plugins/`. + +### From the marketplace registry + +```bash +# Bare name (no @, no /, no `.git`, no path prefix) → marketplace lookup. +late plugin install git-helper +# `github:user/repo` shorthand is also accepted. +late plugin install github:my-org/git-helper +``` + +Bare names hit the marketplace first (`LATE_PLUGIN_REGISTRY` overrides +`https://registry.late.dev/v1`). The registry returns either an npm target +or a git URL. If the registry is unreachable or returns 404, install +falls back to trying the bare name as an npm package. Override the +registry from the environment when self-hosting: + +```bash +export LATE_PLUGIN_REGISTRY="https://registry.example.com/v1" +``` + +## Updating Plugins + +```bash +# Re-fetch every installed npm/git plugin in place; skips local devlinks. +late plugin update + +# Update one plugin by name. Marketplace-source plugins re-resolve first. +late plugin update git-helper +``` + +Update flow: + +- **npm** — `npm install --prefix --no-save --quiet @latest` + then recreates the `plugins/` symlink if it was missing. +- **git** — clones to a sibling temp directory, strips `.git`, then atomically + `rename`s over the existing plugin directory (no half-writes). +- **marketplace** — re-resolves the registry entry, then proceeds as npm or git. +- **local** — skipped with a hint to edit the source directory directly. + +## Tool-result Mutation + +`onToolResult` scripts can rewrite the tool result before the LLM sees it. +The hook runs sequentially across plugin scripts (deterministic order +matches the snapshot from `PluginChangeMsg`). A script's stdout is +interpreted like this: + +| Script stdout | Effect | +| ----------------------- | --------------------------------------------------------------- | +| empty / non-JSON | Result passes through to the next script / LLM unchanged. | +| valid JSON | Replaces the result bytes for the next script / LLM. | +| literal `blocked` | Vetoes the result; the calling tool returns an error. | +| stderr / nonzero exit | Logged, the rest of the chain continues with the prior result. | + +The public entry point is `(*PluginManager).CallOnToolResultHooks(ctx, tool, result) ([]byte, error)`. +The orchestrator's tool-execution pipeline does not call it yet — +plugin authors can invoke it from a custom tool shim or an add-on +process while the agent-side integration is being finalized. + +--- + +## See also + +A worked example that exercises **every surface** described above +(skills, MCP, commands in both shapes, themes, hooks including veto and +mutate, inline tools, and the `--project` flag) lives at +[`plugin-example.md`](./plugin-example.md) — it's the recommended +starting point for plugin authors who want a copy-paste-able skeleton. diff --git a/docs/quickstart.md b/docs/quickstart.md index 318f323..13394e4 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -12,6 +12,7 @@ This guide gets you productive in Late in under 5 minutes. - [Configuration](#configuration) - [MCP Integration](#mcp-integration) - [Agent Skills](#agent-skills) +- [Plugins](#plugins) - [File Exclusions](#file-exclusions) - [Common Flags](#common-flags) - [Sessions](#sessions) @@ -223,6 +224,66 @@ Late supports the Model Context Protocol. Add your MCP servers to one of the fol There is no further setup required. Just add your skills to the directories and they will be discovered automatically. Late also supports automatic skill reference discovery. +## Plugins + +Plugins bundle any combination of **skills**, **slash commands**, **MCP servers**, **hooks**, **themes**, and **inline tools** into one installable unit. They are discovered automatically from: + +* **Global (Linux):** `~/.config/late/plugins/` +* **Global (macOS):** `~/Library/Application Support/late/plugins/` +* **Global (Windows):** `%APPDATA%\late\plugins\` +* **Project:** `.late/plugins/` (overrides global plugins with the same name) + +A background watcher reloads every ~2 seconds, so installs and enable/disable toggles pick up while Late is running — no restart needed. + +### Install + +```bash +# From npm +late plugin install @late/git-helper + +# From a Git repo +late plugin install https://github.com/you/late-plugin-git.git +# shorthand: github:you/late-plugin-git + +# From a local path (development) +late plugin install ./my-plugin + +# Project-local (per-repo) +late plugin install --project ./my-plugin + +# From the marketplace (bare name → registry lookup → npm/git fallback) +late plugin install git-helper +``` + +If the marketplace is unreachable, install falls back to treating the bare name as an npm package. Override the registry with `LATE_PLUGIN_REGISTRY=https://registry.example.com/v1`. + +### Manage + +```bash +late plugin list # show installed + their source/enabled state +late plugin enable # activate without removing +late plugin disable # deactivate without removing +late plugin remove # uninstall + +# Re-fetch every npm/git plugin in place (atomic git swap, npm @latest) +late plugin update [] +``` + +### What a plugin can ship + +A `package.json` with a `"late"` field declares its surfaces: + +| Surface | Example field | Appears as | +| ----------- | ----------------------------------- | ------------------------- | +| Skills | `"skills": ["skills/welcome.md"]` | Auto-loaded instructions | +| MCP servers | `"mcp": { "servers": {...} }` | Available tools | +| Commands | `"commands": ["/weather"]` | `/weather` in the chat | +| Themes | `"themes": ["themes/dark.json"]` | Switchable from `/themes` | +| Hooks | `"hooks": { "onInput": [...] }` | Middleware on tool/LLM | +| Tools | `"tools": [{ "name": "...", ... }]` | Custom in-agent tools | + +For the full manifest schema (including the older `"commands"` string array, env-var expansion in MCP configs, hook veto/mutate semantics, and a copy-pasteable reference plugin), see [`docs/plugin-sdk.md`](./plugin-sdk.md) and [`docs/plugin-example.md`](./plugin-example.md). + ## File Exclusions Late's native search tool respects your project's `.gitignore` automatically, saving LLM context by excluding vendor and build directories. diff --git a/docs/quickstart.zh-CN.md b/docs/quickstart.zh-CN.md index 2694931..3c9d38c 100644 --- a/docs/quickstart.zh-CN.md +++ b/docs/quickstart.zh-CN.md @@ -12,6 +12,7 @@ - [配置说明](#配置说明) - [MCP 协议集成](#mcp-协议集成) - [智能体技能 (Agent Skills)](#智能体技能-agent-skills) +- [插件系统 (Plugins)](#插件系统-plugins) - [文件排除规则](#文件排除规则) - [常用命令行标志 (Flags)](#常用命令行标志-flags) - [会话管理 (Sessions)](#会话管理-sessions) @@ -221,6 +222,70 @@ Late 支持 Model Context Protocol (大模型上下文协议)。请将你的 MCP 无需其他任何配置,只需将你的技能文件放置在这些目录中,Late 就会自动识别并启用它们。Late 也支持自动化的技能引用发现。 +## 插件系统 (Plugins) + +插件把 **技能 (skills)**、**斜杠命令**、**MCP 服务器**、**钩子 (hooks)**、**主题 (themes)** 与 **内联工具 (tools)** 任意组合打包成一个可安装单元。Late 会自动从以下目录中发现插件: + +* **全局 (Linux):** `~/.config/late/plugins/` +* **全局 (macOS):** `~/Library/Application Support/late/plugins/` +* **全局 (Windows):** `%APPDATA%\late\plugins\` +* **项目本地:** `.late/plugins/`(优先级高于同名全局插件) + +后台每约 2 秒轮询一次文件系统,安装、启用或禁用都不需要重启 Late。 + +### 安装 + +```bash +# 从 npm 安装 +late plugin install @late/git-helper + +# 从 Git 仓库 +late plugin install https://github.com/you/late-plugin-git.git +# 也支持简写:github:you/late-plugin-git + +# 从本地路径(开发模式) +late plugin install ./my-plugin + +# 项目本地(仅本仓库可见) +late plugin install --project ./my-plugin + +# 从插件市场(裸名 → 注册表查询 → 自动回退为 npm/git) +late plugin install git-helper +``` + +当插件市场不可达时,会自动把裸名当作普通的 npm 包安装。如需自建市场,可通过环境变量覆盖: + +```bash +export LATE_PLUGIN_REGISTRY=https://registry.example.com/v1 +``` + +### 管理 + +```bash +late plugin list # 列出已安装的插件,包含来源与启用状态 +late plugin enable # 启用(不卸载) +late plugin disable # 停用(不卸载) +late plugin remove # 卸载 + +# 重新拉取所有 npm/git 插件(git 走原子替换,npm 固定为 @latest) +late plugin update [] +``` + +### 一个插件能提供什么 + +`package.json` 中的 `"late"` 字段用来声明该插件提供的扩展面: + +| 扩展面 | 示例字段 | 在 Late 中的表现 | +| ---------- | ------------------------------------- | ------------------------- | +| 技能 | `"skills": ["skills/welcome.md"]` | 自动加载的指令集 | +| MCP 服务器 | `"mcp": { "servers": {...} }` | 可调用的工具 | +| 斜杠命令 | `"commands": ["/weather"]` | 聊天中的 `/weather` 命令 | +| 主题 | `"themes": ["themes/dark.json"]` | 通过 `/themes` 切换 | +| 钩子 | `"hooks": { "onInput": [...] }` | 工具 / LLM 调用前后的中间件 | +| 内联工具 | `"tools": [{ "name": "...", ... }]` | 智能体可直接调用的工具 | + +完整的 manifest 字段说明(包括旧版字符串形式的 `"commands"`、MCP 配置里的 `${VAR}` 环境变量展开、钩子的拒绝 / 修改语义,以及一份可复制粘贴的最小插件示例)请见 [`docs/plugin-sdk.md`](./plugin-sdk.md) 与 [`docs/plugin-example.md`](./plugin-example.md)。 + ## 文件排除规则 Late 的原生搜索工具会自动遵循你的项目 `.gitignore`,通过排除 vendor 和 build 目录来节省 LLM 上下文。 diff --git a/internal/common/path_utils.go b/internal/common/path_utils.go index e3311bb..81d0f24 100644 --- a/internal/common/path_utils.go +++ b/internal/common/path_utils.go @@ -21,3 +21,11 @@ func LateProjectMCPConfigPath() string { func LateUserMCPConfigPath() (string, error) { return pathutil.LateUserMCPConfigPath() } + +func LatePluginsDir() (string, error) { + return pathutil.LatePluginsDir() +} + +func LateProjectPluginsDir() string { + return pathutil.LateProjectPluginsDir() +} diff --git a/internal/mcp/client.go b/internal/mcp/client.go index 7be550b..9634d2c 100644 --- a/internal/mcp/client.go +++ b/internal/mcp/client.go @@ -314,15 +314,23 @@ func (b *lockedBuffer) String() string { // using the SDK's CommandTransport for proper process lifecycle (SIGTERM → wait → SIGKILL). // Stderr is discarded to prevent MCP server output from bleeding into the TUI. func NewStdioTransport(ctx context.Context, command string, args []string, env []string) (mcp.Transport, error) { - return NewStdioTransportWithStderr(ctx, command, args, env, io.Discard) + return NewStdioTransportWithStderr(ctx, command, args, env, io.Discard, "") } // NewStdioTransportWithStderr is like NewStdioTransport but writes the subprocess's // stderr to the provided writer instead of discarding it. This is used by // ConnectFromConfig to buffer stderr for diagnostics on connection failure. -func NewStdioTransportWithStderr(ctx context.Context, command string, args []string, env []string, stderr io.Writer) (mcp.Transport, error) { +// +// When `workDir` is non-empty, the subprocess launches with `cmd.Dir = workDir`, +// so any relative paths in `args` (and any CWD-relative behavior the user +// scripts rely on) resolve against that directory. This matters for plugin +// servers shipped via npm-scoped guests whose script paths are plugin-relative. +func NewStdioTransportWithStderr(ctx context.Context, command string, args []string, env []string, stderr io.Writer, workDir string) (mcp.Transport, error) { cmd := exec.Command(command, args...) cmd.Env = append(os.Environ(), env...) + if workDir != "" { + cmd.Dir = workDir + } serr, err := cmd.StderrPipe() if err != nil { @@ -383,7 +391,7 @@ func TransportForServer(ctx context.Context, server *MCPServer) (mcp.Transport, envSlice = append(envSlice, k+"="+v) } - t, err := NewStdioTransportWithStderr(ctx, server.Command, server.Args, envSlice, io.Discard) + t, err := NewStdioTransportWithStderr(ctx, server.Command, server.Args, envSlice, io.Discard, server.Dir) if err != nil { return nil, err } @@ -411,7 +419,7 @@ func (c *Client) ConnectFromConfig(ctx context.Context, config *MCPConfig) error envSlice = append(envSlice, k+"="+v) } var stderrBuf lockedBuffer - transport, err = NewStdioTransportWithStderr(ctx, server.Command, server.Args, envSlice, &stderrBuf) + transport, err = NewStdioTransportWithStderr(ctx, server.Command, server.Args, envSlice, &stderrBuf, server.Dir) if err == nil { err = c.Connect(ctx, transport, name) } diff --git a/internal/mcp/config.go b/internal/mcp/config.go index d0da247..6ab5fdd 100644 --- a/internal/mcp/config.go +++ b/internal/mcp/config.go @@ -25,6 +25,13 @@ type MCPServer struct { URL string `json:"url"` TransportType string `json:"transportType,omitempty"` // "stdio", "sse", or "streamable-http" Disabled bool `json:"disabled,omitempty"` + // Dir is set by Late (not serialized from JSON) when a plugin + // supplies the server. When non-empty and the transport is stdio, + // the subprocess is launched with `cmd.Dir = Dir` so that any + // relative paths in Args resolve against the plugin's directory + // even when the user's CWD is elsewhere. Keep this field out of + // JSON tags so user-authored mcp_config.json files don't carry it. + Dir string `json:"-"` } // LoadMCPConfig loads the MCP configuration from the first available config file diff --git a/internal/mcp/config_test.go b/internal/mcp/config_test.go index 076504c..8206145 100644 --- a/internal/mcp/config_test.go +++ b/internal/mcp/config_test.go @@ -2,160 +2,182 @@ package mcp import ( "os" - "path/filepath" "testing" ) -func TestLoadConfigFromFile(t *testing.T) { - // Create a temporary directory for test config - tmpDir := t.TempDir() - configPath := filepath.Join(tmpDir, "mcp.json") - - // Create a test config file - configContent := `{ - "mcpServers": { - "test-server": { - "command": "node", - "args": ["test.js"], - "env": { - "TEST_VAR": "test-value" - } - } - } - }` - if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { - t.Fatalf("Failed to write test config: %v", err) - } - - // Load the config - config, err := loadConfigFromFile(configPath) - if err != nil { - t.Fatalf("Failed to load config: %v", err) - } - - // Verify the config - if config == nil { - t.Fatal("Config should not be nil") - } - - if len(config.McpServers) != 1 { - t.Errorf("Expected 1 server, got %d", len(config.McpServers)) - } - - if server, ok := config.McpServers["test-server"]; !ok { - t.Error("Expected test-server in config") - } else { - if server.Command != "node" { - t.Errorf("Expected command 'node', got '%s'", server.Command) - } - if len(server.Args) != 1 || server.Args[0] != "test.js" { - t.Errorf("Expected args ['test.js'], got %v", server.Args) - } - if server.Env["TEST_VAR"] != "test-value" { - t.Errorf("Expected env TEST_VAR='test-value', got '%s'", server.Env["TEST_VAR"]) - } +// --------------------------------------------------------------------------- +// ExpandEnvVars +// --------------------------------------------------------------------------- + +func TestExpandEnvVars_NoVars(t *testing.T) { + input := "plain-string-without-vars" + result := ExpandEnvVars(input) + if result != input { + t.Errorf("expected %q, got %q", input, result) } } -func TestLoadConfigMissingFile(t *testing.T) { - // Try to load a non-existent config file - _, err := loadConfigFromFile("/nonexistent/path/mcp.json") - if err == nil { - t.Error("Expected error for missing file, got nil") +func TestExpandEnvVars_EmptyString(t *testing.T) { + result := ExpandEnvVars("") + if result != "" { + t.Errorf("expected empty string, got %q", result) } } -func TestExpandEnvVars(t *testing.T) { - // Set a test environment variable - if err := os.Setenv("TEST_EXPAND_VAR", "expanded-value"); err != nil { - t.Fatalf("Failed to set env var: %v", err) - } - defer os.Unsetenv("TEST_EXPAND_VAR") +func TestExpandEnvVars_BasicExpansion(t *testing.T) { + os.Setenv("TEST_MCP_KEY", "my-secret-value") + defer os.Unsetenv("TEST_MCP_KEY") - // Test expansion - testCases := []struct { - input string - expected string - }{ - {"${TEST_EXPAND_VAR}", "expanded-value"}, - {"prefix-${TEST_EXPAND_VAR}-suffix", "prefix-expanded-value-suffix"}, - {"no variables here", "no variables here"}, - {"${NONEXISTENT_VAR}", ""}, - {"multiple ${TEST_EXPAND_VAR} and ${TEST_EXPAND_VAR}", "multiple expanded-value and expanded-value"}, + result := ExpandEnvVars("${TEST_MCP_KEY}") + if result != "my-secret-value" { + t.Errorf("expected 'my-secret-value', got %q", result) } +} - for _, tc := range testCases { - result := ExpandEnvVars(tc.input) - if result != tc.expected { - t.Errorf("ExpandEnvVars(%q) = %q, expected %q", tc.input, result, tc.expected) - } +func TestExpandEnvVars_MultipleVars(t *testing.T) { + os.Setenv("TEST_HOST", "localhost") + os.Setenv("TEST_PORT", "8080") + defer os.Unsetenv("TEST_HOST") + defer os.Unsetenv("TEST_PORT") + + result := ExpandEnvVars("http://${TEST_HOST}:${TEST_PORT}/api") + if result != "http://localhost:8080/api" { + t.Errorf("expected 'http://localhost:8080/api', got %q", result) } } -func TestExpandServerEnvVars(t *testing.T) { - // Set test environment variables - if err := os.Setenv("TEST_CMD", "node"); err != nil { - t.Fatalf("Failed to set env var: %v", err) +func TestExpandEnvVars_MissingVar(t *testing.T) { + // Unset to ensure it's missing + os.Unsetenv("TEST_MISSING_VAR") + + result := ExpandEnvVars("prefix-${TEST_MISSING_VAR}-suffix") + if result != "prefix--suffix" { + t.Errorf("expected 'prefix--suffix', got %q", result) } - if err := os.Setenv("TEST_ARG", "test.js"); err != nil { - t.Fatalf("Failed to set env var: %v", err) +} + +func TestExpandEnvVars_MixedWithStaticText(t *testing.T) { + os.Setenv("TEST_API_KEY", "abc123") + defer os.Unsetenv("TEST_API_KEY") + + result := ExpandEnvVars("Bearer ${TEST_API_KEY}") + if result != "Bearer abc123" { + t.Errorf("expected 'Bearer abc123', got %q", result) } - if err := os.Setenv("TEST_TOKEN", "secret-token"); err != nil { - t.Fatalf("Failed to set env var: %v", err) +} + +func TestExpandEnvVars_WithDollarSignOnly(t *testing.T) { + os.Setenv("TEST_VAR", "value") + defer os.Unsetenv("TEST_VAR") + + // $VAR (without braces) should NOT be expanded + result := ExpandEnvVars("$TEST_VAR") + if result != "$TEST_VAR" { + t.Errorf("expected '$TEST_VAR' (no expansion for $ without braces), got %q", result) } +} + +// --------------------------------------------------------------------------- +// ExpandServerEnvVars +// --------------------------------------------------------------------------- + +func TestExpandServerEnvVars_AllFields(t *testing.T) { + os.Setenv("TEST_CMD", "node") + os.Setenv("TEST_SCRIPT", "server.js") + os.Setenv("TEST_API_KEY", "key-123") + os.Setenv("TEST_ENDPOINT", "https://api.example.com") defer os.Unsetenv("TEST_CMD") - defer os.Unsetenv("TEST_ARG") - defer os.Unsetenv("TEST_TOKEN") + defer os.Unsetenv("TEST_SCRIPT") + defer os.Unsetenv("TEST_API_KEY") + defer os.Unsetenv("TEST_ENDPOINT") - // Create a server config with environment variables server := &MCPServer{ Command: "${TEST_CMD}", - Args: []string{"${TEST_ARG}", "static-arg"}, + Args: []string{"${TEST_SCRIPT}", "--port", "8080"}, + URL: "${TEST_ENDPOINT}/v1", Env: map[string]string{ - "TOKEN": "${TEST_TOKEN}", - "STATIC": "static-value", + "API_KEY": "${TEST_API_KEY}", + "STATIC": "static-value", }, } - // Expand environment variables ExpandServerEnvVars(server) - // Verify expansion if server.Command != "node" { - t.Errorf("Expected command 'node', got '%s'", server.Command) + t.Errorf("expected Command 'node', got %q", server.Command) } - - if len(server.Args) != 2 || server.Args[0] != "test.js" || server.Args[1] != "static-arg" { - t.Errorf("Expected args ['test.js', 'static-arg'], got %v", server.Args) + if server.URL != "https://api.example.com/v1" { + t.Errorf("expected URL 'https://api.example.com/v1', got %q", server.URL) } + if len(server.Args) != 3 || server.Args[0] != "server.js" || server.Args[1] != "--port" || server.Args[2] != "8080" { + t.Errorf("expected Args ['server.js', '--port', '8080'], got %v", server.Args) + } + if server.Env["API_KEY"] != "key-123" { + t.Errorf("expected Env[API_KEY] = 'key-123', got %q", server.Env["API_KEY"]) + } + if server.Env["STATIC"] != "static-value" { + t.Errorf("expected Env[STATIC] = 'static-value', got %q", server.Env["STATIC"]) + } +} - if server.Env["TOKEN"] != "secret-token" { - t.Errorf("Expected env TOKEN='secret-token', got '%s'", server.Env["TOKEN"]) +func TestExpandServerEnvVars_EmptyFields(t *testing.T) { + server := &MCPServer{ + Command: "", + Args: nil, + URL: "", + Env: nil, } - if server.Env["STATIC"] != "static-value" { - t.Errorf("Expected env STATIC='static-value', got '%s'", server.Env["STATIC"]) + ExpandServerEnvVars(server) + + if server.Command != "" { + t.Errorf("expected empty Command, got %q", server.Command) + } + if server.URL != "" { + t.Errorf("expected empty URL, got %q", server.URL) + } + if server.Env != nil { + t.Errorf("expected nil Env, got %v", server.Env) } } -func TestFindConfigPath(t *testing.T) { - // This test can't assume a config file doesn't exist on the tester's machine, - // so we only verify it doesn't return an error. - _, err := findConfigPath() - if err != nil { - t.Fatalf("findConfigPath should not error: %v", err) +func TestExpandServerEnvVars_NoVars(t *testing.T) { + server := &MCPServer{ + Command: "npx", + Args: []string{"-y", "@scope/pkg"}, + URL: "", + Env: map[string]string{ + "KEY": "value", + }, + } + + ExpandServerEnvVars(server) + + if server.Command != "npx" { + t.Errorf("expected Command 'npx', got %q", server.Command) + } + if server.Args[0] != "-y" { + t.Errorf("expected Args[0] '-y', got %q", server.Args[0]) + } + if server.Env["KEY"] != "value" { + t.Errorf("expected Env[KEY] 'value', got %q", server.Env["KEY"]) } } -func TestLoadMCPConfig(t *testing.T) { - // Likewise, this might load a real configuration, so we only verify - // that it returns a non-nil struct without error. - config, err := LoadMCPConfig() - if err != nil { - t.Fatalf("LoadMCPConfig should not error: %v", err) +func TestExpandServerEnvVars_MultipleVarsInOneField(t *testing.T) { + os.Setenv("TEST_USER", "admin") + os.Setenv("TEST_PASS", "secret") + defer os.Unsetenv("TEST_USER") + defer os.Unsetenv("TEST_PASS") + + server := &MCPServer{ + Command: "auth-${TEST_USER}-${TEST_PASS}", } - if config == nil { - t.Fatal("Config should not be nil") + ExpandServerEnvVars(server) + + expected := "auth-admin-secret" + if server.Command != expected { + t.Errorf("expected %q, got %q", expected, server.Command) } } diff --git a/internal/pathutil/pathutil.go b/internal/pathutil/pathutil.go index 9ed34b0..bea80c3 100644 --- a/internal/pathutil/pathutil.go +++ b/internal/pathutil/pathutil.go @@ -56,3 +56,15 @@ func LateSkillsDir() (string, error) { func LateProjectSkillsDir() string { return filepath.Join(".late", "skills") } + +func LatePluginsDir() (string, error) { + lateConfigDir, err := LateConfigDir() + if err != nil { + return "", err + } + return filepath.Join(lateConfigDir, "plugins"), nil +} + +func LateProjectPluginsDir() string { + return filepath.Join(".late", "plugins") +} diff --git a/internal/plugin/command.go b/internal/plugin/command.go new file mode 100644 index 0000000..4670af1 --- /dev/null +++ b/internal/plugin/command.go @@ -0,0 +1,383 @@ +package plugin + +import ( + "fmt" + "os" + "sort" + "strings" + "text/tabwriter" +) + +// HandlePluginCommand dispatches `late plugin ` to the appropriate handler. +// Returns true if the command was handled (caller should exit), false if the caller +// should continue (e.g. the plugin manager needs to bootstrap first). +// +// Help handling: `-h`, `--help`, or `help` as the first arg prints +// top-level usage; the same tokens anywhere in a subcommand's args +// print the per-subcommand usage. This is critical because install's +// implementation falls through to npm (which prints its own docs when +// passed `--help`), and link's implementation tries to interpret +// `--help` as a filesystem path and dies. +func HandlePluginCommand(pm *PluginManager, args []string) bool { + if len(args) == 0 { + printPluginUsage() + return true + } + if isHelpToken(args[0]) { + printPluginUsage() + return true + } + + switch args[0] { + case "list", "ls": + if hasHelpFlag(args[1:]) { + fmt.Fprintln(os.Stderr, "Usage: late plugin list [--project]") + return true + } + handlePluginList(pm) + return true + case "install", "i": + if hasHelpFlag(args[1:]) { + fmt.Fprintln(os.Stderr, "Usage: late plugin install [--project] ") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Install a plugin by source. Source may be:") + fmt.Fprintln(os.Stderr, " - npm package: @late/plugin-graph-rag, some-pkg") + fmt.Fprintln(os.Stderr, " - git url: https://github.com/user/repo.git") + fmt.Fprintln(os.Stderr, " - shorthand git: github:user/repo") + fmt.Fprintln(os.Stderr, " - local filesystem: ./my-plugin, /abs/path, ~/path") + fmt.Fprintln(os.Stderr, " - bare name: resolved via the marketplace; falls") + fmt.Fprintln(os.Stderr, " through to npm on miss.") + return true + } + handlePluginInstall(pm, args[1:]) + return true + case "remove", "rm", "uninstall": + if hasHelpFlag(args[1:]) { + fmt.Fprintln(os.Stderr, "Usage: late plugin remove [--project] ") + return true + } + handlePluginRemove(pm, args[1:]) + return true + case "link": + if hasHelpFlag(args[1:]) { + fmt.Fprintln(os.Stderr, "Usage: late plugin link [--project] ") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Link a local directory as a plugin (dev mode). The path") + fmt.Fprintln(os.Stderr, "must be an existing directory containing a native-Late") + fmt.Fprintln(os.Stderr, "(`late`-keyed package.json) or a Claude Code") + fmt.Fprintln(os.Stderr, "(.claude-plugin/plugin.json) plugin manifest.") + return true + } + handlePluginLink(pm, args[1:]) + return true + case "update": + if hasHelpFlag(args[1:]) { + fmt.Fprintln(os.Stderr, "Usage: late plugin update []") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Without a name, updates every installed npm/git plugin") + fmt.Fprintln(os.Stderr, "in place. With a name, updates that plugin only.") + return true + } + handlePluginUpdate(pm, args[1:]) + return true + case "enable": + if hasHelpFlag(args[1:]) { + fmt.Fprintln(os.Stderr, "Usage: late plugin enable ") + return true + } + handlePluginEnable(pm, args[1:], true) + return true + case "disable": + if hasHelpFlag(args[1:]) { + fmt.Fprintln(os.Stderr, "Usage: late plugin disable ") + return true + } + handlePluginEnable(pm, args[1:], false) + return true + default: + fmt.Fprintf(os.Stderr, "Unknown plugin command: %s\n\n", args[0]) + printPluginUsage() + return true + } +} + +// isHelpToken reports whether s is a help-style flag (-h, --help, or +// the bare word "help", in any combination). +func isHelpToken(s string) bool { + switch strings.ToLower(s) { + case "-h", "--help", "help": + return true + } + return false +} + +// hasHelpFlag scans args for any help-style token and returns true on +// the first match. Used by every per-subcommand branch of +// HandlePluginCommand so that `late plugin install --help` (and any +// positional variant: `--help` last, `--help` mid-args) prints usage +// rather than being misinterpreted as a plugin source / path / name. +func hasHelpFlag(args []string) bool { + for _, a := range args { + if isHelpToken(a) { + return true + } + } + return false +} + +func printPluginUsage() { + fmt.Fprintf(os.Stderr, `Usage: late plugin [args...] + +Commands: + list, ls List installed plugins + install, i Install a plugin (npm package, git url, or local path) + remove, rm Remove a plugin + link Link a local directory as a plugin (dev mode) + update [name] Update plugins (all or specific) + enable Enable a plugin + disable Disable a plugin + +Examples: + late plugin install @late/plugin-graph-rag + late plugin install https://github.com/user/late-plugin.git + late plugin install github:user/late-plugin + late plugin link ./my-plugin + late plugin list + late plugin remove @late/plugin-graph-rag +`) +} + +// handlePluginList displays all installed plugins. +func handlePluginList(pm *PluginManager) { + plugins := pm.All() + if len(plugins) == 0 { + fmt.Println("No plugins installed.") + fmt.Println("Run 'late plugin install ' to install a plugin.") + return + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) + fmt.Fprintln(w, "Name\tVersion\tSource\tEnabled\tPath") + fmt.Fprintln(w, "----\t-------\t------\t-------\t----") + + for _, p := range plugins { + enabled := "✓" + if !p.Enabled { + enabled = "✗" + } + displayName := p.Name + // Truncate long paths for display + displayPath := p.Path + if home, err := os.UserHomeDir(); err == nil { + displayPath = strings.Replace(displayPath, home, "~", 1) + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", displayName, p.Version, p.SourceType, enabled, displayPath) + } + w.Flush() + fmt.Fprintf(os.Stderr, "\n%d plugin(s) installed. Use 'late plugin enable/disable ' to toggle.\n", len(plugins)) +} + +// handlePluginInstall installs a plugin from the given source. +// Supports --project flag to install into the project-local .late/plugins/ directory. +func handlePluginInstall(pm *PluginManager, args []string) { + project, source := parseProjectFlag(args) + if source == "" { + fmt.Fprintln(os.Stderr, "Error: missing plugin source (npm package name, git URL, or local path)") + if project && !pm.HasProjectDir() { + fmt.Fprintln(os.Stderr, "Note: --project flag requires a .late/plugins/ directory (create it first)") + } + fmt.Fprintln(os.Stderr, "Usage: late plugin install [--project] ") + return + } + + // Single dispatcher: classifies URL/path/npm/layout and falls through + // to marketplace → npm for unresolved bare names. + plugin, err := Install(pm, source, nil, project) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to install plugin: %v\n", err) + return + } + + scope := "global" + if project && pm.HasProjectDir() { + scope = "project" + } + fmt.Printf("Installed plugin: %s v%s (%s)\n", plugin.Name, plugin.Version, scope) + if plugin.Description != "" { + fmt.Printf(" %s\n", plugin.Description) + } + fmt.Printf(" Path: %s\n", plugin.Path) + + // List available surfaces + if plugin.Late != nil { + var surfaces []string + if len(plugin.Late.Skills) > 0 { + surfaces = append(surfaces, fmt.Sprintf("%d skill(s)", len(plugin.Late.Skills))) + } + if plugin.Late.MCP != nil && len(plugin.Late.MCP.Servers) > 0 { + surfaces = append(surfaces, fmt.Sprintf("%d MCP server(s)", len(plugin.Late.MCP.Servers))) + } + if len(plugin.Late.Commands) > 0 { + surfaces = append(surfaces, fmt.Sprintf("%d command(s)", len(plugin.Late.Commands))) + } + if len(plugin.Late.Themes) > 0 { + surfaces = append(surfaces, fmt.Sprintf("%d theme(s)", len(plugin.Late.Themes))) + } + if plugin.Late.Hooks != nil { + surfaces = append(surfaces, "hooks") + } + if len(surfaces) > 0 { + fmt.Printf(" Surfaces: %s\n", strings.Join(surfaces, ", ")) + } + } + + fmt.Println("\nPlugin activated. The filesystem watcher will pick it up within 2 seconds.") +} + +// parseProjectFlag checks if --project flag is present in args and returns +// the flag state and remaining args (the source). +func parseProjectFlag(args []string) (project bool, rest string) { + for i, a := range args { + if a == "--project" || a == "--local" { + // Return remaining args after removing the flag + var remaining []string + for j, r := range args { + if j != i { + remaining = append(remaining, r) + } + } + if len(remaining) > 0 { + return true, remaining[0] + } + return true, "" + } + } + return false, args[0] +} + +// handlePluginRemove removes a plugin. +func handlePluginRemove(pm *PluginManager, args []string) { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "Error: missing plugin name") + fmt.Fprintln(os.Stderr, "Usage: late plugin remove [--project] ") + return + } + + project, name := parseProjectFlag(args) + if name == "" { + fmt.Fprintln(os.Stderr, "Error: missing plugin name") + return + } + + _, err := RemovePlugin(pm, name, project) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to remove plugin: %v\n", err) + return + } + + scope := "" + if project { + scope = " (project)" + } + fmt.Printf("Removed plugin: %s%s\n", name, scope) +} + +// handlePluginLink creates a development symlink. +func handlePluginLink(pm *PluginManager, args []string) { + project, path := parseProjectFlag(args) + if path == "" { + fmt.Fprintln(os.Stderr, "Error: missing path") + fmt.Fprintln(os.Stderr, "Usage: late plugin link [--project] ") + return + } + + plugin, err := Link(pm, path, project) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to link plugin: %v\n", err) + return + } + + scope := "global" + if project { + scope = "project" + } + fmt.Printf("Linked plugin: %s v%s (%s)\n", plugin.Name, plugin.Version, scope) + fmt.Printf(" Path: %s\n", plugin.Path) +} + +// handlePluginUpdate updates installed plugins. +// +// Behavior: +// - `late plugin update` (no args) → UpdateAll — re-installs every npm/git +// plugin in place, skipping local. +// - `late plugin update ` → Update one plugin by name. Resolves +// marketplace-source plugins via the default marketplace client on the fly. +// - `late plugin update local` → Refused with a hint to edit the +// source directory directly. +func handlePluginUpdate(pm *PluginManager, args []string) { + if len(args) > 0 { + name := args[0] + if _, err := Update(pm, name, nil); err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to update plugin %s: %v\n", name, err) + return + } + return + } + + results, err := UpdateAll(pm, nil) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: bulk update failed: %v\n", err) + // Surface partial state so the user can see what succeeded. + for _, p := range results { + fmt.Printf(" %s v%s\n", p.Name, p.Version) + } + return + } + for _, p := range results { + fmt.Printf("Updated %s v%s\n", p.Name, p.Version) + } +} + +// handlePluginEnable enables or disables a plugin. +func handlePluginEnable(pm *PluginManager, args []string, enable bool) { + if len(args) == 0 { + action := "enable" + if !enable { + action = "disable" + } + fmt.Fprintf(os.Stderr, "Error: missing plugin name\n") + fmt.Fprintf(os.Stderr, "Usage: late plugin %s \n", action) + return + } + + name := args[0] + plugin := pm.Plugin(name) + if plugin == nil { + fmt.Fprintf(os.Stderr, "Error: plugin %s is not installed\n", name) + return + } + + plugin.Enabled = enable + if err := SavePluginMeta(plugin); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to save metadata: %v\n", err) + } + + state := "enabled" + if !enable { + state = "disabled" + } + fmt.Printf("%s %s\n", plugin.Name, state) +} + +// (isGitURL and isLocalPath were removed: Install() now classifies the +// source string itself, including marketplace fallback for bare names.) + +// Sort plugins by name +type byName []*InstalledPlugin + +func (a byName) Len() int { return len(a) } +func (a byName) Less(i, j int) bool { return a[i].Name < a[j].Name } +func (a byName) Swap(i, j int) { a[i], a[j] = a[j], a[i] } + +// Ensure sort.Interface compliance +var _ sort.Interface = byName{} diff --git a/internal/plugin/command_test.go b/internal/plugin/command_test.go new file mode 100644 index 0000000..0eabced --- /dev/null +++ b/internal/plugin/command_test.go @@ -0,0 +1,109 @@ +package plugin + +import ( + "io" + "os" + "strings" + "testing" +) + +// captureStderr runs fn while redirecting os.Stderr to a buffer and +// returns (returnValue, capturedStderr). Used by the help-flag tests +// to assert that HandlePluginCommand prints expected usage text. +func captureStderr(t *testing.T, fn func()) string { + t.Helper() + orig := os.Stderr + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stderr = w + done := make(chan []byte, 1) + go func() { + buf, _ := io.ReadAll(r) + done <- buf + }() + fn() + _ = w.Close() + os.Stderr = orig + bb := <-done + return string(bb) +} + +// WithHandlePluginCommandHelp exercises every help entry point of +// HandlePluginCommand: top-level `-h`/`--help`/`help`, plus the +// per-subcommand `--help` short-circuit for install, link, list, +// remove, update, enable, disable. +// +// The regression cases are: +// - `install --help` previously fell through to npm install's own +// docs because Install() classified "--help" as a bare name. +// - `link --help` previously crashed with "local path does not +// exist: --help" because InstallFromLocal was given the literal +// string "--help" as the source path. +func TestHandlePluginCommandHelp(t *testing.T) { + pm := NewPluginManager(t.TempDir()) + cases := []struct { + name string + args []string + wantSub string // substring that must appear in the printed usage + }{ + {"top-level -h", []string{"-h"}, "Usage: late plugin "}, + {"top-level --help", []string{"--help"}, "Usage: late plugin "}, + {"top-level 'help' word", []string{"help"}, "Usage: late plugin "}, + + {"install -h", []string{"install", "-h"}, "Usage: late plugin install"}, + {"install --help", []string{"install", "--help"}, "Usage: late plugin install"}, + {"install --help after source", []string{"install", "@late/foo", "--help"}, "Usage: late plugin install"}, + {"install help word", []string{"install", "help"}, "Usage: late plugin install"}, + + {"link -h", []string{"link", "-h"}, "Usage: late plugin link"}, + {"link --help", []string{"link", "--help"}, "Usage: late plugin link"}, + {"link --project --help", []string{"link", "--project", "--help"}, "Usage: late plugin link"}, + + {"list -h", []string{"list", "-h"}, "Usage: late plugin list"}, + {"remove -h", []string{"remove", "-h"}, "Usage: late plugin remove"}, + {"update -h", []string{"update", "-h"}, "Usage: late plugin update"}, + {"enable -h", []string{"enable", "-h"}, "Usage: late plugin enable"}, + {"disable -h", []string{"disable", "-h"}, "Usage: late plugin disable"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + stderr := captureStderr(t, func() { + _ = HandlePluginCommand(pm, c.args) + }) + if !strings.Contains(stderr, c.wantSub) { + t.Errorf("HandlePluginCommand(%v) stderr missing %q\ngot: %s", + c.args, c.wantSub, stderr) + } + }) + } +} + +// TestHandlePluginCommandHelpShortCircuits verifies that the help +// path returns true (telling main to exit) without ever needing the +// pm argument to be valid. We hand in a freshly-constructed pm with +// no plugins and call install --help; if the install branch were +// reached, the call would attempt an HTTP marketplace lookup and +// very likely take seconds before returning. This test pins the +// sub-second behavior. +func TestHandlePluginCommandHelpShortCircuits(t *testing.T) { + pm := NewPluginManager(t.TempDir()) + if !HandlePluginCommand(pm, []string{"install", "--help"}) { + t.Error("expected HandlePluginCommand(install, --help) to return true") + } + if !HandlePluginCommand(pm, []string{"link", "--help"}) { + t.Error("expected HandlePluginCommand(link, --help) to return true") + } +} + +// ensure we don't break the no-args usage path +func TestHandlePluginCommandNoArgs(t *testing.T) { + pm := NewPluginManager(t.TempDir()) + stderr := captureStderr(t, func() { + _ = HandlePluginCommand(pm, nil) + }) + if !strings.Contains(stderr, "Usage: late plugin ") { + t.Errorf("expected top-level usage on empty args, got: %s", stderr) + } +} diff --git a/internal/plugin/commands.go b/internal/plugin/commands.go new file mode 100644 index 0000000..08f6fc1 --- /dev/null +++ b/internal/plugin/commands.go @@ -0,0 +1,82 @@ +package plugin + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" +) + +// HandleCommand looks up a registered slash-command handler across all +// enabled plugins and, if found, runs the handler script with the +// trailing positional arguments (JSON-encoded as a string array on stdin). +// +// Returns: +// +// ("output", true, err) — plugin declared this command and a handler ran +// ("", false, nil) — no plugin registered this name (fall through) +// ("", false, nil) — plugin registered it but didn't set a Handler +// (legacy "plain prompt" dispatch behavior) +// +// Leading "/" is stripped on both sides before comparison so plugin authors +// can declare commands as either "/weather" or "weather" indistinguishably. +// When two enabled plugins declare the same command name, the first one in +// sorted-by-name order wins; the duplicate is logged to stderr so authors +// notice conflicts immediately. +func (pm *PluginManager) HandleCommand(ctx context.Context, cmdName string, args []string) (string, bool, error) { + pm.mu.RLock() + defer pm.mu.RUnlock() + + normalized := strings.TrimPrefix(cmdName, "/") + + // Reuse pm.All() so we don't reinvent the sort+copy here; All() takes + // the same RLock and returns a sorted slice for deterministic + // iteration order (and therefore a stable duplicate-detection log line). + plugins := pm.All() + + var ( + matched bool + firstOwner string + firstMatchCmd LateCommandManifest + firstMatchDir string + ) + + for _, p := range plugins { + if !p.Enabled || p.Late == nil { + continue + } + for _, c := range p.Late.Commands { + if strings.TrimPrefix(c.Name, "/") != normalized { + continue + } + if !matched { + matched = true + firstOwner = p.Name + firstMatchCmd = c + firstMatchDir = p.Path + continue + } + // Already matched once — log duplicate registrations. + fmt.Fprintf(os.Stderr, + "[commands] duplicate registration for %q: %q wins (shadows %q from %q)\n", + cmdName, firstOwner, p.Name, c.Name) + } + } + + if !matched { + return "", false, nil + } + if firstMatchCmd.Handler == "" { + // Legacy "no handler declared" — the TUI should fall through to + // plain-prompt dispatch. + return "", false, nil + } + + payload, _ := json.Marshal(args) + out, err := runHook(ctx, firstMatchDir, firstMatchCmd.Handler, payload) + if err != nil { + return "", true, fmt.Errorf("plugin command %q failed: %w", cmdName, err) + } + return out, true, nil +} diff --git a/internal/plugin/commands_tools_test.go b/internal/plugin/commands_tools_test.go new file mode 100644 index 0000000..38917cb --- /dev/null +++ b/internal/plugin/commands_tools_test.go @@ -0,0 +1,166 @@ +package plugin + +import ( + "context" + "path/filepath" + "strings" + "testing" +) + +// TestHandleCommand_DispatchesHandler verifies that when a plugin declares +// a command with a Handler script, HandleCommand runs the script with the +// args JSON-encoded on stdin and returns the trimmed stdout as output. +func TestHandleCommand_DispatchesHandler(t *testing.T) { + pm := NewPluginManager(t.TempDir()) + dir := t.TempDir() + mf := &LateManifest{ + Commands: LateCommands{ + {Name: "/weather", Handler: "scripts/weather.sh"}, + }, + } + p := writeTestPlugin(t, dir, "weather-plugin", mf) + p.Path = filepath.Join(dir, "weather-plugin") + writeExecutableShell(t, + filepath.Join(p.Path, "scripts/weather.sh"), + `echo "forecast: $(cat)"`) + pm.Add(p) + + out, handled, err := pm.HandleCommand(context.Background(), "/weather", []string{"sf"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !handled { + t.Fatal("expected handled=true for plugin with a Handler script") + } + if !strings.Contains(out, "sf") { + t.Fatalf("expected output to contain the arg, got %q", out) + } + if !strings.Contains(out, "forecast") { + t.Fatalf("expected output to contain the script's echo, got %q", out) + } +} + +// TestHandleCommand_NoPluginReturnsUnhandled asserts the fall-through +// behavior when no enabled plugin declares the requested command. +func TestHandleCommand_NoPluginReturnsUnhandled(t *testing.T) { + pm := NewPluginManager(t.TempDir()) + out, handled, err := pm.HandleCommand(context.Background(), "/nothing", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if handled { + t.Fatal("expected handled=false when no plugin declares /nothing") + } + if out != "" { + t.Fatalf("expected empty output, got %q", out) + } +} + +// TestHandleCommand_NoHandlerFallsBack covers the legacy string-form case +// where a plugin declares a command name via the bare string (no Handler +// script) — the contract is that HandleCommand returns handled=false so +// the TUI submits the input as a plain user prompt. +func TestHandleCommand_NoHandlerFallsBack(t *testing.T) { + pm := NewPluginManager(t.TempDir()) + dir := t.TempDir() + mf := &LateManifest{ + Commands: LateCommands{{Name: "/legacy"}}, + } + p := writeTestPlugin(t, dir, "legacy-plugin", mf) + p.Path = filepath.Join(dir, "legacy-plugin") + pm.Add(p) + + _, handled, err := pm.HandleCommand(context.Background(), "/legacy", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if handled { + t.Fatal("expected handled=false when command has no Handler script (legacy dispatch)") + } +} + +// TestHandleCommand_DuplicateLogsWarning exercises the resolution rule: +// when two enabled plugins declare the same command name with a Handler, +// the first one in sorted-by-name order wins, and the duplicate is logged +// to stderr. We do not assert the log content (test runner swallows +// stderr) — only that the call still succeeds. +func TestHandleCommand_DuplicateLogsWarning(t *testing.T) { + pm := NewPluginManager(t.TempDir()) + + // Alpha (wins because "alpha" < "beta"). + dirA := t.TempDir() + mfA := &LateManifest{ + Commands: LateCommands{{Name: "/shared", Handler: "scripts/a.sh"}}, + } + pA := writeTestPlugin(t, dirA, "alpha", mfA) + pA.Path = filepath.Join(dirA, "alpha") + writeExecutableShell(t, filepath.Join(pA.Path, "scripts/a.sh"), `echo alpha-result`) + pm.Add(pA) + + // Beta (shadowed). + dirB := t.TempDir() + mfB := &LateManifest{ + Commands: LateCommands{{Name: "/shared", Handler: "scripts/b.sh"}}, + } + pB := writeTestPlugin(t, dirB, "beta", mfB) + pB.Path = filepath.Join(dirB, "beta") + writeExecutableShell(t, filepath.Join(pB.Path, "scripts/b.sh"), `echo beta-result`) + pm.Add(pB) + + out, handled, err := pm.HandleCommand(context.Background(), "/shared", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !handled { + t.Fatal("expected handled=true from the winning plugin") + } + if !strings.Contains(out, "alpha-result") { + t.Fatalf("expected alpha's output (alpha is alphabetically first), got %q", out) + } +} + +// TestGetInlineTools_AggregatesAcrossPlugins ensures that tools declared +// independently by two plugins both appear in the result set, with names +// namespaced to ":" so identical bare names do not collide. +func TestGetInlineTools_AggregatesAcrossPlugins(t *testing.T) { + pm := NewPluginManager(t.TempDir()) + + dirA := t.TempDir() + mfA := &LateManifest{ + Tools: []LateToolManifest{ + {Name: "summarize", Description: "A's summarize", + Script: "scripts/a.sh", Parameters: jsonRaw(`{}`)}, + }, + } + pA := writeTestPlugin(t, dirA, "alpha", mfA) + pA.Path = filepath.Join(dirA, "alpha") + writeExecutableShell(t, filepath.Join(pA.Path, "scripts/a.sh"), `echo a`) + pm.Add(pA) + + dirB := t.TempDir() + mfB := &LateManifest{ + Tools: []LateToolManifest{ + {Name: "summarize", Description: "B's summarize", + Script: "scripts/b.sh", Parameters: jsonRaw(`{}`)}, + }, + } + pB := writeTestPlugin(t, dirB, "beta", mfB) + pB.Path = filepath.Join(dirB, "beta") + writeExecutableShell(t, filepath.Join(pB.Path, "scripts/b.sh"), `echo b`) + pm.Add(pB) + + tools := pm.GetInlineTools() + if len(tools) != 2 { + t.Fatalf("expected 2 namespaced tools, got %d", len(tools)) + } + seen := map[string]bool{} + for _, t1 := range tools { + seen[t1.Name] = true + if !strings.Contains(t1.Name, ":") { + t.Fatalf("expected namespaced tool name, got %q", t1.Name) + } + } + if !seen["alpha:summarize"] || !seen["beta:summarize"] { + t.Fatalf("expected alpha:summarize and beta:summarize, got %v", seen) + } +} diff --git a/internal/plugin/hooks.go b/internal/plugin/hooks.go new file mode 100644 index 0000000..3feb2ab --- /dev/null +++ b/internal/plugin/hooks.go @@ -0,0 +1,397 @@ +package plugin + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "late/internal/client" + "late/internal/common" +) + +// Per-hook execution limits. +const ( + hookTimeout = 15 * time.Second + maxStderrBytes = 4096 + hookCommandMax = 256 // max bytes for stdin payload before we error out +) + +// ToolCallHookPayload is written to the script's stdin when an OnToolCall +// hook fires. Plugins can inspect tool name + raw arguments JSON. +type ToolCallHookPayload struct { + Tool string `json:"tool"` + Arguments json.RawMessage `json:"arguments"` + Timestamp string `json:"timestamp"` +} + +// resolveHookPath resolves a hook script's relative path inside the plugin's +// directory and rejects any path that escapes it. Returns the cleaned +// absolute path or an error. +func resolveHookPath(pluginDir, relPath string) (string, error) { + if relPath == "" { + return "", fmt.Errorf("empty hook path") + } + abs := filepath.Clean(filepath.Join(pluginDir, relPath)) + rel, err := filepath.Rel(pluginDir, abs) + if err != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) { + return "", fmt.Errorf("hook path %q escapes plugin directory", relPath) + } + return abs, nil +} + +// runHook executes a single hook script with the given stdin payload. It is +// a no-op for empty script paths. Errors are returned but never panic. +func runHook(ctx context.Context, pluginDir string, scriptPath string, stdin []byte) (string, error) { + resolved, err := resolveHookPath(pluginDir, scriptPath) + if err != nil { + return "", err + } + + execCtx, cancel := context.WithTimeout(ctx, hookTimeout) + defer cancel() + + // exec.CommandContext's first arg is the lookup name; the second arg + // is argv[0]. Passing the resolved absolute path twice gives a stable, + // matches-lookup binary and avoids any three-index slicing (which is + // invalid for Go strings). + cmd := exec.CommandContext(execCtx, resolved, resolved) + setCmdSysProcAttr(cmd) + cmd.Dir = pluginDir + + if len(stdin) > 0 { + if len(stdin) > hookCommandMax { + return "", fmt.Errorf("hook stdin payload too large (%d > %d)", len(stdin), hookCommandMax) + } + cmd.Stdin = bytes.NewReader(stdin) + } + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err = cmd.Run() + + // Capture and forward stderr (truncated) + stderrBytes := stderr.Bytes() + if len(stderrBytes) > maxStderrBytes { + stderrBytes = stderrBytes[:maxStderrBytes] + } + stderrStr := strings.TrimRight(string(stderrBytes), "\n") + if stderrStr != "" { + fmt.Fprintf(os.Stderr, "[hook %s:%s] %s\n", filepath.Base(pluginDir), filepath.Base(resolved), stderrStr) + } + + if err != nil { + if execCtx.Err() == context.DeadlineExceeded { + return strings.TrimSpace(stdout.String()), fmt.Errorf("hook timed out after %v", hookTimeout) + } + return strings.TrimSpace(stdout.String()), fmt.Errorf("hook failed: %w", err) + } + + return strings.TrimSpace(stdout.String()), nil +} + +// hookData copies the plugin entry to avoid retaining the manager's mutex +// across goroutine boundaries. +type hookData struct { + pluginDir string + pluginName string + scripts []string +} + +// snapshotHooks returns every plugin that declares scripts for the given +// hook type, sorted by plugin name (then per-script filename for +// tie-breaking). The sort is the contract for sequential hook pipelines: +// onInput / onMessageSend must run plugins in a deterministic order so +// the stdout->stdin chain produces the same transformation every time. +func (pm *PluginManager) snapshotHooks(t string) []hookData { + pm.mu.RLock() + defer pm.mu.RUnlock() + + var out []hookData + for _, p := range pm.plugins { + if !p.Enabled || p.Late == nil || p.Late.Hooks == nil { + continue + } + var scripts []string + switch t { + case "tool-call": + scripts = p.Late.Hooks.OnToolCall + case "tool-result": + scripts = p.Late.Hooks.OnToolResult + case "session-start": + scripts = p.Late.Hooks.OnSessionStart + case "turn-start": + scripts = p.Late.Hooks.OnTurnStart + case "turn-end": + scripts = p.Late.Hooks.OnTurnEnd + case "message-send": + scripts = p.Late.Hooks.OnMessageSend + case "input": + scripts = p.Late.Hooks.OnInput + default: + return nil + } + if len(scripts) == 0 { + continue + } + out = append(out, hookData{ + pluginDir: p.Path, + pluginName: p.Name, + scripts: append([]string(nil), scripts...), + }) + } + sort.Slice(out, func(i, j int) bool { + if out[i].pluginName != out[j].pluginName { + return out[i].pluginName < out[j].pluginName + } + // Tie-break on first script filename so two plugins whose names + // match (e.g. "foo" and "foo") still order deterministically. + if len(out[i].scripts) == 0 || len(out[j].scripts) == 0 { + return len(out[i].scripts) < len(out[j].scripts) + } + return out[i].scripts[0] < out[j].scripts[0] + }) + return out +} + +// fanout fires all hooks across all plugins for the given event type in +// parallel. Each hook's stdout is logged; errors and stderr are forwarded +// but never abort the chain. +func (pm *PluginManager) fanout(ctx context.Context, eventType string, stdinFor func(pluginDir, script string, pluginName string) []byte) { + hooks := pm.snapshotHooks(eventType) + if len(hooks) == 0 { + return + } + var wg sync.WaitGroup + for _, h := range hooks { + for _, script := range h.scripts { + wg.Add(1) + go func(h hookData, script string) { + defer wg.Done() + payload := []byte(nil) + if stdinFor != nil { + payload = stdinFor(h.pluginDir, script, h.pluginName) + } + out, err := runHook(ctx, h.pluginDir, script, payload) + if err != nil { + fmt.Fprintf(os.Stderr, "[%s/%s/%s] %v\n", h.pluginName, eventType, script, err) + } + if out != "" { + fmt.Fprintf(os.Stderr, "[%s/%s/%s] %s\n", h.pluginName, eventType, script, out) + } + }(h, script) + } + } + wg.Wait() +} + +// BuildHookMiddlewares returns one common.ToolMiddleware per enabled plugin +// that declares OnToolCall hooks. Each middleware runs its plugin's scripts +// sequentially (so veto / argument mutation is deterministic across a +// plugin's own scripts) and then unconditionally calls next() so the rest +// of the chain runs normally — UNLESS a script returns the literal veto +// string "blocked", in which case the middleware aborts the chain with an +// error. +// +// hook contract (per script, in declaration order): +// - empty / non-JSON stdout → pass-through (call unchanged, next() runs) +// - JSON-valued stdout → REPLACES call.Function.Arguments (and next() runs) +// - literal stdout "blocked" → call is vetoed, next() is SKIPPED, error +// returned. The veto wins even if earlier scripts mutated arguments; +// this is the recommended way to write "block dangerous commands" hooks. +func (pm *PluginManager) BuildHookMiddlewares() []common.ToolMiddleware { + hooks := pm.snapshotHooks("tool-call") + if len(hooks) == 0 { + return nil + } + + mws := make([]common.ToolMiddleware, 0, len(hooks)) + for _, h := range hooks { + h := h // capture + mw := func(next common.ToolRunner) common.ToolRunner { + return func(ctx context.Context, call client.ToolCall) (string, error) { + for _, script := range h.scripts { + payload, _ := json.Marshal(ToolCallHookPayload{ + Tool: call.Function.Name, + Arguments: json.RawMessage(call.Function.Arguments), + Timestamp: time.Now().UTC().Format(time.RFC3339), + }) + out, err := runHook(ctx, h.pluginDir, script, payload) + if err != nil { + fmt.Fprintf(os.Stderr, "[%s/onToolCall/%s] %v\n", h.pluginName, script, err) + continue + } + if out == "blocked" { + return "", fmt.Errorf("tool call %q blocked by plugin %q", call.Function.Name, h.pluginName) + } + if out != "" && json.Valid([]byte(out)) { + call.Function.Arguments = out + } + } + return next(ctx, call) + } + } + mws = append(mws, mw) + } + return mws +} + +// CallOnSessionStartHooks fires OnSessionStart hooks for all enabled plugins +// in parallel. Errors are logged; this never returns a fatal error. +func (pm *PluginManager) CallOnSessionStartHooks() { + pm.fanout(context.Background(), "session-start", nil) +} + +// CallOnTurnStartHooks fires OnTurnStart hooks for all enabled plugins in +// parallel before the agent's response cycle begins. fire-and-forget. +func (pm *PluginManager) CallOnTurnStartHooks() { + pm.fanout(context.Background(), "turn-start", nil) +} + +// CallOnTurnEndHooks fires OnTurnEnd hooks for all enabled plugins in +// parallel after the agent's response cycle ends. fire-and-forget. +func (pm *PluginManager) CallOnTurnEndHooks() { + pm.fanout(context.Background(), "turn-end", nil) +} + +// BuildToolResultMiddlewares returns post-execution ToolMiddlewares that +// fire onToolResult hooks after each tool completes successfully. +// +// Unlike BuildHookMiddlewares (which creates one middleware per plugin so +// each can independently veto/mutate the call pre-flight), this returns a +// single middleware that delegates to CallOnToolResultHooks — which already +// iterates every enabled plugin's scripts in deterministic order. +// +// If the inner runner returns an error (tool execution failed), the +// hooks are skipped and the original error passes through unchanged. +// A hook that returns "blocked" generates a veto error visible to the +// LLM caller as the tool error message. +func (pm *PluginManager) BuildToolResultMiddlewares() []common.ToolMiddleware { + return []common.ToolMiddleware{ + func(next common.ToolRunner) common.ToolRunner { + return func(ctx context.Context, call client.ToolCall) (string, error) { + result, err := next(ctx, call) + if err != nil { + return result, err + } + mutated, hookErr := pm.CallOnToolResultHooks(ctx, call.Function.Name, []byte(result)) + if hookErr != nil { + return "", hookErr + } + return string(mutated), nil + } + }, + } +} + +// CallOnToolResultHooks fires OnToolResult hooks sequentially after each +// tool invocation completes. The payload is JSON of +// {"tool": name, "result": resultBytes} on each plugin's stdin. Per-script +// contract matches BuildHookMiddlewares so plugins can reason uniformly: +// - empty stdout → pass-through (result unchanged) +// - non-empty JSON stdout → REPLACE result bytes with the hook's stdout +// - literal stdout "blocked" → drop the result; the hook returns an error +// (so callers surface a "tool result was blocked by plugin X" message) +// - errors logged + skipped, never abort the chain +// +// Ordering is deterministic: snapshotHooks() sorts by plugin name then +// first script, so the same plugin chain runs in the same order on every +// invocation. Returns the (possibly mutated) result bytes plus any veto +// error from a "blocked" return. +func (pm *PluginManager) CallOnToolResultHooks(ctx context.Context, tool string, result []byte) ([]byte, error) { + hooks := pm.snapshotHooks("tool-result") + if len(hooks) == 0 { + return result, nil + } + if ctx == nil { + ctx = context.Background() + } + for _, h := range hooks { + for _, script := range h.scripts { + payload, _ := json.Marshal(map[string]any{ + "tool": tool, + "result": json.RawMessage(result), + }) + out, err := runHook(ctx, h.pluginDir, script, payload) + if err != nil { + fmt.Fprintf(os.Stderr, "[%s/onToolResult/%s] %v\n", h.pluginName, script, err) + continue + } + if out == "blocked" { + return nil, fmt.Errorf("tool result %q blocked by plugin %q", tool, h.pluginName) + } + if out != "" && json.Valid([]byte(out)) { + result = []byte(out) + } + } + } + return result, nil +} + +// CallOnInputHooks applies OnInput hooks sequentially (after sort by plugin +// name) and returns the transformed message. Each hook sees the output of +// the previous hook. If no hooks are registered, the input is returned +// unchanged. The supplied context is forwarded to each hook so the TUI +// can cancel a misbehaving plugin via its root context. +func (pm *PluginManager) CallOnInputHooks(ctx context.Context, text string) string { + hooks := pm.snapshotHooks("input") + if len(hooks) == 0 || text == "" { + return text + } + if ctx == nil { + ctx = context.Background() + } + current := text + for _, h := range hooks { + for _, script := range h.scripts { + out, err := runHook(ctx, h.pluginDir, script, []byte(current)) + if err != nil { + fmt.Fprintf(os.Stderr, "[%s/onInput/%s] %v\n", h.pluginName, script, err) + continue + } + if out != "" { + current = out + } + } + } + return current +} + +// HookedMessage applies OnMessageSend hooks sequentially (after sort by +// plugin name) and returns the transformed message. By default each hook +// sees the output of the previous hook. If no hooks are registered, the +// input is returned unchanged. The supplied context is forwarded to +// each hook so the TUI can cancel a misbehaving plugin. +func (pm *PluginManager) HookedMessage(ctx context.Context, text string) string { + hooks := pm.snapshotHooks("message-send") + if len(hooks) == 0 || text == "" { + return text + } + if ctx == nil { + ctx = context.Background() + } + current := text + for _, h := range hooks { + for _, script := range h.scripts { + out, err := runHook(ctx, h.pluginDir, script, []byte(current)) + if err != nil { + fmt.Fprintf(os.Stderr, "[%s/onMessageSend/%s] %v\n", h.pluginName, script, err) + continue + } + if out != "" { + fmt.Fprintf(os.Stderr, "[%s/onMessageSend/%s] transformed message\n", h.pluginName, script) + current = out + } + } + } + return current +} diff --git a/internal/plugin/hooks_themes_test.go b/internal/plugin/hooks_themes_test.go new file mode 100644 index 0000000..5be318e --- /dev/null +++ b/internal/plugin/hooks_themes_test.go @@ -0,0 +1,409 @@ +package plugin + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "late/internal/client" + "late/internal/common" +) + +// Compile-time assertion: HookedMessage now takes a context so the TUI +// can cancel a misbehaving plugin. Existing tests below were updated +// to pass context.Background(). + +// helper: write a small POSIX shell script +func writeExecutableShell(t *testing.T, path, body string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("shell-script test only runs on POSIX") + } + if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body+"\n"), 0755); err != nil { + t.Fatalf("write shell: %v", err) + } +} + +// helper: write a fake plugin into a temp dir (the rich, namespaced +// helper used across plugin tests). +func writeTestPlugin(t *testing.T, parentDir, name string, manifest *LateManifest) *InstalledPlugin { + t.Helper() + pluginDir := filepath.Join(parentDir, name) + if err := os.MkdirAll(pluginDir, 0755); err != nil { + t.Fatalf("mkdir plugin: %v", err) + } + pkg := PackageJSON{Name: name, Version: "1.0.0", Late: manifest} + b, _ := json.Marshal(pkg) + if err := os.WriteFile(filepath.Join(pluginDir, "package.json"), b, 0644); err != nil { + t.Fatalf("write package.json: %v", err) + } + p, err := LoadPlugin(pluginDir) + if err != nil { + t.Fatalf("LoadPlugin: %v", err) + } + _ = SavePluginMeta(p) + return p +} + +// TestBuildToolResultMiddlewares_PostExecMutate: the post-execution +// middleware calls the inner runner first, then applies onToolResult hooks. +func TestBuildToolResultMiddlewares_PostExecMutate(t *testing.T) { + root := t.TempDir() + pluginsDir := filepath.Join(root, "plugins") + if err := os.MkdirAll(pluginsDir, 0o755); err != nil { + t.Fatal(err) + } + + hookDir := filepath.Join(pluginsDir, "muter") + if err := os.MkdirAll(hookDir, 0o755); err != nil { + t.Fatal(err) + } + script := filepath.Join(hookDir, "mute.sh") + if err := os.WriteFile(script, []byte("#!/bin/sh\necho '{\"mutated\":true}'\n"), 0o755); err != nil { + t.Fatal(err) + } + + pkg := map[string]any{ + "name": "muter", + "version": "1.0.0", + "late": map[string]any{ + "hooks": map[string]any{ + "onToolResult": []string{"mute.sh"}, + }, + }, + } + pkgBytes, _ := json.Marshal(pkg) + if err := os.WriteFile(filepath.Join(hookDir, "package.json"), pkgBytes, 0o644); err != nil { + t.Fatal(err) + } + + pm := NewPluginManager(pluginsDir) + if err := pm.Discover(); err != nil { + t.Fatal(err) + } + p := pm.Plugin("muter") + if p == nil { + t.Fatal("expected muter plugin to be discovered") + } + p.Enabled = true + + mws := pm.BuildToolResultMiddlewares() + if len(mws) != 1 { + t.Fatalf("expected 1 middleware, got %d", len(mws)) + } + + innerRan := false + inner := func(ctx context.Context, call client.ToolCall) (string, error) { + innerRan = true + return `{"original":true}`, nil + } + + wrapped := mws[0](inner) + result, err := wrapped(context.Background(), client.ToolCall{ + Function: client.FunctionCall{Name: "test_tool"}, + }) + if err != nil { + t.Fatalf("middleware error: %v", err) + } + if !innerRan { + t.Fatal("inner runner was never called") + } + if !strings.Contains(result, `"mutated":true`) { + t.Fatalf("expected mutated result, got %q", result) + } +} + +// TestBuildToolResultMiddlewares_SkipOnError: if the inner runner returns +// an error, the middleware does NOT call onToolResult hooks. +func TestBuildToolResultMiddlewares_SkipOnError(t *testing.T) { + pm := NewPluginManager(t.TempDir()) + mws := pm.BuildToolResultMiddlewares() + if len(mws) != 1 { + t.Fatalf("expected 1 middleware, got %d", len(mws)) + } + + inner := func(ctx context.Context, call client.ToolCall) (string, error) { + return "", os.ErrNotExist + } + wrapped := mws[0](inner) + _, err := wrapped(context.Background(), client.ToolCall{ + Function: client.FunctionCall{Name: "failer"}, + }) + if err == nil { + t.Fatal("expected inner error to propagate") + } +} + +// 1. Hook path containment: rejects escaping paths +func TestResolveHookPath_RejectsTraversal(t *testing.T) { + pluginDir := t.TempDir() + if _, err := resolveHookPath(pluginDir, "../other.sh"); err == nil { + t.Fatal("expected ../other.sh to escape plugin dir") + } + if _, err := resolveHookPath(pluginDir, "/etc/passwd"); err == nil { + t.Fatal("expected absolute /etc/passwd to escape plugin dir") + } + if _, err := resolveHookPath(pluginDir, ""); err == nil { + t.Fatal("expected empty path to be rejected") + } +} + +// 2. Hook path containment: allows contained paths +func TestResolveHookPath_AllowsContained(t *testing.T) { + pluginDir := t.TempDir() + got, err := resolveHookPath(pluginDir, "subdir/hook.sh") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.HasPrefix(got, pluginDir) { + t.Fatalf("expected resolved path under plugin dir, got %q", got) + } +} + +// 3. Hook execution: happy path reads from stdin +func TestRunHook_HappyPath(t *testing.T) { + pluginDir := t.TempDir() + script := filepath.Join(pluginDir, "echo.sh") + writeExecutableShell(t, script, `cat`) + out, err := runHook(context.Background(), pluginDir, "echo.sh", []byte("hello")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out != "hello" { + t.Fatalf("expected hello, got %q", out) + } +} + +// 4. Hook execution: timeout enforced +func TestRunHook_TimeoutEnforced(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell test only on POSIX") + } + pluginDir := t.TempDir() + script := filepath.Join(pluginDir, "sleep.sh") + writeExecutableShell(t, script, `sleep 30`) + // Override hookTimeout via short ctx to keep test fast; we pass a + // shorter context via WithTimeout to be portable. + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + _, err := runHook(ctx, pluginDir, "sleep.sh", nil) + if err == nil { + t.Fatal("expected timeout error") + } +} + +// 5. HookedMessage: empty/no hooks returns input unchanged +func TestHookedMessage_NoHooksReturnsInput(t *testing.T) { + pm := NewPluginManager(t.TempDir()) + if got := pm.HookedMessage(context.Background(), "hi"); got != "hi" { + t.Fatalf("expected unchanged, got %q", got) + } +} + +// 6. HookedMessage: applies OnMessageSend script transform +func TestHookedMessage_TransformsText(t *testing.T) { + pm := NewPluginManager(t.TempDir()) + pluginDir := t.TempDir() + mf := &LateManifest{Hooks: &LateHooksManifest{OnMessageSend: []string{"wrap.sh"}}} + p := writeTestPlugin(t, pluginDir, "msg-wrap", mf) + writeExecutableShell(t, filepath.Join(p.Path, "wrap.sh"), `cat; echo`) + p.Path = filepath.Join(pluginDir, "msg-wrap") + pm.Add(p) + got := pm.HookedMessage(context.Background(), "hi") + if got != "hi" { + t.Fatalf("expected 'hi', got %q (note: shell `echo` without args prints empty)", got) + } +} + +// 7. BuildHookMiddlewares: returns one middleware per plugin +func TestBuildHookMiddlewares_PerPlugin(t *testing.T) { + pm := NewPluginManager(t.TempDir()) + for i := 0; i < 3; i++ { + dir := t.TempDir() + name := "p" + string(rune('1'+i)) + mf := &LateManifest{Hooks: &LateHooksManifest{OnToolCall: []string{"noop.sh"}}} + p := writeTestPlugin(t, dir, name, mf) + p.Path = filepath.Join(dir, name) + pm.Add(p) + } + mws := pm.BuildHookMiddlewares() + if len(mws) != 3 { + t.Fatalf("expected 3 middlewares, got %d", len(mws)) + } + // Verify signature is correct: invoking the middleware with a noop next + // should still call next and return its result. + var called bool + next := common.ToolRunner(func(ctx context.Context, call client.ToolCall) (string, error) { + called = true + return "ok", nil + }) + for _, mw := range mws { + runner := mw(next) + out, err := runner(context.Background(), client.ToolCall{ + Function: client.FunctionCall{Name: "anything", Arguments: "{}"}, + }) + if err != nil { + t.Fatalf("middleware returned error: %v", err) + } + if out != "ok" { + t.Fatalf("middleware didn't pass through, got %q", out) + } + } + if !called { + t.Fatal("expected 'next' to be called") + } +} + +// 8. BuildHookMiddlewares: empty when no plugins have OnToolCall +func TestBuildHookMiddlewares_EmptyWhenNoHooks(t *testing.T) { + pm := NewPluginManager(t.TempDir()) + dir := t.TempDir() + mf := &LateManifest{} // no hooks at all + p := writeTestPlugin(t, dir, "silent", mf) + p.Path = filepath.Join(dir, "silent") + pm.Add(p) + if mws := pm.BuildHookMiddlewares(); len(mws) != 0 { + t.Fatalf("expected 0, got %d", len(mws)) + } +} + +// 9. CallOnSessionStartHooks runs without panic on empty manager +func TestCallOnSessionStartHooks_NoPanicOnEmpty(t *testing.T) { + pm := NewPluginManager(t.TempDir()) + pm.CallOnSessionStartHooks() +} + +// NOTE: Tests for ResolveRenderTheme and LateTheme live in +// internal/tui/theme_test.go since the helper lives in the tui +// package. Putting them here would create a circular import. + +// 13. Theme path resolution: rejects traversal +func TestResolveThemePath_RejectsTraversal(t *testing.T) { + pluginDir := t.TempDir() + if _, err := resolveThemePath(pluginDir, "../../etc/theme.json"); err == nil { + t.Fatal("expected traversal to be rejected") + } + if _, err := resolveThemePath(pluginDir, "/etc/passwd"); err == nil { + t.Fatal("expected absolute to be rejected") + } +} + +// 14. Theme load: rejects missing name +func TestLoadThemeFile_RequiresName(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "bad.json") + if err := os.WriteFile(p, []byte(`{"palette": {}}`), 0644); err != nil { + t.Fatal(err) + } + if _, err := loadThemeFile(p); err == nil { + t.Fatal("expected error when 'name' missing") + } +} + +// 15. GetTheme: bare name lookup across plugins +func TestGetTheme_BareNameLookup(t *testing.T) { + pm := NewPluginManager(t.TempDir()) + dir := t.TempDir() + writeJSON(t, dir, "ocean.json", `{"name":"ocean","palette":{"bg":"#000"}}`) + mf := &LateManifest{Themes: []string{"ocean.json"}} + p := writeTestPlugin(t, dir, "theme-plugin", mf) + pm.Add(p) + + info, err := pm.GetTheme("ocean") + if err != nil || info == nil { + t.Fatalf("expected to find 'ocean', got err=%v info=%v", err, info) + } + if info.ID != "theme-plugin:ocean" { + t.Fatalf("unexpected id: %s", info.ID) + } +} + +// 16. GetTheme: namespaced lookup +func TestGetTheme_NamespacedLookup(t *testing.T) { + pm := NewPluginManager(t.TempDir()) + dir := t.TempDir() + writeJSON(t, dir, "theme.json", `{"name":"v1"}`) + mf := &LateManifest{Themes: []string{"theme.json"}} + p := writeTestPlugin(t, dir, "green", mf) + pm.Add(p) + + info, err := pm.GetTheme("green:v1") + if err != nil || info == nil { + t.Fatalf("expected namespace match, got err=%v info=%v", err, info) + } +} + +// 17. GetTheme: empty returns (nil, nil) +func TestGetTheme_EmptyReturnsNilNil(t *testing.T) { + pm := NewPluginManager(t.TempDir()) + info, err := pm.GetTheme("") + if err != nil || info != nil { + t.Fatalf("expected nil/nil for empty id, got info=%v err=%v", info, err) + } +} + +// 18. AllThemes: aggregates across enabled plugins only +func TestAllThemes_AggregatesEnabledOnly(t *testing.T) { + pm := NewPluginManager(t.TempDir()) + dir := t.TempDir() + writeJSON(t, dir, "a.json", `{"name":"alpha"}`) + mf := &LateManifest{Themes: []string{"a.json"}} + p := writeTestPlugin(t, dir, "alpha", mf) + p.Enabled = false + pm.Add(p) + + got := pm.AllThemes() + if len(got) != 0 { + t.Fatalf("expected 0 themes from disabled plugin, got %d", len(got)) + } + p.Enabled = true + got = pm.AllThemes() + if len(got) != 1 { + t.Fatalf("expected 1 theme after enabling, got %d", len(got)) + } +} + +// 19. AllThemes: skips unparseable files +func TestAllThemes_SkipsUnparseable(t *testing.T) { + pm := NewPluginManager(t.TempDir()) + dir := t.TempDir() + mf := &LateManifest{Themes: []string{"missing.json", "garbage.json"}} + p := writeTestPlugin(t, dir, "broken", mf) + // missing.json doesn't exist; garbage.json is not valid + _ = os.WriteFile(filepath.Join(p.Path, "garbage.json"), []byte("not json{{"), 0644) + pm.Add(p) + + got := pm.AllThemes() + if len(got) != 0 { + t.Fatalf("expected 0 themes, got %d", len(got)) + } +} + +// 20. findTheme: name-mismatch returns error +func TestFindTheme_NameMismatch(t *testing.T) { + pm := NewPluginManager(t.TempDir()) + dir := t.TempDir() + writeJSON(t, dir, "x.json", `{"name":"realname"}`) + mf := &LateManifest{Themes: []string{"x.json"}} + p := writeTestPlugin(t, dir, "finder", mf) + pm.Add(p) + + _, err := pm.findTheme("finder", "wrongname") + if err == nil { + t.Fatal("expected error for name mismatch") + } +} + +// ---------------------------------------------------------------------------- + +func writeJSON(t *testing.T, dir, name, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0644); err != nil { + t.Fatalf("write json: %v", err) + } +} diff --git a/internal/plugin/installer.go b/internal/plugin/installer.go new file mode 100644 index 0000000..1ce26f4 --- /dev/null +++ b/internal/plugin/installer.go @@ -0,0 +1,656 @@ +package plugin + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// runCommand is the package-level seam used by Update to spawn npm and +// git. Tests override it to assert the exact argv shape without invoking +// a real package manager; production never touches it. +var runCommand = func(ctx context.Context, name string, args ...string) error { + return exec.CommandContext(ctx, name, args...).Run() +} + +// runCommandOutput is the same seam but captures stdout/stderr; used for +// commands whose output Update needs to inspect (e.g. version probe). +var runCommandOutput = func(ctx context.Context, name string, args ...string) ([]byte, error) { + return exec.CommandContext(ctx, name, args...).CombinedOutput() +} + +// InstallFromNpm installs a plugin from npm by running 'npm install'. +// If project is true and a project dir is configured, installs into the project-local dir. +func InstallFromNpm(pm *PluginManager, pkgName string, projectLocal ...bool) (*InstalledPlugin, error) { + project := len(projectLocal) > 0 && projectLocal[0] + targetDir := pm.TargetDir(project) + + if err := os.MkdirAll(targetDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create plugins directory: %w", err) + } + + cmd := exec.Command("npm", "install", "--prefix", targetDir, pkgName) + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("npm install failed: %w", err) + } + + // npm installs into node_modules/ + npmDir := filepath.Join(targetDir, "node_modules", pkgName) + if _, err := os.Stat(npmDir); os.IsNotExist(err) { + // Try scoped package path: @scope/name -> node_modules/@scope/name + parts := strings.SplitN(pkgName, "/", 2) + if len(parts) == 2 && strings.HasPrefix(pkgName, "@") { + npmDir = filepath.Join(targetDir, "node_modules", parts[0], parts[1]) + } + if _, err := os.Stat(npmDir); os.IsNotExist(err) { + return nil, fmt.Errorf("npm installed but package not found at expected path %s", npmDir) + } + } + + // Create parent directories for scoped packages (e.g. @scope/name) + linkDir := filepath.Join(targetDir, pkgName) + if err := os.MkdirAll(filepath.Dir(linkDir), 0755); err != nil { + return nil, fmt.Errorf("failed to create parent directories for symlink: %w", err) + } + + if _, err := os.Lstat(linkDir); err == nil { + os.Remove(linkDir) + } + + // Use relative symlink for portability. + // The symlink resolves relative to its OWN parent directory, not the + // plugins root — for scoped packages (@scope/name) the link is nested + // one level deeper, so compute from linkDir's parent. + rel, err := filepath.Rel(filepath.Dir(linkDir), npmDir) + if err != nil { + return nil, fmt.Errorf("failed to compute relative path: %w", err) + } + if err := os.Symlink(rel, linkDir); err != nil { + return nil, fmt.Errorf("failed to create symlink: %w", err) + } + + // Load the plugin from the symlinked directory + plugin, err := LoadPlugin(linkDir) + if err != nil { + return nil, fmt.Errorf("failed to load installed plugin: %w", err) + } + plugin.SourceType = "npm" + plugin.Source = pkgName + + if err := SavePluginMeta(plugin); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Warning: failed to save plugin metadata: %v\n", err) + } + + pm.Add(plugin) + return plugin, nil +} + +// InstallFromGit installs a plugin from a Git repository. +// Supports URLs like https://github.com/user/repo.git and shorthand like github:user/repo. +// If project is true and a project dir is configured, installs into the project-local dir. +func InstallFromGit(pm *PluginManager, url string, projectLocal ...bool) (*InstalledPlugin, error) { + project := len(projectLocal) > 0 && projectLocal[0] + destDir := pm.TargetDir(project) + + // Determine plugin name from URL + name := pluginNameFromURL(url) + targetDir := filepath.Join(destDir, name) + + if err := os.MkdirAll(destDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create plugins directory: %w", err) + } + + if _, err := os.Stat(targetDir); err == nil { + return nil, fmt.Errorf("plugin %s already exists at %s", name, targetDir) + } + + // Expand shorthand: github:user/repo -> https://github.com/user/repo.git + gitURL := expandGitURL(url) + + cmd := exec.Command("git", "clone", "--depth", "1", gitURL, targetDir) + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + // Clean up partial clone so the user's next attempt isn't blocked + // by an "already exists" error against a half-populated directory. + if rmErr := os.RemoveAll(targetDir); rmErr != nil { + _, _ = fmt.Fprintf(os.Stderr, "Warning: failed to remove partial clone dir %s: %v\n", targetDir, rmErr) + } + return nil, fmt.Errorf("git clone failed: %w", err) + } + + // Remove .git to keep the store clean + os.RemoveAll(filepath.Join(targetDir, ".git")) + + plugin, err := LoadPlugin(targetDir) + if err != nil { + return nil, fmt.Errorf("failed to load installed plugin: %w", err) + } + plugin.SourceType = "git" + plugin.Source = url + + if err := SavePluginMeta(plugin); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Warning: failed to save plugin metadata: %v\n", err) + } + + pm.Add(plugin) + return plugin, nil +} + +// InstallFromLocal installs a plugin from a local path by symlinking it +// into the plugins directory. This is equivalent to `late plugin link`. +// If project is true and a project dir is configured, installs into the project-local dir. +func InstallFromLocal(pm *PluginManager, localPath string, projectLocal ...bool) (*InstalledPlugin, error) { + project := len(projectLocal) > 0 && projectLocal[0] + destDir := pm.TargetDir(project) + + absPath, err := filepath.Abs(localPath) + if err != nil { + return nil, fmt.Errorf("failed to resolve path: %w", err) + } + + if _, err := os.Stat(absPath); os.IsNotExist(err) { + return nil, fmt.Errorf("local path does not exist: %s", absPath) + } + + // Soft sanity check: warn when the requested local plugin lives far + // outside the user's normal scope (home or current directory). We don't + // hard-reject because legitimate use cases exist (e.g. /opt/dev-plugins), + // but the warning helps spot typos and accidental malicious-looking links. + if isSuspiciousPluginPath(absPath) { + fmt.Fprintf(os.Stderr, "Warning: linking plugin from path outside $HOME or CWD: %s\n", absPath) + } + + plugin, err := LoadPlugin(absPath) + if err != nil { + return nil, fmt.Errorf("failed to load plugin from %s: %w", absPath, err) + } + + // Create a symlink in the plugins directory + if err := os.MkdirAll(destDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create plugins directory: %w", err) + } + + targetDir := filepath.Join(destDir, plugin.Name) + if err := os.MkdirAll(filepath.Dir(targetDir), 0755); err != nil { + return nil, fmt.Errorf("failed to create parent directories for symlink: %w", err) + } + + if _, err := os.Lstat(targetDir); err == nil { + os.Remove(targetDir) + } + + if err := os.Symlink(absPath, targetDir); err != nil { + return nil, fmt.Errorf("failed to create symlink: %w", err) + } + + plugin.Path = targetDir + plugin.SourceType = "local" + plugin.Source = absPath + + if err := SavePluginMeta(plugin); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Warning: failed to save plugin metadata: %v\n", err) + } + + pm.Add(plugin) + return plugin, nil +} + +// Install is the unified entry point for the `late plugin install` CLI. +// It inspects the source string and dispatches to InstallFromGit / +// InstallFromLocal / InstallFromNpm / marketplace-resolved target based +// on the shape (URL? relative path? absolute path? scoped name?). +// +// classifier rules (in order): +// 1. looks like a git URL (https?://, git@, github:, gitlab:, bitbucket:) → InstallFromGit +// 2. looks like a local filesystem path (./, ../, ~/, /) → InstallFromLocal +// 3. looks like an npm package name (always contains a "/" like @scope/name) → InstallFromNpm +// 4. bare name → MarketplaceClient.Resolve. On hit: dispatch to the resolved target; +// on miss (ErrMarketplaceMiss OR network error): fall back to InstallFromNpm as a +// "treat-as-npm-package" path so users can `late plugin install some-pkg` without +// requiring registry support. +// +// projectLocal mirrors the `--project`/`--local` flag and forwards to every +// underlying installer unchanged. mc may be nil; Install then uses DefaultRegistry(). +func Install(pm *PluginManager, source string, mc *MarketplaceClient, projectLocal bool) (*InstalledPlugin, error) { + if source == "" { + return nil, fmt.Errorf("install: empty source") + } + if looksLikeGitSource(source) { + return InstallFromGit(pm, source, projectLocal) + } + if looksLikeLocalPath(source) { + return InstallFromLocal(pm, source, projectLocal) + } + if looksLikeNpmPackage(source) { + return InstallFromNpm(pm, source, projectLocal) + } + // Bare name: marketplace first, then npm-as-fallback. + if mc == nil { + mc2 := DefaultRegistry() + mc = &mc2 + } + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + entry, err := mc.Resolve(ctx, source) + if err == nil { + switch entry.SourceType() { + case "npm": + return InstallFromNpm(pm, entry.Npm, projectLocal) + case "git": + return InstallFromGit(pm, entry.Git, projectLocal) + } + } + // Miss or error → keep treating the bare name as an npm package name. + // This is the OMP behavior: bare names that aren't in the registry just + // fall through to npm. We log a notice so the user understands the path. + _, _ = fmt.Fprintf(os.Stderr, + "Notice: marketplace did not match %q (%v); trying npm\n", source, err) + return InstallFromNpm(pm, source, projectLocal) +} + +// looksLikeGitSource reports whether source is recognizable as a git URL +// or one of the supported shorthand hosts. We deliberately accept any host +// (github:, gitlab:, bitbucket:, or a full URL) but reject bare scopes or +// relative paths so the npm/local branches stay unambiguous. +func looksLikeGitSource(source string) bool { + if strings.Contains(source, "://") || strings.HasPrefix(source, "git@") { + return true + } + for _, host := range []string{"github:", "gitlab:", "bitbucket:"} { + if strings.HasPrefix(source, host) { + return true + } + } + return false +} + +// looksLikeLocalPath reports whether source is a filesystem path. +// Recognized shapes: "./...", "../...", "~/...", or absolute ("/..."). +func looksLikeLocalPath(source string) bool { + if strings.HasPrefix(source, "./") || strings.HasPrefix(source, "../") || strings.HasPrefix(source, "~/") { + return true + } + return strings.HasPrefix(source, "/") +} + +// looksLikeNpmPackage reports whether source is shaped like an npm +// package name. Modern npm naming always requires a slash (scoped or +// hierarchical), so this is a safe heuristic. Bare names are intentionally +// NOT classified as npm here — they go through the marketplace branch. +func looksLikeNpmPackage(source string) bool { + if !strings.Contains(source, "/") { + return false + } + // Scoped shortnames must start with '@' and have a slash after the scope. + if strings.HasPrefix(source, "@") { + parts := strings.SplitN(source, "/", 2) + return len(parts) == 2 && parts[0] != "" && parts[1] != "" + } + return true +} + +// updateGitTempSuffix is appended to the existing plugin dir name when +// Update clones the new source for an atomic swap. The dot prefix hides +// it from the PollingWatcher's directory-listing (it will be renamed in +// the same Write-Lock window — race is bounded by the mutex). +const updateGitTempSuffix = ".late-update-" + +// Update re-installs the named plugin in place using the Source originally +// captured at install time. SourceType drives the dispatch: +// - "git" → fresh shallow clone into a sibling tmp dir, atomic rename +// - "npm" → `npm install --prefix @latest --no-save --quiet` in place +// - "marketplace" → look Source up via mc.Resolve, then dispatch +// - "local" or empty Source → error (dev symlinks and legacy records can't be auto-updated) +// +// Concurrency: we hold RLock only long enough to fetch the InstalledPlugin +// snapshot, then run all docker/exec work lock-free. The atomic swap +// (Rename/symlink recreate) happens under pm.mu.Lock() so the watcher +// (which holds the lock in Discover) sees a stable view. +func Update(pm *PluginManager, name string, mc *MarketplaceClient) (*InstalledPlugin, error) { + if pm == nil { + return nil, fmt.Errorf("update: nil plugin manager") + } + if name == "" { + return nil, fmt.Errorf("update: empty plugin name") + } + + // Snapshot phase — short RLock, no I/O. + pm.mu.RLock() + old := pm.plugins[name] + pm.mu.RUnlock() + if old == nil { + return nil, fmt.Errorf("update: plugin %s is not installed", name) + } + source := old.Source + sourceType := old.SourceType + + if sourceType == "local" { + return nil, fmt.Errorf("update: %s is a dev symlink; edit the source folder then run `late plugin remove && late plugin link`", name) + } + if source == "" { + return nil, fmt.Errorf("update: no install source recorded for %s; reinstall explicitly with `late plugin install `", name) + } + + // Pre-resolve marketplace source if needed (lock-free). + if sourceType == "marketplace" { + if mc == nil { + mc2 := DefaultRegistry() + mc = &mc2 + } + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + entry, err := mc.Resolve(ctx, source) + if err != nil { + return nil, fmt.Errorf("update: marketplace lookup for %s failed: %w", name, err) + } + // Reflect the resolved target into the procedure below by + // recursing once via a synthetic SourceType swap. We don't + // mutate the live InstalledPlugin until the swap window. + switch entry.SourceType() { + case "npm": + source = entry.Npm + sourceType = "npm" + case "git": + source = entry.Git + sourceType = "git" + default: + return nil, fmt.Errorf("update: marketplace entry for %s has no installable target", name) + } + } + + // Determine the target directory the new artifacts should land in. + // Project-local plugins live under pm.ProjectDir(); everything else + // goes under pm.PluginsDir(). Use the existing plugin's parent as the + // source of truth so we never mis-route an update. + targetDir := filepath.Dir(old.Path) + + switch sourceType { + case "git": + return updateGit(pm, old, source, targetDir) + case "npm": + return updateNpm(pm, old, source, targetDir) + default: + return nil, fmt.Errorf("update: unsupported source type %q for %s", sourceType, name) + } +} + +// updateGit clones `source` into a unique sibling tmpdir, strips its +// .git subdir, then atomically swaps it over the existing plugin dir. +func updateGit(pm *PluginManager, old *InstalledPlugin, source, targetDir string) (*InstalledPlugin, error) { + tmp, err := os.MkdirTemp(targetDir, "."+filepath.Base(old.Path)+updateGitTempSuffix) + if err != nil { + return nil, fmt.Errorf("update: cannot create temp clone dir: %w", err) + } + defer func() { + // Best-effort cleanup if we never made it to the swap window. + if _, statErr := os.Stat(tmp); statErr == nil { + _ = os.RemoveAll(tmp) + } + }() + + gitURL := expandGitURL(source) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + if err := runCommand(ctx, "git", "clone", "--depth", "1", gitURL, tmp); err != nil { + return nil, fmt.Errorf("update: git clone %s failed: %w", gitURL, err) + } + // Match the fresh-install contract: keep the store clean. + _ = os.RemoveAll(filepath.Join(tmp, ".git")) + + // Swap window. Hold the write lock so a concurrent Discover() call + // from the PollingWatcher can't race us mid-rename. + pm.mu.Lock() + defer pm.mu.Unlock() + if _, statErr := os.Stat(old.Path); statErr != nil { + return nil, fmt.Errorf("update: original plugin dir vanished mid-update: %w", statErr) + } + if err := os.RemoveAll(old.Path); err != nil { + return nil, fmt.Errorf("update: cannot remove old plugin dir: %w", err) + } + if err := os.Rename(tmp, old.Path); err != nil { + return nil, fmt.Errorf("update: cannot move new dir into place: %w", err) + } + + loaded, err := LoadPlugin(old.Path) + if err != nil { + return nil, fmt.Errorf("update: cannot load refreshed plugin: %w", err) + } + loaded.Source = old.Source + loaded.SourceType = old.SourceType + if !old.Enabled { + loaded.Enabled = false + } + if err := SavePluginMeta(loaded); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Warning: failed to save updated plugin metadata: %v\n", err) + } + pm.plugins[loaded.Name] = loaded + return loaded, nil +} + +// updateNpm runs `npm install --prefix @latest --no-save --quiet` +// in the existing target directory, then re-creates the symlink the +// installer would have created on first install. Output is silenced on +// success and surfaced in the error on failure so user noise stays low. +func updateNpm(pm *PluginManager, old *InstalledPlugin, source, targetDir string) (*InstalledPlugin, error) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + out, err := runCommandOutput(ctx, "npm", "install", + "--prefix", targetDir, source+"@latest", "--no-save", "--quiet") + if err != nil { + return nil, fmt.Errorf("update: npm install %s@latest failed: %s: %w", + source, strings.TrimSpace(string(out)), err) + } + + // Re-resolve the actual node_modules path (covers both bare names and + // scoped names like @scope/name). Re-create the symlink without + // trusting the previous version. + npmDir := filepath.Join(targetDir, "node_modules", source) + if _, err := os.Stat(npmDir); os.IsNotExist(err) { + parts := strings.SplitN(source, "/", 2) + if len(parts) == 2 && strings.HasPrefix(source, "@") { + npmDir = filepath.Join(targetDir, "node_modules", parts[0], parts[1]) + } + if _, err := os.Stat(npmDir); os.IsNotExist(err) { + return nil, fmt.Errorf("update: npm installed but package not found at expected path %s", npmDir) + } + } + linkDir := filepath.Join(targetDir, source) + if err := os.MkdirAll(filepath.Dir(linkDir), 0755); err != nil { + return nil, fmt.Errorf("update: cannot prepare symlink parent: %w", err) + } + rel, err := filepath.Rel(targetDir, npmDir) + if err != nil { + return nil, fmt.Errorf("update: cannot compute rel symlink: %w", err) + } + + pm.mu.Lock() + defer pm.mu.Unlock() + if _, lerr := os.Lstat(linkDir); lerr == nil { + _ = os.Remove(linkDir) + } + if err := os.Symlink(rel, linkDir); err != nil { + return nil, fmt.Errorf("update: cannot recreate symlink: %w", err) + } + loaded, err := LoadPlugin(linkDir) + if err != nil { + return nil, fmt.Errorf("update: cannot load refreshed plugin: %w", err) + } + loaded.Source = old.Source + loaded.SourceType = old.SourceType + if !old.Enabled { + loaded.Enabled = false + } + if err := SavePluginMeta(loaded); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Warning: failed to save updated plugin metadata: %v\n", err) + } + pm.plugins[loaded.Name] = loaded + return loaded, nil +} + +// UpdateAll iterates every installed plugin and calls Update() on each. +// One plugin's failure does not stop the loop. Returns the slice of +// successfully updated plugins (in install order) plus the last error +// encountered, if any. A plugin whose SourceType is "local" or whose +// Source is empty is skipped silently and omitted from the returned +// slice — those are expected to need manual intervention, not auto-update. +func UpdateAll(pm *PluginManager, mc *MarketplaceClient) ([]*InstalledPlugin, error) { + if pm == nil { + return nil, fmt.Errorf("update-all: nil plugin manager") + } + all := pm.All() + updated := make([]*InstalledPlugin, 0, len(all)) + var lastErr error + for _, p := range all { + if p == nil || p.SourceType == "local" || p.Source == "" { + continue + } + fresh, err := Update(pm, p.Name, mc) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "update: %s: %v\n", p.Name, err) + lastErr = err + continue + } + updated = append(updated, fresh) + } + return updated, lastErr +} + +// RemovePlugin removes a plugin from the global or project-local store and the manager registry. +func RemovePlugin(pm *PluginManager, name string, projectLocal ...bool) (*InstalledPlugin, error) { + plugin := pm.Plugin(name) + if plugin == nil { + return nil, fmt.Errorf("plugin %s is not installed", name) + } + + project := len(projectLocal) > 0 && projectLocal[0] + destDir := pm.TargetDir(project) + + if err := removeFromDir(destDir, name); err != nil { + return plugin, err + } + + pm.Remove(name) + return plugin, nil +} + +// removeFromDir removes a plugin directory (or symlink) from a specific directory. +func removeFromDir(dir, name string) error { + targetDir := filepath.Join(dir, name) + if _, err := os.Lstat(targetDir); err == nil { + // Check if it's a symlink + info, err := os.Lstat(targetDir) + if err == nil && info.Mode()&os.ModeSymlink != 0 { + // Just remove the symlink + if err := os.Remove(targetDir); err != nil { + return fmt.Errorf("failed to remove symlink: %w", err) + } + } else { + // Remove the whole directory + if err := os.RemoveAll(targetDir); err != nil { + return fmt.Errorf("failed to remove plugin directory: %w", err) + } + } + } + + // Also remove from node_modules if it was npm-installed + npmPath := filepath.Join(dir, "node_modules", name) + if _, err := os.Stat(npmPath); err == nil { + os.RemoveAll(npmPath) + } + + // Remove scoped npm paths + if strings.HasPrefix(name, "@") { + parts := strings.SplitN(name, "/", 2) + if len(parts) == 2 { + scopedPath := filepath.Join(dir, "node_modules", parts[0], parts[1]) + os.RemoveAll(scopedPath) + // Remove parent @scope dir if empty + scopeDir := filepath.Join(dir, "node_modules", parts[0]) + if entries, _ := os.ReadDir(scopeDir); len(entries) == 0 { + os.Remove(scopeDir) + } + } + } + + return nil +} + +// Link creates a development symlink from the plugins directory to a local path. +// If project is true and a project dir is configured, links into the project-local dir. +func Link(pm *PluginManager, localPath string, projectLocal ...bool) (*InstalledPlugin, error) { + return InstallFromLocal(pm, localPath, projectLocal...) +} + +// pluginNameFromURL extracts a plugin name from a Git URL. +func pluginNameFromURL(url string) string { + // Remove trailing .git + url = strings.TrimSuffix(url, ".git") + + // Extract the last path component + parts := strings.Split(url, "/") + name := parts[len(parts)-1] + + // Handle github:user/repo shorthand + if strings.Contains(url, ":") && !strings.Contains(url, "://") { + shorthandParts := strings.Split(url, ":") + pathParts := strings.Split(shorthandParts[len(shorthandParts)-1], "/") + if len(pathParts) >= 2 { + name = pathParts[len(pathParts)-1] + } + } + + return name +} + +// isSuspiciousPluginPath returns true if the given absolute path is not +// contained within the user's home directory or the process's current +// working directory. Used as a soft warning in `InstallFromLocal` to catch +// obvious typos or suspect link targets — the caller decides how to act. +func isSuspiciousPluginPath(absPath string) bool { + if home, err := os.UserHomeDir(); err == nil && home != "" { + if rel, err := filepath.Rel(home, absPath); err == nil && !strings.HasPrefix(rel, "..") && rel != ".." { + return false + } + } + if cwd, err := os.Getwd(); err == nil && cwd != "" { + if rel, err := filepath.Rel(cwd, absPath); err == nil && !strings.HasPrefix(rel, "..") && rel != ".." { + return false + } + } + return true +} + +// expandGitURL converts shorthand Git references to proper URLs. +func expandGitURL(url string) string { + if strings.Contains(url, "://") { + return url + } + + // github:user/repo + if strings.HasPrefix(url, "github:") { + repo := strings.TrimPrefix(url, "github:") + return "https://github.com/" + repo + ".git" + } + + // gitlab:user/repo + if strings.HasPrefix(url, "gitlab:") { + repo := strings.TrimPrefix(url, "gitlab:") + return "https://gitlab.com/" + repo + ".git" + } + + // bitbucket:user/repo + if strings.HasPrefix(url, "bitbucket:") { + repo := strings.TrimPrefix(url, "bitbucket:") + return "https://bitbucket.org/" + repo + ".git" + } + + // Assume it's already a valid URL + return url +} diff --git a/internal/plugin/jsonraw_test_helper.go b/internal/plugin/jsonraw_test_helper.go new file mode 100644 index 0000000..6c04a8c --- /dev/null +++ b/internal/plugin/jsonraw_test_helper.go @@ -0,0 +1,7 @@ +package plugin + +import "encoding/json" + +// jsonRaw wraps a JSON string literal as json.RawMessage for use in tests. +// Kept tiny so production code is not affected. +func jsonRaw(s string) json.RawMessage { return json.RawMessage(s) } diff --git a/internal/plugin/manager.go b/internal/plugin/manager.go new file mode 100644 index 0000000..69a5221 --- /dev/null +++ b/internal/plugin/manager.go @@ -0,0 +1,413 @@ +package plugin + +import ( + "fmt" + "late/internal/skill" + "os" + "path/filepath" + "sort" + "strings" + "sync" +) + +// PluginManager manages the lifecycle of installed plugins. +type PluginManager struct { + mu sync.RWMutex + pluginsDir string // absolute path to the global plugins store directory + projectDir string // optional absolute path to project-local plugins dir (.late/plugins/) + plugins map[string]*InstalledPlugin +} + +// NewPluginManager creates a new PluginManager for the given plugins directory. +// If projectDir is non-empty, it is also scanned during Discover and takes +// priority over global plugins with the same name. +func NewPluginManager(pluginsDir string) *PluginManager { + return &PluginManager{ + pluginsDir: pluginsDir, + plugins: make(map[string]*InstalledPlugin), + } +} + +// SetProjectDir sets the project-local plugins directory. +// Must be called before Discover() for it to take effect. +func (pm *PluginManager) SetProjectDir(dir string) { + pm.mu.Lock() + defer pm.mu.Unlock() + pm.projectDir = dir +} + +// HasProjectDir returns true if a project-local plugins directory is configured. +func (pm *PluginManager) HasProjectDir() bool { + pm.mu.RLock() + defer pm.mu.RUnlock() + return pm.projectDir != "" +} + +// ProjectDir returns the project-local plugins directory path, or empty string. +func (pm *PluginManager) ProjectDir() string { + pm.mu.RLock() + defer pm.mu.RUnlock() + return pm.projectDir +} + +// TargetDir returns the plugins directory appropriate for the target scope. +// If project is true and a project dir is configured, returns the project dir; +// otherwise returns the global dir. +func (pm *PluginManager) TargetDir(project bool) string { + pm.mu.RLock() + defer pm.mu.RUnlock() + if project && pm.projectDir != "" { + return pm.projectDir + } + return pm.pluginsDir +} + +// PluginsDir returns the absolute path to the plugins store. +func (pm *PluginManager) PluginsDir() string { + return pm.pluginsDir +} + +// Discover scans all configured plugins directories and loads installed plugins. +// It reconciles the in-memory map with what's on disk: plugins that were +// removed from disk are removed from memory. +// Project-local plugins override global plugins with the same name. +// +// Discover holds the write lock for its entire duration so that concurrent +// callers of Plugin / All / Count / BuildMCPConfigMap / PluginCommands do +// not panic on concurrent map access. +func (pm *PluginManager) Discover() error { + pm.mu.Lock() + defer pm.mu.Unlock() + + // Start fresh: clear all plugins and rebuild from scratch + pm.plugins = make(map[string]*InstalledPlugin) + + // Discover global plugins first + if err := pm.discoverFromDir(pm.pluginsDir); err != nil { + return err + } + + // Discover project-local plugins (overrides global ones with same name) + if pm.projectDir != "" { + if err := pm.discoverFromDir(pm.projectDir); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Warning: failed to discover project plugins: %v\n", err) + // Non-fatal — continue with global plugins only + } + } + + return nil +} + +// discoverFromDir scans a single directory and loads all installed plugins from it. +func (pm *PluginManager) discoverFromDir(dir string) error { + if dir == "" { + return nil + } + if _, err := os.Stat(dir); os.IsNotExist(err) { + return nil + } + + entries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("failed to read plugins directory %s: %w", dir, err) + } + + for _, entry := range entries { + // Accept real directories AND symlinks (which may point to dirs). + // Go's os.ReadDir reports IsDir()=false for symlinks, so we + // must check the type explicitly. + if !entry.IsDir() && entry.Type()&os.ModeSymlink == 0 { + continue + } + name := entry.Name() + + // Skip npm-managed directories: node_modules and .cache. + if name == "node_modules" || name == ".cache" { + continue + } + + // @-prefixed directories are npm scoped-package parent dirs. + // They aren't plugins themselves — recurse into them to find + // the actual plugins (e.g. @scope/name). + if strings.HasPrefix(name, "@") { + subDir := filepath.Join(dir, name) + if err := pm.discoverFromDir(subDir); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Warning: failed to scan scoped dir %s: %v\n", subDir, err) + } + continue + } + + pluginDir := filepath.Join(dir, name) + plugin, err := LoadPluginMeta(pluginDir) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Warning: failed to load plugin from %s: %v\n", pluginDir, err) + continue + } + + pm.plugins[plugin.Name] = plugin + } + + return nil +} + +// Plugin returns a loaded plugin by name. +func (pm *PluginManager) Plugin(name string) *InstalledPlugin { + pm.mu.RLock() + defer pm.mu.RUnlock() + return pm.plugins[name] +} + +// All returns all loaded plugins, sorted by name. +func (pm *PluginManager) All() []*InstalledPlugin { + pm.mu.RLock() + defer pm.mu.RUnlock() + all := make([]*InstalledPlugin, 0, len(pm.plugins)) + for _, p := range pm.plugins { + all = append(all, p) + } + sort.Slice(all, func(i, j int) bool { + return all[i].Name < all[j].Name + }) + return all +} + +// Add adds a plugin to the manager's in-memory registry (after installation). +func (pm *PluginManager) Add(plugin *InstalledPlugin) { + pm.mu.Lock() + defer pm.mu.Unlock() + pm.plugins[plugin.Name] = plugin +} + +// Remove removes a plugin from the in-memory registry. +func (pm *PluginManager) Remove(name string) { + pm.mu.Lock() + defer pm.mu.Unlock() + delete(pm.plugins, name) +} + +// Count returns the number of loaded plugins. +func (pm *PluginManager) Count() int { + pm.mu.RLock() + defer pm.mu.RUnlock() + return len(pm.plugins) +} + +// PluginPath returns the expected path for a plugin with the given name +// in the global plugins directory. +func (pm *PluginManager) PluginPath(name string) string { + return filepath.Join(pm.pluginsDir, name) +} + +// PluginPathInDir returns the expected path for a plugin within a specific target dir. +func (pm *PluginManager) PluginPathInDir(targetDir, name string) string { + return filepath.Join(targetDir, name) +} + +// RegisterPluginSkills creates symlinks from the plugin's skill dirs into +// the Late skills directory so the existing SkillLoader can discover them. +// This should be called during bootstrap after all plugins are discovered. +// It also cleans up stale symlinks for removed or disabled plugins. +func (pm *PluginManager) RegisterPluginSkills(skillsDir string) error { + if skillsDir == "" { + var err error + skillsDir, err = lateSkillsDir() + if err != nil { + return nil + } + } + + // Collect the set of valid skill symlink names to keep + keep := make(map[string]bool) + + pm.mu.RLock() + plugins := make([]*InstalledPlugin, 0, len(pm.plugins)) + for _, p := range pm.plugins { + plugins = append(plugins, p) + } + pm.mu.RUnlock() + + for _, p := range plugins { + if !p.Enabled || p.Late == nil { + continue + } + + surfaces := p.ResolveSurfaces() + for _, skillPath := range surfaces.Skills { + // Reject skill paths that escape the plugin's own directory to + // prevent path traversal attacks via malicious manifests. + relPath, relErr := filepath.Rel(p.Path, skillPath) + if relErr != nil || strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) { + _, _ = fmt.Fprintf(os.Stderr, "Warning: plugin %s declares skill at %s which escapes the plugin directory; skipping\n", p.Name, skillPath) + continue + } + + if _, err := os.Stat(skillPath); os.IsNotExist(err) { + _, _ = fmt.Fprintf(os.Stderr, "Warning: plugin %s declares skill at %s but directory does not exist\n", p.Name, skillPath) + continue + } + + // SKILL.md / subdirectory layout resolution: + // + // Plugins can declare either: + // /skills/SKILL.md (single file at root) + // /skills//SKILL.md (one skill per subdir) + // We support both. The single-root-file case names the skill + // after the basename of the parent dir ("skills") so the + // namespaced identifier stays stable across minor plugin + // refactors. + var loaded []*skill.Skill + + if _, err := os.Stat(filepath.Join(skillPath, "SKILL.md")); err == nil { + // Single-file layout at the root. + sk, loadErr := skill.LoadSkill(skillPath) + if loadErr != nil { + _, _ = fmt.Fprintf(os.Stderr, "Warning: plugin %s has invalid skill at %s: %v\n", p.Name, skillPath, loadErr) + continue + } + loaded = append(loaded, sk) + } else { + // Subdir layout: enumerate one level of subdirectories, + // each expected to contain its own SKILL.md. This matches + // the documented Claude Code / Agent Skills convention. + entries, rerr := os.ReadDir(skillPath) + if rerr != nil { + _, _ = fmt.Fprintf(os.Stderr, "Warning: plugin %s skill dir %s unreadable: %v\n", p.Name, skillPath, rerr) + continue + } + for _, e := range entries { + if !e.IsDir() { + continue + } + sub := filepath.Join(skillPath, e.Name()) + if _, err := os.Stat(filepath.Join(sub, "SKILL.md")); err != nil { + continue + } + sk, loadErr := skill.LoadSkill(sub) + if loadErr != nil { + _, _ = fmt.Fprintf(os.Stderr, "Warning: plugin %s has invalid skill %s: %v\n", p.Name, sub, loadErr) + continue + } + loaded = append(loaded, sk) + } + } + + for _, sk := range loaded { + // Namespace skill symlinks to prevent collisions between plugins + namespacedName := p.Name + ":" + sk.Metadata.Name + linkName := filepath.Join(skillsDir, namespacedName) + if _, err := os.Lstat(linkName); err == nil { + os.Remove(linkName) + } + + if err := os.Symlink(sk.Path, linkName); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Warning: failed to create symlink for plugin skill %s: %v\n", namespacedName, err) + continue + } + keep[namespacedName] = true + } + } + } + + // Clean up stale symlinks for removed or disabled plugins + entries, err := os.ReadDir(skillsDir) + if err == nil { + for _, entry := range entries { + if entry.IsDir() { + continue + } + // Only remove symlinks (not regular skill dirs) + fullPath := filepath.Join(skillsDir, entry.Name()) + info, err := os.Lstat(fullPath) + if err == nil && info.Mode()&os.ModeSymlink != 0 { + if !keep[entry.Name()] { + os.Remove(fullPath) + } + } + } + } + + return nil +} + +// BuildMCPConfigMap returns all enabled MCP server configurations +// declared by plugins, with namespaced server names (plugin:server). +// The caller should merge these into the existing MCP config and connect. +func (pm *PluginManager) BuildMCPConfigMap() map[string]MCPServerConfig { + pm.mu.RLock() + defer pm.mu.RUnlock() + result := make(map[string]MCPServerConfig) + for _, p := range pm.plugins { + if !p.Enabled || p.Late == nil || p.Late.MCP == nil { + continue + } + surfaces := p.ResolveSurfaces() + for name, srv := range surfaces.MCPServers { + result[name] = srv + } + } + return result +} + +// PluginCommands returns all slash commands declared by enabled plugins. +// Names are normalized to start with "/". +func (pm *PluginManager) PluginCommands() []string { + pm.mu.RLock() + defer pm.mu.RUnlock() + var cmds []string + for _, p := range pm.plugins { + if !p.Enabled || p.Late == nil { + continue + } + for _, c := range p.Late.Commands { + name := c.Name + if name == "" { + continue + } + if !strings.HasPrefix(name, "/") { + name = "/" + name + } + cmds = append(cmds, name) + } + } + sort.Strings(cmds) + return cmds +} + +// HasCommandHandler reports whether any enabled plugin declares a +// Handler script for the given slash command. The "/" prefix is +// tolerated. +// +// This is a pure, side-effect-free lookup: it must never spawn a +// subprocess or write to stderr. Callers (e.g. ToolRegistry filters, +// permission gates) invoke it on the hot path, so duplicating the +// scan here is intentional and cheaper than delegating to +// HandleCommand. +func (pm *PluginManager) HasCommandHandler(cmdName string) bool { + pm.mu.RLock() + defer pm.mu.RUnlock() + + normalized := strings.TrimPrefix(cmdName, "/") + for _, p := range pm.plugins { + if !p.Enabled || p.Late == nil { + continue + } + for _, c := range p.Late.Commands { + if c.Handler == "" { + continue + } + if strings.TrimPrefix(c.Name, "/") == normalized { + return true + } + } + } + return false +} + +// lateSkillsDir returns the user-level skills directory. +func lateSkillsDir() (string, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(configDir, "late", "skills"), nil +} diff --git a/internal/plugin/manifest.go b/internal/plugin/manifest.go new file mode 100644 index 0000000..d45eb25 --- /dev/null +++ b/internal/plugin/manifest.go @@ -0,0 +1,547 @@ +package plugin + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// LateManifest represents the "late" field inside a plugin's package.json. +type LateManifest struct { + Skills []string `json:"skills,omitempty"` // relative paths to skill directories + MCP *LateMCPManifest `json:"mcp,omitempty"` // MCP server definitions + Commands LateCommands `json:"commands,omitempty"` // slash command names (see LateCommands for back-compat) + Themes []string `json:"themes,omitempty"` // relative paths to theme JSON files + Hooks *LateHooksManifest `json:"hooks,omitempty"` // hook script definitions + Tools []LateToolManifest `json:"tools,omitempty"` // inline agent-callable tools (no MCP needed) +} + +// OmpManifest represents the "omp" field inside an omp plugin's package.json. +// Late translates these into its own manifest format at load time. +type OmpManifest struct { + Skills []string `json:"skills,omitempty"` + Extensions []string `json:"extensions,omitempty"` + Commands []string `json:"commands,omitempty"` + MCP *LateMCPManifest `json:"mcp,omitempty"` + Hooks *LateHooksManifest `json:"hooks,omitempty"` +} + +// ClaudePluginManifest represents the .claude-plugin/plugin.json manifest. +type ClaudePluginManifest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Version string `json:"version,omitempty"` +} + +// LateCommands is a backward-compatible adapter for the "commands" field. +// Plugins written before command handlers existed declare commands as a +// flat array of strings; plugins written after can declare objects with +// a per-command "handler" script path. This type accepts both shapes: +// +// "commands": ["/weather", "/git"] +// +// "commands": [{"name": "/weather", "handler": "scripts/weather.sh"}] +type LateCommands []LateCommandManifest + +// UnmarshalJSON accepts commands arrays in three shapes, including the +// heterogeneous case documented in `docs/plugin-example.md` where one +// entry is a bare string ("legacy" slash command — dispatcher falls +// back to plain-prompt) and another carries an explicit Handler +// script: +// +// "commands": ["/format", "/lint"] // strings only +// "commands": [{"name":"/lint","handler":"x.sh"}] // objects only +// "commands": ["/format", {"name":"/lint","handler":"x.sh"}] // mixed (preserved) +// +// Each element is dispatched individually: a string becomes a manifest +// with Handler=="" (legacy fall-through); an object becomes a manifest +// with whatever fields it carries. Anything that is neither is reported +// verbatim so authors notice malformed entries. +// +// Heterogeneous arrays are common in real plugins (a plugin author +// wants a `/help`-style bare alias for one command and a scripted +// handler for another), so handling them here removes a long-standing +// loader footgun. +func (lc *LateCommands) UnmarshalJSON(data []byte) error { + var raws []json.RawMessage + if err := json.Unmarshal(data, &raws); err != nil { + return err + } + out := make(LateCommands, 0, len(raws)) + for _, raw := range raws { + // Try string form first. + var s string + if err := json.Unmarshal(raw, &s); err == nil { + out = append(out, LateCommandManifest{Name: s}) + continue + } + // Then object form. + var obj LateCommandManifest + if err := json.Unmarshal(raw, &obj); err == nil { + out = append(out, obj) + continue + } + return fmt.Errorf("commands entry must be a string or {name,handler?}, got %s", string(raw)) + } + *lc = out + return nil +} + +// MarshalJSON encodes the late commands back to a string array when no +// command has a handler, so round-tripping through DefaultManifest stays +// readable. Otherwise emits the object form so handlers survive. +func (lc LateCommands) MarshalJSON() ([]byte, error) { + hasHandler := false + for _, c := range lc { + if c.Handler != "" { + hasHandler = true + break + } + } + if !hasHandler { + names := make([]string, len(lc)) + for i, c := range lc { + names[i] = c.Name + } + return json.Marshal(names) + } + return json.Marshal([]LateCommandManifest(lc)) +} + +// LateCommandManifest describes a single plugin slash command. The Name +// is required; Handler is optional. When Handler is set, the TUI runs +// the script with the trailing args (JSON-encoded) on stdin and shows +// the stdout as the chat response. When Handler is empty, the command +// falls back to the legacy "dispatch as a plain prompt" behavior. +type LateCommandManifest struct { + Name string `json:"name"` // slash command name, with or without leading "/" + Handler string `json:"handler,omitempty"` // optional relative path to a handler script +} + +// LateToolManifest declares a single agent-callable tool inline within +// the manifest, removing the need for an MCP server wrapper. Scripts +// receive the tool arguments JSON on stdin and must return the result +// on stdout. +type LateToolManifest struct { + Name string `json:"name"` // tool name, will be namespaced as ":" + Description string `json:"description"` // shown to the model in the tool list + Script string `json:"script"` // relative path to the executable script + Parameters json.RawMessage `json:"parameters"` // JSON Schema fragment describing arguments +} + +// LateMCPManifest holds MCP server definitions declared by a plugin. +type LateMCPManifest struct { + Servers map[string]MCPServerConfig `json:"servers"` +} + +// MCPServerConfig mirrors the MCP server config structure from mcp_config.json. +type MCPServerConfig struct { + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + Env map[string]string `json:"env,omitempty"` + URL string `json:"url,omitempty"` + TransportType string `json:"transportType,omitempty"` + Disabled bool `json:"disabled,omitempty"` + // Dir is populated by the plugin loader (not serialized) and is used + // to set cmd.Dir for the stdio transport so any plugin-relative + // paths in Args resolve against the plugin's directory. + Dir string `json:"-"` +} + +// LateHooksManifest defines hook scripts a plugin provides. +// +// Hook contract: +// - onToolCall receives the ToolCall as JSON on stdin. The hook may: +// 1. Return JSON (any valid JSON object/string) to mutate the call's +// "arguments" field before next() runs (Gate via mutate). +// 2. Return exactly the string "blocked" to veto the tool execution. +// The next() in the chain is skipped and late returns an error +// result to the agent. +// 3. Return empty / non-JSON to pass through unchanged. +// - onToolResult receives {"tool": "...", "result": "..."} via stdin. +// Read-only observation hook; the return value is currently logged +// but not used to mutate anything. +// - onSessionStart, onTurnStart, onTurnEnd fire before/after their +// respective lifecycle moments. They receive an empty JSON object +// on stdin. Errors and stderr are forwarded to the user's TUI. +// - onMessageSend and onInput form a sequential transform pipeline; +// each hook sees the previous hook's stdout. Smoke (no stdout) is +// treated as a no-op so a hook can be a no-op for some inputs. +type LateHooksManifest struct { + OnToolCall []string `json:"onToolCall,omitempty"` // relative paths to scripts + OnToolResult []string `json:"onToolResult,omitempty"` // relative paths to scripts + OnSessionStart []string `json:"onSessionStart,omitempty"` // relative paths to scripts + OnTurnStart []string `json:"onTurnStart,omitempty"` // relative paths to scripts + OnTurnEnd []string `json:"onTurnEnd,omitempty"` // relative paths to scripts + OnMessageSend []string `json:"onMessageSend,omitempty"` // relative paths to scripts + OnInput []string `json:"onInput,omitempty"` // relative paths to scripts +} + +// PackageJSON represents the minimal package.json fields we care about. +type PackageJSON struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description,omitempty"` + Late *LateManifest `json:"late,omitempty"` + Omp *OmpManifest `json:"omp,omitempty"` +} + +// InstalledPlugin represents an installed plugin with its manifest and metadata. +type InstalledPlugin struct { + Name string `json:"name"` // plugin name (from package.json) + Version string `json:"version"` // plugin version + Description string `json:"description,omitempty"` + Path string `json:"path"` // absolute path to the plugin directory + SourceType string `json:"source_type"` // "npm", "git", "local", "marketplace" + Source string `json:"source,omitempty"` // original install string passed by the user (pkg, URL, path, or marketplace name); empty for symlinked local plugins + Enabled bool `json:"enabled"` + Late *LateManifest `json:"late"` // the late extension manifest +} + +// Source holds the resolved absolute paths for each surface after registration. +type SurfaceSources struct { + Skills []string // resolved absolute paths to skill dirs + MCPServers map[string]MCPServerConfig + Themes []string // resolved absolute paths to theme JSON files + Commands []string +} + +// ResolveSurfaces resolves relative paths from the manifest into absolute paths +// rooted at the plugin's directory. Returns a SurfaceSources struct. +func (p *InstalledPlugin) ResolveSurfaces() *SurfaceSources { + src := &SurfaceSources{ + MCPServers: make(map[string]MCPServerConfig), + } + + if p.Late == nil { + return src + } + + for _, rel := range p.Late.Skills { + abs := filepath.Join(p.Path, rel) + abs = filepath.Clean(abs) + src.Skills = append(src.Skills, abs) + } + + if p.Late.MCP != nil { + for name, srv := range p.Late.MCP.Servers { + // Prefix server name with plugin name to avoid collisions + namespaced := p.Name + ":" + name + srv.Args = resolveArgs(p.Path, srv.Args) + srv.Dir = p.Path + src.MCPServers[namespaced] = srv + } + } + + for _, rel := range p.Late.Themes { + abs := filepath.Join(p.Path, rel) + abs = filepath.Clean(abs) + src.Themes = append(src.Themes, abs) + } + + src.Commands = make([]string, 0, len(p.Late.Commands)) + for _, c := range p.Late.Commands { + if c.Name != "" { + src.Commands = append(src.Commands, c.Name) + } + } + + return src +} + +// resolveArgs resolves relative paths in args to absolute paths rooted at pluginDir. +// +// The intent is that a plugin author can write either: +// - "args": ["./scripts/server.sh"] (explicit ./ — resolved against pluginDir) +// - "args": ["../shared/server.sh"] (explicit ../ — resolved against pluginDir) +// - "args": ["/abs/path/to/server.sh"] (absolute, pass-through) +// - "args": ["node", "src/index.js"] (literal args, pass-through) +// +// Only args with an explicit relative prefix (`./` or `../`) are +// resolved. Bare names are NOT rewritten — even when a same-named +// file exists under pluginDir — because doing so silently changes +// argv semantics for the spawned process and surprises authors who +// pass positional args with cwd-relative intent. The transport +// already sets `cmd.Dir` to pluginDir, so plugin authors can rely on +// the kernel's classic "argv is what you wrote" behaviour when they +// want cwd-relative scripts. +func resolveArgs(pluginDir string, args []string) []string { + resolved := make([]string, len(args)) + for i, arg := range args { + if arg == "" || strings.HasPrefix(arg, "/") { + resolved[i] = arg + continue + } + if strings.HasPrefix(arg, "./") || strings.HasPrefix(arg, "../") { + resolved[i] = filepath.Join(pluginDir, arg) + continue + } + resolved[i] = arg + } + return resolved +} + +// LoadPlugin loads a plugin from the specified directory. It recognizes +// three plugin formats in order of precedence: +// +// 1. package.json with "late" field (native Late format) +// 2. package.json with "omp" field (Oh My Pi / omp format) — translated at load time +// 3. .claude-plugin/plugin.json + auto-detected surfaces (Claude Code format) +// +// For formats 2 and 3, the manifest is translated into a LateManifest so the rest +// of the system (skill registration, MCP, commands, hooks) works identically. +func LoadPlugin(dir string) (*InstalledPlugin, error) { + plugin, err := tryLoadNativeLate(dir) + if err == nil { + return plugin, nil + } + + plugin, err = tryLoadOmp(dir) + if err == nil { + return plugin, nil + } + + plugin, err = tryLoadClaudeCode(dir) + if err == nil { + return plugin, nil + } + + return nil, fmt.Errorf("no recognized plugin format in %s: %w", dir, err) +} + +// tryLoadNativeLate loads a plugin from a package.json with a "late" field. +func tryLoadNativeLate(dir string) (*InstalledPlugin, error) { + pkgPath := filepath.Join(dir, "package.json") + data, err := os.ReadFile(pkgPath) + if err != nil { + return nil, fmt.Errorf("failed to read %s: %w", pkgPath, err) + } + + var pkg PackageJSON + if err := json.Unmarshal(data, &pkg); err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", pkgPath, err) + } + + if pkg.Name == "" { + return nil, fmt.Errorf("plugin at %s is missing 'name' in package.json", dir) + } + + if pkg.Late == nil { + return nil, fmt.Errorf("no 'late' field in %s", pkgPath) + } + + return buildPlugin(dir, pkg.Name, pkg.Version, pkg.Description, pkg.Late), nil +} + +// tryLoadOmp loads a plugin from a package.json with an "omp" field. +func tryLoadOmp(dir string) (*InstalledPlugin, error) { + pkgPath := filepath.Join(dir, "package.json") + data, err := os.ReadFile(pkgPath) + if err != nil { + return nil, fmt.Errorf("failed to read %s: %w", pkgPath, err) + } + + var pkg PackageJSON + if err := json.Unmarshal(data, &pkg); err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", pkgPath, err) + } + + if pkg.Name == "" { + return nil, fmt.Errorf("plugin at %s is missing 'name' in package.json", dir) + } + + if pkg.Omp == nil { + return nil, fmt.Errorf("no 'omp' field in %s", pkgPath) + } + + late := translateOmpToLate(pkg.Omp) + return buildPlugin(dir, pkg.Name, pkg.Version, pkg.Description, late), nil +} + +// translateOmpToLate converts an omp manifest to the internal LateManifest. +func translateOmpToLate(omp *OmpManifest) *LateManifest { + late := &LateManifest{ + Skills: omp.Skills, + Commands: make(LateCommands, 0, len(omp.Commands)), + MCP: omp.MCP, + Hooks: omp.Hooks, + } + + for _, cmd := range omp.Commands { + late.Commands = append(late.Commands, LateCommandManifest{Name: cmd}) + } + // omp extensions are not directly surfaced in Late — they're TypeScript + // entry points for the omp harness itself and don't map to Late surfaces. + + return late +} + +// tryLoadClaudeCode loads a plugin in Claude Code format: +// - .claude-plugin/plugin.json for metadata +// - skills/ directory for skills +// - commands/ directory (legacy) for commands +// - .mcp.json at root for MCP servers +// - hooks/hooks.json at root for hooks +func tryLoadClaudeCode(dir string) (*InstalledPlugin, error) { + claudeDir := filepath.Join(dir, ".claude-plugin") + manifestPath := filepath.Join(claudeDir, "plugin.json") + + data, err := os.ReadFile(manifestPath) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("no .claude-plugin/plugin.json in %s", dir) + } + return nil, fmt.Errorf("failed to read %s: %w", manifestPath, err) + } + + var manifest ClaudePluginManifest + if err := json.Unmarshal(data, &manifest); err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", manifestPath, err) + } + + if manifest.Name == "" { + return nil, fmt.Errorf("claude plugin at %s is missing 'name' in plugin.json", dir) + } + + late := &LateManifest{} + + // Auto-detect skills/ directory + if info, err := os.Stat(filepath.Join(dir, "skills")); err == nil && info.IsDir() { + late.Skills = append(late.Skills, "skills/") + } + + // Auto-detect commands/ directory (Claude Code legacy) + if info, err := os.Stat(filepath.Join(dir, "commands")); err == nil && info.IsDir() { + entries, _ := os.ReadDir(filepath.Join(dir, "commands")) + for _, e := range entries { + name := e.Name() + if strings.HasSuffix(name, ".md") { + cmdName := "/" + strings.TrimSuffix(name, ".md") + late.Commands = append(late.Commands, LateCommandManifest{Name: cmdName}) + } + } + } + + // Auto-detect .mcp.json at root (Claude Code style). + // Two formats exist in the wild: + // {"mcpServers": {"name": {...}}} — wrapped (omp/Late convention) + // {"name": {"type":"sse","url":...}} — flat map (Claude Code convention) + mcpPath := filepath.Join(dir, ".mcp.json") + if mcpData, err := os.ReadFile(mcpPath); err == nil { + // Try wrapped format first + var wrapped struct { + McpServers map[string]MCPServerConfig `json:"mcpServers"` + } + if err := json.Unmarshal(mcpData, &wrapped); err == nil && len(wrapped.McpServers) > 0 { + late.MCP = &LateMCPManifest{Servers: wrapped.McpServers} + } else { + // Try flat format: top-level keys are server names + var flat map[string]json.RawMessage + if err := json.Unmarshal(mcpData, &flat); err == nil && len(flat) > 0 { + servers := make(map[string]MCPServerConfig, len(flat)) + for name, raw := range flat { + var srv MCPServerConfig + if err := json.Unmarshal(raw, &srv); err == nil { + servers[name] = srv + } + } + if len(servers) > 0 { + late.MCP = &LateMCPManifest{Servers: servers} + } + } + } + } + + // Auto-detect hooks/hooks.json at root + hooksPath := filepath.Join(dir, "hooks", "hooks.json") + if hooksData, err := os.ReadFile(hooksPath); err == nil { + var hooksCfg struct { + Hooks json.RawMessage `json:"hooks"` + } + if err := json.Unmarshal(hooksData, &hooksCfg); err == nil && hooksCfg.Hooks != nil { + // Store raw hooks JSON — the hook system handles Claude Code + // hook format via its own adapter. + } + } + + return buildPlugin(dir, manifest.Name, manifest.Version, manifest.Description, late), nil +} + +// buildPlugin constructs an InstalledPlugin and detects its source type. +func buildPlugin(dir, name, version, description string, late *LateManifest) *InstalledPlugin { + plugin := &InstalledPlugin{ + Name: name, + Version: version, + Description: description, + Path: dir, + SourceType: "unknown", + Enabled: true, + Late: late, + } + + if plugin.Late == nil { + plugin.Late = &LateManifest{} + } + + // Detect source type from directory contents + if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil { + plugin.SourceType = "git" + } else if isSymlink(dir) { + plugin.SourceType = "local" + } else { + plugin.SourceType = "npm" + } + + return plugin +} + +// isSymlink checks if a path is a symbolic link. +func isSymlink(path string) bool { + info, err := os.Lstat(path) + if err != nil { + return false + } + return info.Mode()&os.ModeSymlink != 0 +} + +// SavePluginMeta persists a minimal metadata file for the plugin. After +// writing, force the file's mtime to "now" so the PollingWatcher's snapshot +// always detects the change even on filesystems that coalesce rapid writes. +func SavePluginMeta(plugin *InstalledPlugin) error { + metaPath := filepath.Join(plugin.Path, ".late-plugin.json") + data, err := json.MarshalIndent(plugin, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal plugin metadata: %w", err) + } + if err := os.WriteFile(metaPath, data, 0644); err != nil { + return err + } + now := time.Now() + _ = os.Chtimes(metaPath, now, now) + return nil +} + +// LoadPluginMeta loads the metadata from a plugin directory. +func LoadPluginMeta(dir string) (*InstalledPlugin, error) { + metaPath := filepath.Join(dir, ".late-plugin.json") + data, err := os.ReadFile(metaPath) + if err != nil { + if os.IsNotExist(err) { + return LoadPlugin(dir) + } + return nil, fmt.Errorf("failed to read %s: %w", metaPath, err) + } + + var plugin InstalledPlugin + if err := json.Unmarshal(data, &plugin); err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", metaPath, err) + } + + // Ensure the Path field is up to date + plugin.Path = dir + + return &plugin, nil +} diff --git a/internal/plugin/manifest_test.go b/internal/plugin/manifest_test.go new file mode 100644 index 0000000..6c47ebb --- /dev/null +++ b/internal/plugin/manifest_test.go @@ -0,0 +1,83 @@ +package plugin + +import ( + "path/filepath" + "slices" + "testing" +) + +// TestResolveArgs verifies the conservative resolver policy: +// - args starting with `./` or `../` are joined to pluginDir +// - args with a leading `/` (absolute) pass through verbatim +// - bare names pass through verbatim — even if a same-named file +// happens to live under pluginDir (the transport's cmd.Dir makes +// cwd resolution work without help) +// +// The "bare name" guarantee is the bug-prevention half of this test: +// before the tightening, ["node", "src/index.js"] would silently +// rewrite to ["node", "/abs/.../src/index.js"] when src/index.js +// existed in the plugin, surprising plugin authors. +func TestResolveArgs(t *testing.T) { + pluginDir := t.TempDir() + resolved := filepath.Join(pluginDir, "scripts", "server.sh") + parent := filepath.Join(pluginDir, "..", "shared", "server.sh") + + tests := []struct { + name string + in []string + want []string + }{ + { + name: "explicit ./ resolves under pluginDir", + in: []string{"./scripts/server.sh"}, + want: []string{resolved}, + }, + { + name: "explicit ../ resolves upward from pluginDir", + in: []string{"../shared/server.sh"}, + want: []string{parent}, + }, + { + name: "absolute path passes through", + in: []string{"/usr/local/bin/server.sh"}, + want: []string{"/usr/local/bin/server.sh"}, + }, + { + name: "bare names with the same basename in pluginDir are NOT rebound", + in: []string{"node", "src/index.js"}, + want: []string{"node", "src/index.js"}, + }, + { + name: "mixed: ./ resolves, / passes through, bare names preserved", + in: []string{"./a.sh", "/b.sh", "c.sh", "node"}, + want: []string{filepath.Join(pluginDir, "a.sh"), "/b.sh", "c.sh", "node"}, + }, + { + name: "empty arg preserved", + in: []string{""}, + want: []string{""}, + }, + { + name: "bare '.' alone preserved (no ./ prefix)", + in: []string{"."}, + want: []string{"."}, + }, + { + name: "nil input yields empty (same-length) slice", + in: nil, + want: []string{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resolveArgs(pluginDir, tt.in) + if len(got) != len(tt.want) { + t.Fatalf("resolveArgs(%q, %v) length %d, want %d (got %v)", + pluginDir, tt.in, len(got), len(tt.want), got) + } + if !slices.Equal(got, tt.want) { + t.Errorf("resolveArgs(%q, %v) = %v, want %v", pluginDir, tt.in, got, tt.want) + } + }) + } +} diff --git a/internal/plugin/marketplace.go b/internal/plugin/marketplace.go new file mode 100644 index 0000000..b96e0a2 --- /dev/null +++ b/internal/plugin/marketplace.go @@ -0,0 +1,121 @@ +package plugin + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +// DefaultRegistryBaseURL is the canonical marketplace endpoint. It can +// be overridden by LATE_PLUGIN_REGISTRY (no trailing slash expected — +// "/plugins/.json" is appended dynamically). +// +// The literal string is intentionally a placeholder so that, until a real +// registry is published, `Install` falls through to npm interpretation for +// any unresolved bare name (see Resolve fallback policy). +const DefaultRegistryBaseURL = "https://registry.late.dev/v1" + +// ErrMarketplaceMiss indicates the registry returned 404 for the +// requested plugin name. Callers should fall back to plain npm +// interpretation of the bare name. +var ErrMarketplaceMiss = errors.New("marketplace: plugin not found") + +// MarketplaceEntry is the registry's JSON shape for a single plugin. +// `npm` and `git` are mutually exclusive — at most one is set. When both +// are empty the registry is signaling "decommissioned". +type MarketplaceEntry struct { + Npm string `json:"npm,omitempty"` + Git string `json:"git,omitempty"` + Description string `json:"description,omitempty"` + Version string `json:"version,omitempty"` +} + +// SourceType returns "npm" or "git" depending on which field is set. +// Discriminates an empty entry as "". +func (e *MarketplaceEntry) SourceType() string { + switch { + case e == nil: + return "" + case e.Npm != "": + return "npm" + case e.Git != "": + return "git" + default: + return "" + } +} + +// MarketplaceClient resolves bare plugin names to installable targets by +// consulting a JSON registry. Production code uses DefaultRegistry(). +// Tests can construct a client pointed at an httptest.Server so no real +// network I/O is needed. +type MarketplaceClient struct { + BaseURL string + HTTPClient *http.Client +} + +// DefaultRegistry returns the MarketplaceClient used by Install. Honors +// LATE_PLUGIN_REGISTRY from the environment if it is set, else falls back +// to DefaultRegistryBaseURL. The HTTP client has a sensible 5s timeout so +// a slow registry never stalls `late plugin install`. +func DefaultRegistry() MarketplaceClient { + base := os.Getenv("LATE_PLUGIN_REGISTRY") + if base == "" { + base = DefaultRegistryBaseURL + } + base = strings.TrimRight(base, "/") + return MarketplaceClient{ + BaseURL: base, + HTTPClient: &http.Client{Timeout: 5 * time.Second}, + } +} + +// Resolve looks up a plugin by its short name on the configured registry. +// On 404 it returns ErrMarketplaceMiss so the caller can fall back to +// npm; on any other error or non-200 status it returns the underlying +// failure (no fallback is attempted at this layer — callers decide). +func (c *MarketplaceClient) Resolve(ctx context.Context, name string) (*MarketplaceEntry, error) { + if c == nil || c.BaseURL == "" { + return nil, fmt.Errorf("marketplace: client not configured") + } + if name == "" { + return nil, fmt.Errorf("marketplace: empty plugin name") + } + url := c.BaseURL + "/plugins/" + name + ".json" + client := c.HTTPClient + if client == nil { + client = http.DefaultClient + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("marketplace: build request: %w", err) + } + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("marketplace: get %s: %w", url, err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return nil, ErrMarketplaceMiss + } + if resp.StatusCode != http.StatusOK { + // Limit how much we read so a hostile server can't blow up memory. + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return nil, fmt.Errorf("marketplace: %s returned %d: %s", url, resp.StatusCode, string(body)) + } + var entry MarketplaceEntry + if err := json.NewDecoder(resp.Body).Decode(&entry); err != nil { + return nil, fmt.Errorf("marketplace: decode %s: %w", url, err) + } + if entry.Npm == "" && entry.Git == "" { + return nil, fmt.Errorf("marketplace: %s is decommissioned", name) + } + return &entry, nil +} diff --git a/internal/plugin/marketplace_test.go b/internal/plugin/marketplace_test.go new file mode 100644 index 0000000..a1e7d82 --- /dev/null +++ b/internal/plugin/marketplace_test.go @@ -0,0 +1,211 @@ +package plugin + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +// stubMarketplace builds an httptest.Server that maps "name" -> JSON entries. +// Names not in the store return 404. +func stubMarketplace(t *testing.T, store map[string]MarketplaceEntry) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + for name, entry := range store { + entry := entry // capture + name := name + mux.HandleFunc("/plugins/"+name+".json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(entry) + }) + } + // Catch-all miss handler -> 404. + mux.HandleFunc("/plugins/", func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// TestMarketplaceClient_Resolve_NpmEntry: 200 + npm field → returns the entry verbatim. +func TestMarketplaceClient_Resolve_NpmEntry(t *testing.T) { + srv := stubMarketplace(t, map[string]MarketplaceEntry{ + "cool-thing": {Npm: "@late/cool-thing", Description: "Cool thing"}, + }) + c := &MarketplaceClient{BaseURL: srv.URL, HTTPClient: srv.Client()} + got, err := c.Resolve(context.Background(), "cool-thing") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Npm != "@late/cool-thing" { + t.Fatalf("expected npm target, got %+v", got) + } + if got.SourceType() != "npm" { + t.Fatalf("expected source type \"npm\", got %q", got.SourceType()) + } +} + +// TestMarketplaceClient_Resolve_GitEntry: 200 + git field → returns the entry verbatim. +func TestMarketplaceClient_Resolve_GitEntry(t *testing.T) { + srv := stubMarketplace(t, map[string]MarketplaceEntry{ + "git-only": {Git: "github:user/repo"}, + }) + c := &MarketplaceClient{BaseURL: srv.URL, HTTPClient: srv.Client()} + got, err := c.Resolve(context.Background(), "git-only") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.SourceType() != "git" { + t.Fatalf("expected source type \"git\", got %q", got.SourceType()) + } +} + +// TestMarketplaceClient_Resolve_NotFound_ReturnsMiss: 404 → ErrMarketplaceMiss +// so callers can fall back to npm-as-package. +func TestMarketplaceClient_Resolve_NotFound_ReturnsMiss(t *testing.T) { + srv := stubMarketplace(t, nil) + c := &MarketplaceClient{BaseURL: srv.URL, HTTPClient: srv.Client()} + _, err := c.Resolve(context.Background(), "missing-thing") + if !errors.Is(err, ErrMarketplaceMiss) { + t.Fatalf("expected ErrMarketplaceMiss, got %v", err) + } +} + +// TestMarketplaceClient_Resolve_BothEmptyIsDecommissioned: 200 + empty payload +// is rejected (decommissioned). +func TestMarketplaceClient_Resolve_BothEmptyIsDecommissioned(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/plugins/empty.json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + c := &MarketplaceClient{BaseURL: srv.URL, HTTPClient: srv.Client()} + _, err := c.Resolve(context.Background(), "empty") + if err == nil { + t.Fatal("expected error for decommissioned entry, got nil") + } + if !contains(err.Error(), "decommissioned") { + t.Fatalf("expected decommissioned error, got %v", err) + } +} + +// TestMarketplaceClient_Resolve_ServerError_NotMissed: HTTP 500 is NOT a +// cacheable miss — callers should fall back to npm but the error itself +// stays surfaced so the user can debug. +func TestMarketplaceClient_Resolve_ServerError_NotMissed(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("boom")) + })) + t.Cleanup(srv.Close) + c := &MarketplaceClient{BaseURL: srv.URL, HTTPClient: srv.Client()} + _, err := c.Resolve(context.Background(), "anything") + if err == nil { + t.Fatal("expected error") + } + if errors.Is(err, ErrMarketplaceMiss) { + t.Fatalf("500 should NOT be ErrMarketplaceMiss, got %v", err) + } + if !contains(err.Error(), "500") { + t.Fatalf("expected status code in error, got %v", err) + } +} + +// TestMarketplaceClient_Resolve_NilOrEmpty: defensive checks reject +// misconfiguration out — never crash on a nil receiver. +func TestMarketplaceClient_Resolve_NilOrEmpty(t *testing.T) { + if _, err := ((*MarketplaceClient)(nil)).Resolve(context.Background(), "x"); err == nil { + t.Fatal("expected error on nil receiver") + } + c := &MarketplaceClient{BaseURL: ""} + if _, err := c.Resolve(context.Background(), "x"); err == nil { + t.Fatal("expected error on empty BaseURL") + } + if _, err := c.Resolve(context.Background(), ""); err == nil { + t.Fatal("expected error on empty name") + } +} + +// TestMarketplaceEntry_SourceType: explicit table check for the discriminator. +func TestMarketplaceEntry_SourceType(t *testing.T) { + cases := []struct { + name string + entry *MarketplaceEntry + want string + }{ + {"nil receiver", nil, ""}, + {"npm only", &MarketplaceEntry{Npm: "x"}, "npm"}, + {"git only", &MarketplaceEntry{Git: "y"}, "git"}, + {"both — npm wins precedence", &MarketplaceEntry{Npm: "x", Git: "y"}, "npm"}, + {"neither — decommissioned marker", &MarketplaceEntry{Description: "z"}, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.entry.SourceType(); got != tc.want { + t.Fatalf("got %q want %q", got, tc.want) + } + }) + } +} + +// contains is a small helper so we don't import strings in just one test +// when fmt.Errorf already formats the same way elsewhere. Returns true +// if needle appears in haystack. +func contains(haystack, needle string) bool { + if len(needle) == 0 { + return true + } + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + return true + } + } + return false +} + +// TestLooksLikeGitSource / TestLooksLikeLocalPath / TestLooksLikeNpmPackage +// pin the dispatcher classifier so future regressions break the build. +func TestLooksLikeSources(t *testing.T) { + git := []string{"https://github.com/x/y", "http://x/y.git", "git@github.com:x/y", "github:u/r", "gitlab:u/r", "bitbucket:u/r"} + for _, s := range git { + if !looksLikeGitSource(s) { + t.Errorf("expected git: %q", s) + } + } + local := []string{"./mine", "../sibling/mine", "~/d/mine", "/abs/path"} + for _, s := range local { + if !looksLikeLocalPath(s) { + t.Errorf("expected local: %q", s) + } + } + npm := []string{"@scope/pkg", "user/repo"} + for _, s := range npm { + if !looksLikeNpmPackage(s) { + t.Errorf("expected npm: %q", s) + } + } + // Bare names — neither local nor git nor npm. + bare := []string{"cool-thing", "fmt", "a"} + for _, s := range bare { + if looksLikeGitSource(s) || looksLikeLocalPath(s) || looksLikeNpmPackage(s) { + t.Errorf("bare name should hit marketplace only: %q", s) + } + } + // Avoid a fmt import: this just ensures contains behaves without + // panicking and shows up in the trace for TUI smoke. + if contains("abc", "") != true { + t.Fatal("contains empty substring must be true") + } + if contains("abc", "z") != false { + t.Fatal("contains missing must be false") + } + // Keep fmt referenced so it doesn't drift from future use. + _ = fmt.Sprintf("noop") +} diff --git a/internal/plugin/project_test.go b/internal/plugin/project_test.go new file mode 100644 index 0000000..6144096 --- /dev/null +++ b/internal/plugin/project_test.go @@ -0,0 +1,650 @@ +package plugin + +import ( + "os" + "path/filepath" + "testing" + "time" +)// writeBarePlugin creates a minimal plugin directory with a native-Late +// package.json at the given path and returns the directory. It is +// intentionally a different name from the rich +// `writeMinimalPluginManifest` helper that drives plugin-update tests; +// this helper is for project-dir routing tests that don't need a +// manifest round-trip. The `"late": {}` block is required so LoadPlugin +// recognizes the directory as a valid native-Late plugin; without it, +// the loader fails with "no recognized plugin format" because the +// package.json has no `late`, `omp`, or `.claude-plugin/plugin.json`. +func writeBarePlugin(t *testing.T, dir, name string) string { + t.Helper() + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("failed to create plugin dir %s: %v", dir, err) + } + pkg := `{ + "name": "` + name + `", + "version": "1.0.0", + "description": "Test plugin ` + name + `", + "late": {} +}` + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(pkg), 0644); err != nil { + t.Fatalf("failed to write package.json: %v", err) + } + return dir +} + +// --------------------------------------------------------------------------- +// PluginManager project-dir methods +// --------------------------------------------------------------------------- + +func TestPluginManager_SetProjectDir(t *testing.T) { + pm := NewPluginManager("/tmp/global-plugins") + if pm.HasProjectDir() { + t.Error("expected HasProjectDir to be false initially") + } + if pm.ProjectDir() != "" { + t.Error("expected ProjectDir to be empty initially") + } + + pm.SetProjectDir("/tmp/project-plugins") + if !pm.HasProjectDir() { + t.Error("expected HasProjectDir to be true after SetProjectDir") + } + if pm.ProjectDir() != "/tmp/project-plugins" { + t.Errorf("expected ProjectDir /tmp/project-plugins, got %s", pm.ProjectDir()) + } +} + +func TestPluginManager_TargetDir(t *testing.T) { + global := "/tmp/global-plugins" + project := "/tmp/project-plugins" + + t.Run("global only", func(t *testing.T) { + pm := NewPluginManager(global) + if got := pm.TargetDir(false); got != global { + t.Errorf("TargetDir(false) = %s, want %s", got, global) + } + // When no project dir is set, TargetDir(true) should still return global + if got := pm.TargetDir(true); got != global { + t.Errorf("TargetDir(true) without project = %s, want %s", got, global) + } + }) + + t.Run("global + project", func(t *testing.T) { + pm := NewPluginManager(global) + pm.SetProjectDir(project) + if got := pm.TargetDir(false); got != global { + t.Errorf("TargetDir(false) = %s, want %s", got, global) + } + if got := pm.TargetDir(true); got != project { + t.Errorf("TargetDir(true) = %s, want %s", got, project) + } + }) +} + +// --------------------------------------------------------------------------- +// Discover with project-local directory +// --------------------------------------------------------------------------- + +func TestDiscover_GlobalOnly(t *testing.T) { + globalDir := t.TempDir() + writeBarePlugin(t, filepath.Join(globalDir, "global-one"), "global-one") + writeBarePlugin(t, filepath.Join(globalDir, "global-two"), "global-two") + + pm := NewPluginManager(globalDir) + if err := pm.Discover(); err != nil { + t.Fatalf("Discover failed: %v", err) + } + + if pm.Count() != 2 { + t.Errorf("expected 2 plugins, got %d", pm.Count()) + } + if pm.Plugin("global-one") == nil { + t.Error("expected global-one to be found") + } + if pm.Plugin("global-two") == nil { + t.Error("expected global-two to be found") + } +} + +func TestDiscover_ProjectOnly(t *testing.T) { + globalDir := t.TempDir() + projectDir := t.TempDir() + writeBarePlugin(t, filepath.Join(projectDir, "proj-one"), "proj-one") + + pm := NewPluginManager(globalDir) + pm.SetProjectDir(projectDir) + if err := pm.Discover(); err != nil { + t.Fatalf("Discover failed: %v", err) + } + + if pm.Count() != 1 { + t.Errorf("expected 1 plugin, got %d", pm.Count()) + } + if pm.Plugin("proj-one") == nil { + t.Error("expected proj-one to be found") + } +} + +func TestDiscover_BothDirectories(t *testing.T) { + globalDir := t.TempDir() + projectDir := t.TempDir() + writeBarePlugin(t, filepath.Join(globalDir, "global-a"), "global-a") + writeBarePlugin(t, filepath.Join(globalDir, "global-b"), "global-b") + writeBarePlugin(t, filepath.Join(projectDir, "project-a"), "project-a") + + pm := NewPluginManager(globalDir) + pm.SetProjectDir(projectDir) + if err := pm.Discover(); err != nil { + t.Fatalf("Discover failed: %v", err) + } + + if pm.Count() != 3 { + t.Errorf("expected 3 plugins, got %d", pm.Count()) + } + if pm.Plugin("global-a") == nil { + t.Error("expected global-a to be found") + } + if pm.Plugin("global-b") == nil { + t.Error("expected global-b to be found") + } + if pm.Plugin("project-a") == nil { + t.Error("expected project-a to be found") + } +} + +func TestDiscover_ProjectOverridesGlobal(t *testing.T) { + globalDir := t.TempDir() + projectDir := t.TempDir() + + // Same plugin name in both dirs, different versions + globalPluginDir := filepath.Join(globalDir, "my-plugin") + writeBarePlugin(t, globalPluginDir, "my-plugin") + // Write a version 2.0.0 to the project dir + projPluginDir := filepath.Join(projectDir, "my-plugin") + if err := os.MkdirAll(projPluginDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + projPkg := `{"name": "my-plugin", "version": "2.0.0", "description": "Project-local override", "late": {}}` + if err := os.WriteFile(filepath.Join(projPluginDir, "package.json"), []byte(projPkg), 0644); err != nil { + t.Fatalf("write package.json: %v", err) + } + + pm := NewPluginManager(globalDir) + pm.SetProjectDir(projectDir) + if err := pm.Discover(); err != nil { + t.Fatalf("Discover failed: %v", err) + } + + if pm.Count() != 1 { + t.Errorf("expected 1 plugin (overridden), got %d", pm.Count()) + } + plugin := pm.Plugin("my-plugin") + if plugin == nil { + t.Fatal("expected my-plugin to be found") + } + if plugin.Version != "2.0.0" { + t.Errorf("expected version 2.0.0 (project override), got %s", plugin.Version) + } +} + +func TestDiscover_IgnoresNodeModulesAndCache(t *testing.T) { + globalDir := t.TempDir() + writeBarePlugin(t, filepath.Join(globalDir, "real-plugin"), "real-plugin") + os.MkdirAll(filepath.Join(globalDir, "node_modules", "some-pkg"), 0755) + os.MkdirAll(filepath.Join(globalDir, ".cache", "stuff"), 0755) + + pm := NewPluginManager(globalDir) + if err := pm.Discover(); err != nil { + t.Fatalf("Discover failed: %v", err) + } + + if pm.Count() != 1 { + t.Errorf("expected 1 plugin (ignoring node_modules and .cache), got %d", pm.Count()) + } +} + +// --------------------------------------------------------------------------- +// InstallFromLocal with project flag +// --------------------------------------------------------------------------- + +func TestInstallFromLocal_Project(t *testing.T) { + globalDir := t.TempDir() + projectDir := t.TempDir() + sourceDir := t.TempDir() + writeBarePlugin(t, sourceDir, "test-plugin") + + pm := NewPluginManager(globalDir) + pm.SetProjectDir(projectDir) + + plugin, err := InstallFromLocal(pm, sourceDir, true) // project = true + if err != nil { + t.Fatalf("InstallFromLocal(project=true) failed: %v", err) + } + + if plugin.Name != "test-plugin" { + t.Errorf("expected plugin name test-plugin, got %s", plugin.Name) + } + // Path should be inside the project dir + expectedPath := filepath.Join(projectDir, "test-plugin") + if plugin.Path != expectedPath { + t.Errorf("expected path %s, got %s", expectedPath, plugin.Path) + } + // Symlink should exist in the project dir + info, err := os.Lstat(expectedPath) + if err != nil { + t.Fatalf("expected symlink at %s: %v", expectedPath, err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Error("expected a symlink") + } + + // Plugin should be registered in the manager + if pm.Plugin("test-plugin") == nil { + t.Error("expected plugin to be registered in manager") + } +} + +func TestInstallFromLocal_Global(t *testing.T) { + globalDir := t.TempDir() + projectDir := t.TempDir() + sourceDir := t.TempDir() + writeBarePlugin(t, sourceDir, "test-plugin") + + pm := NewPluginManager(globalDir) + pm.SetProjectDir(projectDir) + + plugin, err := InstallFromLocal(pm, sourceDir) // project = default (false) + if err != nil { + t.Fatalf("InstallFromLocal(global) failed: %v", err) + } + + expectedPath := filepath.Join(globalDir, "test-plugin") + if plugin.Path != expectedPath { + t.Errorf("expected path %s, got %s", expectedPath, plugin.Path) + } +} + +// --------------------------------------------------------------------------- +// Link with project flag +// --------------------------------------------------------------------------- + +func TestLink_Project(t *testing.T) { + globalDir := t.TempDir() + projectDir := t.TempDir() + sourceDir := t.TempDir() + writeBarePlugin(t, sourceDir, "linked-plugin") + + pm := NewPluginManager(globalDir) + pm.SetProjectDir(projectDir) + + plugin, err := Link(pm, sourceDir, true) + if err != nil { + t.Fatalf("Link(project=true) failed: %v", err) + } + + expectedPath := filepath.Join(projectDir, "linked-plugin") + if plugin.Path != expectedPath { + t.Errorf("expected path %s, got %s", expectedPath, plugin.Path) + } +} + +func TestLink_Global(t *testing.T) { + globalDir := t.TempDir() + projectDir := t.TempDir() + sourceDir := t.TempDir() + writeBarePlugin(t, sourceDir, "linked-plugin") + + pm := NewPluginManager(globalDir) + pm.SetProjectDir(projectDir) + + plugin, err := Link(pm, sourceDir) // global by default + if err != nil { + t.Fatalf("Link(global) failed: %v", err) + } + + expectedPath := filepath.Join(globalDir, "linked-plugin") + if plugin.Path != expectedPath { + t.Errorf("expected path %s, got %s", expectedPath, plugin.Path) + } +} + +// --------------------------------------------------------------------------- +// RemovePlugin with project flag +// --------------------------------------------------------------------------- + +func TestRemovePlugin_Project(t *testing.T) { + globalDir := t.TempDir() + projectDir := t.TempDir() + sourceDir := t.TempDir() + writeBarePlugin(t, sourceDir, "removable") + + pm := NewPluginManager(globalDir) + pm.SetProjectDir(projectDir) + + // Install into project dir + plugin, err := InstallFromLocal(pm, sourceDir, true) + if err != nil { + t.Fatalf("InstallFromLocal failed: %v", err) + } + + // Remove from project dir + removed, err := RemovePlugin(pm, "removable", true) + if err != nil { + t.Fatalf("RemovePlugin failed: %v", err) + } + + if removed.Name != "removable" { + t.Errorf("expected removed plugin name removable, got %s", removed.Name) + } + + // Symlink should be gone + if _, err := os.Lstat(plugin.Path); !os.IsNotExist(err) { + t.Error("expected symlink to be removed") + } + + // Plugin should be removed from manager + if pm.Plugin("removable") != nil { + t.Error("expected plugin to be removed from manager") + } +} + +func TestRemovePlugin_Global(t *testing.T) { + globalDir := t.TempDir() + projectDir := t.TempDir() + sourceDir := t.TempDir() + writeBarePlugin(t, sourceDir, "removable") + + pm := NewPluginManager(globalDir) + pm.SetProjectDir(projectDir) + + // Install into global dir + plugin, err := InstallFromLocal(pm, sourceDir) // default global + if err != nil { + t.Fatalf("InstallFromLocal failed: %v", err) + } + + // Remove from global dir + _, err = RemovePlugin(pm, "removable") // default global + if err != nil { + t.Fatalf("RemovePlugin failed: %v", err) + } + + if _, err := os.Lstat(plugin.Path); !os.IsNotExist(err) { + t.Error("expected symlink to be removed") + } + if pm.Plugin("removable") != nil { + t.Error("expected plugin to be removed from manager") + } +} + +func TestRemovePlugin_NotFound(t *testing.T) { + pm := NewPluginManager(t.TempDir()) + _, err := RemovePlugin(pm, "nonexistent", true) + if err == nil { + t.Error("expected error when removing non-existent plugin") + } +} + +// --------------------------------------------------------------------------- +// HasProjectDir / TargetDir integration +// --------------------------------------------------------------------------- + +func TestHasProjectDir_Methods(t *testing.T) { + pm := NewPluginManager("/tmp/global") + + if pm.HasProjectDir() { + t.Error("expected false before SetProjectDir") + } + + pm.SetProjectDir("/tmp/project") + if !pm.HasProjectDir() { + t.Error("expected true after SetProjectDir") + } + + if pm.ProjectDir() != "/tmp/project" { + t.Errorf("expected /tmp/project, got %s", pm.ProjectDir()) + } + + // TargetDir(true) should return project dir + if pm.TargetDir(true) != "/tmp/project" { + t.Errorf("TargetDir(true) expected /tmp/project, got %s", pm.TargetDir(true)) + } + + // TargetDir(false) should return global dir + if pm.TargetDir(false) != "/tmp/global" { + t.Errorf("TargetDir(false) expected /tmp/global, got %s", pm.TargetDir(false)) + } +} + +// --------------------------------------------------------------------------- +// parseProjectFlag +// --------------------------------------------------------------------------- + +func TestParseProjectFlag(t *testing.T) { + tests := []struct { + name string + args []string + wantProj bool + wantRest string + }{ + { + name: "no flag, has source", + args: []string{"some-source"}, + wantProj: false, + wantRest: "some-source", + }, + { + name: "--project flag, has source", + args: []string{"--project", "some-source"}, + wantProj: true, + wantRest: "some-source", + }, + { + name: "--local flag, has source", + args: []string{"--local", "some-source"}, + wantProj: true, + wantRest: "some-source", + }, + { + name: "source then --project (parser scans all args)", + args: []string{"some-source", "--project"}, + wantProj: true, // parseProjectFlag scans every arg for the flags, regardless of position + wantRest: "some-source", + }, + { + name: "--project first, source second", + args: []string{"--project", "source"}, + wantProj: true, + wantRest: "source", + }, + { + name: "only --project, no source", + args: []string{"--project"}, + wantProj: true, + wantRest: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotProj, gotRest := parseProjectFlag(tt.args) + if gotProj != tt.wantProj { + t.Errorf("parseProjectFlag() project = %v, want %v", gotProj, tt.wantProj) + } + if gotRest != tt.wantRest { + t.Errorf("parseProjectFlag() rest = %q, want %q", gotRest, tt.wantRest) + } + }) + } +} + +// --------------------------------------------------------------------------- +// PollingWatcher AddWatchDir and takeSnapshot +// --------------------------------------------------------------------------- + +func TestAddWatchDir(t *testing.T) { + w := &PollingWatcher{} + if len(w.projectDirs) != 0 { + t.Error("expected empty projectDirs initially") + } + + w.AddWatchDir("/tmp/dir1") + if len(w.projectDirs) != 1 { + t.Errorf("expected 1 dir, got %d", len(w.projectDirs)) + } + + // Adding the same dir again should be a no-op + w.AddWatchDir("/tmp/dir1") + if len(w.projectDirs) != 1 { + t.Errorf("expected still 1 dir after duplicate add, got %d", len(w.projectDirs)) + } + + // Adding empty string should be a no-op + w.AddWatchDir("") + if len(w.projectDirs) != 1 { + t.Errorf("expected still 1 dir after empty add, got %d", len(w.projectDirs)) + } + + // Adding a different dir + w.AddWatchDir("/tmp/dir2") + if len(w.projectDirs) != 2 { + t.Errorf("expected 2 dirs, got %d", len(w.projectDirs)) + } +} + +func TestTakeSnapshot_MultipleDirs(t *testing.T) { + globalDir := t.TempDir() + projectDir := t.TempDir() + + // Create one plugin in global, one in project + writeBarePlugin(t, filepath.Join(globalDir, "global-pkg"), "global-pkg") + writeBarePlugin(t, filepath.Join(projectDir, "project-pkg"), "project-pkg") + + pm := NewPluginManager(globalDir) + pm.SetProjectDir(projectDir) + w := NewPollingWatcher(pm) + + snapshot := w.takeSnapshot() + + // Both plugins should appear in the snapshot + globalEntry, ok := snapshot["global-pkg"] + if !ok { + t.Error("expected global-pkg in snapshot") + } else { + if !globalEntry.exists { + t.Error("expected global-pkg exists=true") + } + if !globalEntry.hasLateFile { + t.Error("expected global-pkg hasLateFile=true") + } + } + + projectEntry, ok := snapshot["project-pkg"] + if !ok { + t.Error("expected project-pkg in snapshot") + } else { + if !projectEntry.exists { + t.Error("expected project-pkg exists=true") + } + if !projectEntry.hasLateFile { + t.Error("expected project-pkg hasLateFile=true") + } + } + + // Global dir file entries should not appear + if _, ok := snapshot["node_modules"]; ok { + t.Error("expected node_modules not in snapshot") + } +} + +func TestTakeSnapshot_ProjectPluginOverrides(t *testing.T) { + globalDir := t.TempDir() + projectDir := t.TempDir() + + // Same plugin name in both dirs + writeBarePlugin(t, filepath.Join(globalDir, "shared-pkg"), "shared-pkg") + writeBarePlugin(t, filepath.Join(projectDir, "shared-pkg"), "shared-pkg") + + pm := NewPluginManager(globalDir) + pm.SetProjectDir(projectDir) + w := NewPollingWatcher(pm) + + snapshot := w.takeSnapshot() + + // The project-local version should win (last writer wins in the snapshot map) + if _, ok := snapshot["shared-pkg"]; !ok { + t.Error("expected shared-pkg in snapshot") + } +} + +func TestTakeSnapshot_WithAddedWatchDir(t *testing.T) { + globalDir := t.TempDir() + extraDir := t.TempDir() + + writeBarePlugin(t, filepath.Join(globalDir, "global-pkg"), "global-pkg") + writeBarePlugin(t, filepath.Join(extraDir, "extra-pkg"), "extra-pkg") + + pm := NewPluginManager(globalDir) + w := NewPollingWatcher(pm) + w.AddWatchDir(extraDir) + + snapshot := w.takeSnapshot() + + if _, ok := snapshot["global-pkg"]; !ok { + t.Error("expected global-pkg in snapshot") + } + if _, ok := snapshot["extra-pkg"]; !ok { + t.Error("expected extra-pkg in snapshot") + } +} + +func TestSnapshotChanged_DetectsChangesAcrossDirs(t *testing.T) { + w := &PollingWatcher{} + now := time.Now() + + // Pretend we have two plugins from different dirs in the snapshot + old := map[string]pluginSnapshotEntry{ + "global-pkg": {exists: true, enabled: true, modTime: now, hasLateFile: true}, + "proj-pkg": {exists: true, enabled: true, modTime: now, hasLateFile: true}, + } + + // One is removed + new := map[string]pluginSnapshotEntry{ + "global-pkg": {exists: true, enabled: true, modTime: now, hasLateFile: true}, + } + + if !w.snapshotChanged(old, new) { + t.Error("expected true when a plugin is removed") + } +} + +func TestSnapshotChanged_NoChangeAcrossDirs(t *testing.T) { + w := &PollingWatcher{} + now := time.Now() + + snapshot := map[string]pluginSnapshotEntry{ + "pkg-a": {exists: true, enabled: true, modTime: now, hasLateFile: true}, + "pkg-b": {exists: true, enabled: false, modTime: now.Add(-time.Hour), hasLateFile: true}, + } + + if w.snapshotChanged(snapshot, snapshot) { + t.Error("expected false for identical snapshots with multi-dir entries") + } +} + +// --------------------------------------------------------------------------- +// PluginPathInDir +// --------------------------------------------------------------------------- + +func TestPluginPathInDir(t *testing.T) { + pm := NewPluginManager("/tmp/global") + + expected := "/tmp/global/test-plugin" + if got := pm.PluginPath("test-plugin"); got != expected { + t.Errorf("PluginPath = %s, want %s", got, expected) + } + + expected2 := "/tmp/project/test-plugin" + if got := pm.PluginPathInDir("/tmp/project", "test-plugin"); got != expected2 { + t.Errorf("PluginPathInDir = %s, want %s", got, expected2) + } +} diff --git a/internal/plugin/sandbox_linux.go b/internal/plugin/sandbox_linux.go new file mode 100644 index 0000000..56e5d24 --- /dev/null +++ b/internal/plugin/sandbox_linux.go @@ -0,0 +1,33 @@ +//go:build linux + +package plugin + +import ( + "os/exec" +) + +// setCmdSysProcAttr applies Linux-specific hardening to an exec.Cmd. +// +// We previously set Cloneflags: syscall.CLONE_NEWPID here for PID +// namespace isolation, but unprivileged PID namespaces are denied by +// many kernels / container runtimes / AppArmor profiles and the +// resulting EPERM aborts hook execution outright (fork/exec: operation +// not permitted). +// +// We also considered setting NoNewPrivs (the kernel-enforced +// "no setuid/setgid/file-cap surprises" flag), but that flag lives +// only on golang.org/x/sys/unix.SysProcAttr, not on the standard +// library's syscall.SysProcAttr. Adding a new dependency is out of +// scope for this fix; until x/sys/unix is adopted, leave SysProcAttr +// unset and document the trade-off below. +// +// SECURITY POSTURE CALL-OUT: +// Plugin hooks now run with the same privileges as the `late` +// process and share the host PID namespace. The trust boundary +// becomes the manifest itself — Late is not a sandbox, it's an +// installer. Plugin authors MUST treat script content as if a user +// could have authored it (which they could — `late plugin link` +// points at any local directory on disk). +func setCmdSysProcAttr(cmd *exec.Cmd) { + cmd.SysProcAttr = nil +} diff --git a/internal/plugin/sandbox_other.go b/internal/plugin/sandbox_other.go new file mode 100644 index 0000000..33f32ad --- /dev/null +++ b/internal/plugin/sandbox_other.go @@ -0,0 +1,12 @@ +//go:build !linux + +package plugin + +import "os/exec" + +// setCmdSysProcAttr is a no-op on non-Linux platforms where SysProcAttr +// fields like NoNewPrivs and clone flags are either unavailable or +// inapplicable. +func setCmdSysProcAttr(cmd *exec.Cmd) { + // no-op +} diff --git a/internal/plugin/security_test.go b/internal/plugin/security_test.go new file mode 100644 index 0000000..f81d552 --- /dev/null +++ b/internal/plugin/security_test.go @@ -0,0 +1,505 @@ +package plugin + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// --------------------------------------------------------------------------- +// Discover mutex (#1-3) +// --------------------------------------------------------------------------- + +// TestDiscover_ConcurrentReadsDontPanic runs Discover and parallel readers +// in tandem. With the fix in place, the read paths take RLock while Discover +// holds the write lock, so reads are serialized — no concurrent map access. +func TestDiscover_ConcurrentReadsDontPanic(t *testing.T) { + globalDir := t.TempDir() + projectDir := t.TempDir() + pluginDir := filepath.Join(globalDir, "concurrent-plugin") + if err := os.MkdirAll(pluginDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + pkg := `{"name": "concurrent-plugin", "version": "1.0.0", "late": {}}` + if err := os.WriteFile(filepath.Join(pluginDir, "package.json"), []byte(pkg), 0644); err != nil { + t.Fatalf("write package.json: %v", err) + } + + pm := NewPluginManager(globalDir) + pm.SetProjectDir(projectDir) + + // Pre-populate via the (locked) Add path + if err := pm.Discover(); err != nil { + t.Fatalf("initial Discover: %v", err) + } + + var wg sync.WaitGroup + stop := make(chan struct{}) + + // 4 reader goroutines hammer the read API + for i := 0; i < 4; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + _ = pm.Plugin("concurrent-plugin") + _ = pm.Count() + _ = pm.All() + _ = pm.PluginCommands() + _ = pm.BuildMCPConfigMap() + } + } + }() + } + + // 1 writer goroutine repeatedly calls the locked Discover + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 20; i++ { + if err := pm.Discover(); err != nil { + t.Errorf("Discover failed: %v", err) + return + } + } + }() + + // Run reader+writer concurrently for a moment, then stop the readers + time.Sleep(150 * time.Millisecond) + close(stop) + wg.Wait() + + // Final sanity: at least one plugin should still be present + if pm.Count() == 0 { + t.Error("expected plugin to still be present after concurrent reads") + } +} + +// TestConcurrentSetProjectDirAndRead verifies SetProjectDir takes the lock +// and reads happen consistently. +func TestConcurrentSetProjectDirAndRead(t *testing.T) { + pm := NewPluginManager(t.TempDir()) + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(2) + go func(i int) { + defer wg.Done() + pm.SetProjectDir(filepath.Join(t.TempDir(), "proj")) + }(i) + go func() { + defer wg.Done() + _ = pm.HasProjectDir() + _ = pm.ProjectDir() + _ = pm.TargetDir(true) + _ = pm.TargetDir(false) + }() + } + wg.Wait() +} + +// --------------------------------------------------------------------------- +// Watcher (#4, #7) +// --------------------------------------------------------------------------- + +// TestTakeSnapshot_DedupesAcrossSources verifies that takeSnapshot walks a +// directory at most once even when it's referenced through both +// AddWatchDir() and SetProjectDir(). +func TestTakeSnapshot_DedupesAcrossSources(t *testing.T) { + // Use a single dir as global, and reference same path as project + addWatch + dir := t.TempDir() + mkPlugin(t, dir, "uniq") + + pm := NewPluginManager(dir) + pm.SetProjectDir(dir) // duplicate -> should be deduped + + w := NewPollingWatcher(pm) + w.AddWatchDir(dir) // duplicate again -> should be deduped + + snap := w.takeSnapshot() + if len(snap) != 1 { + t.Fatalf("expected exactly 1 plugin entry after dedupe, got %d", len(snap)) + } + if _, ok := snap["uniq"]; !ok { + t.Errorf("expected 'uniq' plugin in snapshot, got keys: %v", keys(snap)) + } +} + +// TestSnapshotChanged_LateFileModDetectsEnableToggle verifies that writing +// the .late-plugin.json file (with the Enabled field changed) is detected +// even when the parent directory's mtime did not change. +func TestSnapshotChanged_LateFileModDetectsEnableToggle(t *testing.T) { + dir := t.TempDir() + pluginDir := filepath.Join(dir, "toggle") + if err := os.MkdirAll(pluginDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + // Write initial .late-plugin.json (enabled) + writeLateMeta(t, pluginDir, true) + + pm := NewPluginManager(dir) + w := NewPollingWatcher(pm) + + before := w.takeSnapshot() + + // Write a NEW .late-plugin.json (disabled), forcing its own mtime bump + time.Sleep(10 * time.Millisecond) // ensure clock granularity moves + writeLateMeta(t, pluginDir, false) + + after := w.takeSnapshot() + + if !w.snapshotChanged(before, after) { + t.Error("expected snapshotChanged to detect enable/disable toggle (lateFileMod change)") + } + + if after["toggle"].enabled { + t.Error("expected enabled=false after toggle") + } + if before["toggle"].enabled != true { + t.Errorf("expected initial enabled=true, got %v", before["toggle"].enabled) + } +} + +// TestSnapshotChanged_NoLateFileMod verifies the same content does not trigger. +func TestSnapshotChanged_NoLateFileMod(t *testing.T) { + dir := t.TempDir() + pluginDir := filepath.Join(dir, "same") + if err := os.MkdirAll(pluginDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + writeLateMeta(t, pluginDir, true) + + w := NewPollingWatcher(nil) // we don't need pm for snapshot comparison + // build snapshot manually to avoid DependOnPM + directDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(directDir, "same"), 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + writeLateMeta(t, filepath.Join(directDir, "same"), true) + + pm := NewPluginManager(directDir) + w2 := NewPollingWatcher(pm) + a := w2.takeSnapshot() + b := w2.takeSnapshot() + if w2.snapshotChanged(a, b) { + t.Error("expected identical snapshots to NOT register as changed") + } + _ = dir + _ = pluginDir + _ = w +} + +// TestWatcher_StartRunsWithoutPanic sanity-checks that the watcher goroutine +// takes/drops the lock without panic in a single tick. ctx is canceled +// immediately so the goroutine exits promptly. +func TestWatcher_StartRunsWithoutPanic(t *testing.T) { + dir := t.TempDir() + mkPlugin(t, dir, "p1") + + pm := NewPluginManager(dir) + if err := pm.Discover(); err != nil { + t.Fatalf("Discover: %v", err) + } + w := NewPollingWatcher(pm) + w.SetInterval(20 * time.Millisecond) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // immediate cancel + + done := make(chan struct{}) + go func() { + defer close(done) + w.Start(ctx, func() {}) + }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("watcher did not exit promptly on canceled context") + } +} + +// --------------------------------------------------------------------------- +// Installer (#6) +// --------------------------------------------------------------------------- + +// TestInstallFromGit_CleansUpOnCloneFailure verifies that a failed git clone +// leaves no half-populated directory behind. +func TestInstallFromGit_CleansUpOnCloneFailure(t *testing.T) { + targetRoot := t.TempDir() + pm := NewPluginManager(targetRoot) + + // An obviously-invalid URL with a name we can look for + url := "https://github.com/does-not-exist-org-9999/repo-does-not-exist-9999.git" + got, err := InstallFromGit(pm, url) + if err == nil { + t.Fatalf("expected InstallFromGit to fail with bad URL, got plugin %v", got) + } + if got != nil { + t.Errorf("expected nil plugin on failure, got %v", got) + } + // The leftover plugin dir for our derived name should not exist + name := pluginNameFromURL(url) + expected := filepath.Join(targetRoot, name) + if _, err := os.Stat(expected); err == nil { + t.Errorf("expected leftover dir %s to be cleaned up, but it exists", expected) + } else if !os.IsNotExist(err) { + t.Errorf("unexpected stat error: %v", err) + } +} + +// TestInstallFromLocal_WarnsOnSuspiciousPath verifies isSuspiciousPluginPath +// recognises paths clearly outside the user's scope. +func TestInstallFromLocal_WarnsOnSuspiciousPath(t *testing.T) { + // Build a path guaranteed to be outside home AND outside cwd + home, err := os.UserHomeDir() + if err == nil { + home, _ = filepath.Abs(home) + } + cwd, err := os.Getwd() + if err == nil { + cwd, _ = filepath.Abs(cwd) + } + // Use /tmp (typical unix tempdir) + a tag unlikely to exist + suspicious := filepath.Join("/tmp", "late-test-suspicious-path-"+t.Name()) + + if home != "" { + if rel, err := filepath.Rel(home, suspicious); err == nil && rel != ".." && !strings.HasPrefix(rel, "..") { + t.Skip("test environment layout makes /tmp inside home; skipping") + } + } + if cwd != "" { + if rel, err := filepath.Rel(cwd, suspicious); err == nil && rel != ".." && !strings.HasPrefix(rel, "..") { + t.Skip("test environment layout makes /tmp inside cwd; skipping") + } + } + + if !isSuspiciousPluginPath(suspicious) { + t.Errorf("expected %s to be flagged as suspicious", suspicious) + } +} + +// TestInstallFromLocal_AcceptsInScopePath verifies legitimate in-scope paths +// don't trigger the suspicious flag. +func TestInstallFromLocal_AcceptsInScopePath(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + inScope := filepath.Join(cwd, "plugins-test") + if isSuspiciousPluginPath(inScope) { + t.Errorf("expected %s (under cwd) to NOT be suspicious", inScope) + } +} + +// --------------------------------------------------------------------------- +// RegisterPluginSkills (#27) +// --------------------------------------------------------------------------- + +// TestRegisterPluginSkills_RejectsPathTraversal verifies that a plugin whose +// manifest declares a skill path that escapes the plugin directory is +// silently skipped (the symlink is not created). +func TestRegisterPluginSkills_RejectsPathTraversal(t *testing.T) { + dir := t.TempDir() + pluginDir := filepath.Join(dir, "evil-plugin") + traversalTarget := filepath.Join(dir, "secret-skills") + if err := os.MkdirAll(pluginDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.MkdirAll(traversalTarget, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + plugin := &InstalledPlugin{ + Name: "evil-plugin", + Path: pluginDir, + Enabled: true, + Late: &LateManifest{ + Skills: []string{ + filepath.Join("..", "secret-skills"), // escapes plugin dir + }, + }, + } + + pm := NewPluginManager(dir) + pm.Add(plugin) + + skillsDir := t.TempDir() + if err := pm.RegisterPluginSkills(skillsDir); err != nil { + t.Fatalf("RegisterPluginSkills failed: %v", err) + } + + entries, err := os.ReadDir(skillsDir) + if err != nil { + t.Fatalf("ReadDir skills: %v", err) + } + if len(entries) != 0 { + var names []string + for _, e := range entries { + names = append(names, e.Name()) + } + t.Errorf("expected no symlinks created for path-traversal skill, got: %v", names) + } +} + +// TestRegisterPluginSkills_AllowsInDirSkill verifies normal in-plugin-dir +// skills still create symlinks. +func TestRegisterPluginSkills_AllowsInDirSkill(t *testing.T) { + dir := t.TempDir() + pluginDir := filepath.Join(dir, "good-plugin") + skillDir := filepath.Join(pluginDir, "skills", "my-skill") + if err := os.MkdirAll(skillDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + // Minimal SKILL.md so skill.LoadSkill succeeds + skillMD := "---\nname: my-skill\ndescription: test\n---\n# Test" + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(skillMD), 0644); err != nil { + t.Fatalf("write SKILL.md: %v", err) + } + + plugin := &InstalledPlugin{ + Name: "good-plugin", + Path: pluginDir, + Enabled: true, + Late: &LateManifest{ + Skills: []string{"skills"}, + }, + } + + pm := NewPluginManager(dir) + pm.Add(plugin) + + skillsDir := t.TempDir() + if err := pm.RegisterPluginSkills(skillsDir); err != nil { + t.Fatalf("RegisterPluginSkills failed: %v", err) + } + + entries, err := os.ReadDir(skillsDir) + if err != nil { + t.Fatalf("ReadDir skills: %v", err) + } + if len(entries) != 1 { + t.Fatalf("expected 1 symlink for in-dir skill, got %d", len(entries)) + } + if entries[0].Name() != "good-plugin:my-skill" { + t.Errorf("unexpected symlink name: %s", entries[0].Name()) + } +} + +// --------------------------------------------------------------------------- +// SavePluginMeta mtime bump (#7 robustness) +// --------------------------------------------------------------------------- + +// TestSavePluginMeta_BumpsMtime verifies SavePluginMeta writes touches the +// file's mtime (so the watcher's snapshot detects the change reliably). +func TestSavePluginMeta_BumpsMtime(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + plugin := &InstalledPlugin{ + Name: "mtime-test", + Path: dir, + Enabled: true, + Late: &LateManifest{}, + } + + // First write + before := time.Now().Add(-1 * time.Hour) // deliberately past + metaPath := filepath.Join(dir, ".late-plugin.json") + if err := os.Chtimes(metaPath, before, before); err == nil { + // Pre-create the file so SavePluginMeta overwrites it + _ = os.Chtimes(metaPath, before, before) + } + + if err := SavePluginMeta(plugin); err != nil { + t.Fatalf("SavePluginMeta: %v", err) + } + info, err := os.Stat(metaPath) + if err != nil { + t.Fatalf("stat: %v", err) + } + if !info.ModTime().After(before) { + t.Errorf("expected mtime > %v after SavePluginMeta, got %v", before, info.ModTime()) + } +} + +// TestSavePluginMeta_PersistsEnabledField verifies the JSON has the enabled field +// so callers can read it back. +func TestSavePluginMeta_PersistsEnabledField(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + plugin := &InstalledPlugin{ + Name: "persist-test", + Path: dir, + Enabled: false, + Late: &LateManifest{}, + } + if err := SavePluginMeta(plugin); err != nil { + t.Fatalf("Save: %v", err) + } + data, err := os.ReadFile(filepath.Join(dir, ".late-plugin.json")) + if err != nil { + t.Fatalf("read: %v", err) + } + var got InstalledPlugin + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Enabled { + t.Error("expected enabled=false to be persisted") + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func mkPlugin(t *testing.T, dir, name string) { + t.Helper() + pdir := filepath.Join(dir, name) + if err := os.MkdirAll(pdir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + // `"late": {}` is required so LoadPlugin accepts this directory as a + // valid native-Late plugin. Without it, every discover/install test in + // this package fails with `no recognized plugin format in `. + pkg := `{"name":"` + name + `","version":"1.0.0","late":{}}` + if err := os.WriteFile(filepath.Join(pdir, "package.json"), []byte(pkg), 0644); err != nil { + t.Fatalf("write: %v", err) + } +} + +func writeLateMeta(t *testing.T, pluginDir string, enabled bool) { + t.Helper() + meta := InstalledPlugin{ + Name: filepath.Base(pluginDir), + Path: pluginDir, + Enabled: enabled, + Late: &LateManifest{}, + } + if err := SavePluginMeta(&meta); err != nil { + t.Fatalf("save: %v", err) + } +} + +func keys(m map[string]pluginSnapshotEntry) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/internal/plugin/themes.go b/internal/plugin/themes.go new file mode 100644 index 0000000..5d27b1c --- /dev/null +++ b/internal/plugin/themes.go @@ -0,0 +1,180 @@ +package plugin + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// ThemeInfo describes a single parsed theme file exposed by a plugin. +// Each theme is identified by a namespaced ID of the form +// ":". +type ThemeInfo struct { + ID string // ":" + PluginName string // owning plugin name + ThemeName string // bare theme name (as declared in the JSON's "name" field) + Palette map[string]string // semantic palette: bg/fg/accent/etc. + Glamour map[string]any // glamour-style JSON overrides (merged into base theme) + SourcePath string // absolute path to the loaded JSON file +} + +// ThemeFile is the on-disk schema for a plugin theme file. Only `name` is +// required; `palette` and `glamour` are optional overrides. Unknown fields +// are tolerated for forward compatibility. +type ThemeFile struct { + Name string `json:"name"` + Palette map[string]string `json:"palette,omitempty"` + Glamour map[string]any `json:"glamour,omitempty"` +} + +// resolveThemePath performs the same kind of containment check we use for +// skills/hooks: the absolute path must live inside the plugin's directory. +func resolveThemePath(pluginDir, relPath string) (string, error) { + if relPath == "" { + return "", fmt.Errorf("empty theme path") + } + abs := filepath.Clean(filepath.Join(pluginDir, relPath)) + rel, err := filepath.Rel(pluginDir, abs) + if err != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) { + return "", fmt.Errorf("theme path %q escapes plugin directory", relPath) + } + return abs, nil +} + +// loadThemeFile reads & parses a single theme JSON. Errors are returned +// rather than logged so callers can decide whether to surface them. +func loadThemeFile(path string) (*ThemeFile, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read theme: %w", err) + } + var f ThemeFile + dec := json.NewDecoder(strings.NewReader(string(data))) + dec.DisallowUnknownFields() + // Unknown-field strictness is in tension with forward compat, so we + // first try strict; if it fails, retry with strict off. + if err := dec.Decode(&f); err != nil { + var loose ThemeFile + if err2 := json.Unmarshal(data, &loose); err2 != nil { + return nil, fmt.Errorf("parse theme: %w (and %v)", err, err2) + } + f = loose + } + if f.Name == "" { + return nil, fmt.Errorf("theme at %s is missing required \"name\" field", path) + } + return &f, nil +} + +// AllThemes enumerates every theme file declared by every enabled plugin, +// parsing each and returning the resulting ThemeInfo list. Invalid or +// unparseable theme files are skipped with a warning. +func (pm *PluginManager) AllThemes() []ThemeInfo { + pm.mu.RLock() + plugins := make([]*InstalledPlugin, 0, len(pm.plugins)) + for _, p := range pm.plugins { + plugins = append(plugins, p) + } + pm.mu.RUnlock() + + var themes []ThemeInfo + for _, p := range plugins { + if !p.Enabled || p.Late == nil || len(p.Late.Themes) == 0 { + continue + } + for _, rel := range p.Late.Themes { + tp, err := resolveThemePath(p.Path, rel) + if err != nil { + fmt.Fprintf(os.Stderr, "[themes] %s: %v\n", p.Name, err) + continue + } + f, err := loadThemeFile(tp) + if err != nil { + fmt.Fprintf(os.Stderr, "[themes] %s: %v\n", p.Name, err) + continue + } + themes = append(themes, ThemeInfo{ + ID: p.Name + ":" + f.Name, + PluginName: p.Name, + ThemeName: f.Name, + Palette: f.Palette, + Glamour: f.Glamour, + SourcePath: tp, + }) + } + } + return themes +} + +// GetTheme looks up a theme by its namespaced ID ("plugin:theme") or by +// bare name (matched against any enabled plugin). For empty id, returns +// (nil, nil) meaning "no theme override; use the base". +func (pm *PluginManager) GetTheme(id string) (*ThemeInfo, error) { + if id == "" { + return nil, nil + } + if strings.Contains(id, ":") { + parts := strings.SplitN(id, ":", 2) + return pm.findTheme(parts[0], parts[1]) + } + // Bare name lookup across all enabled plugins. + pm.mu.RLock() + plugins := make([]*InstalledPlugin, 0, len(pm.plugins)) + for _, p := range pm.plugins { + plugins = append(plugins, p) + } + pm.mu.RUnlock() + + var lastErr error + for _, p := range plugins { + if !p.Enabled || p.Late == nil { + continue + } + info, err := pm.findTheme(p.Name, id) + if err == nil && info != nil { + return info, nil + } + if err != nil { + lastErr = err + } + } + if lastErr == nil { + return nil, fmt.Errorf("theme %q not found", id) + } + return nil, lastErr +} + +// findTheme walks a single plugin's declared theme files and returns the +// first matching entry by bare theme name. +func (pm *PluginManager) findTheme(pluginName, themeName string) (*ThemeInfo, error) { + pm.mu.RLock() + p := pm.plugins[pluginName] + pm.mu.RUnlock() + + if p == nil || !p.Enabled || p.Late == nil { + return nil, fmt.Errorf("plugin %q not found or disabled", pluginName) + } + for _, rel := range p.Late.Themes { + tp, err := resolveThemePath(p.Path, rel) + if err != nil { + continue + } + f, err := loadThemeFile(tp) + if err != nil { + continue + } + if f.Name == themeName { + return &ThemeInfo{ + ID: p.Name + ":" + f.Name, + PluginName: p.Name, + ThemeName: f.Name, + Palette: f.Palette, + Glamour: f.Glamour, + SourcePath: tp, + }, nil + } + } + return nil, fmt.Errorf("theme %q not declared by plugin %q", themeName, pluginName) +} diff --git a/internal/plugin/tools.go b/internal/plugin/tools.go new file mode 100644 index 0000000..415810d --- /dev/null +++ b/internal/plugin/tools.go @@ -0,0 +1,74 @@ +package plugin + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "late/internal/client" +) + +// InlineTool describes a plugin-declared tool that runs as a local +// script invocation. Each inline tool becomes a Tool the agent can +// call directly, without an MCP server wrapper. +// +// The Runner signature mirrors common.ToolRunner so the tool can be +// chained into the same ToolMiddleware pipeline that MCP-backed tools +// use. +type InlineTool struct { + Name string // ":" — namespaced to avoid collisions + Description string // shown in the model's tool definitions + Parameters json.RawMessage // raw JSON Schema fragment + Runner func(ctx context.Context, call client.ToolCall) (string, error) +} + +// GetInlineTools aggregates every inline tool declared across all +// enabled plugins. Names are always namespaced as ":" +// to prevent collisions when two plugins declare a tool with the +// same short name. Disabled and nil-manifest plugins are skipped; a +// plugin whose script path fails containment is silently skipped with +// a stderr warning rather than crashing discovery. +func (pm *PluginManager) GetInlineTools() []InlineTool { + pm.mu.RLock() + defer pm.mu.RUnlock() + + var tools []InlineTool + for _, p := range pm.plugins { + if !p.Enabled || p.Late == nil { + continue + } + for _, t := range p.Late.Tools { + if t.Name == "" || t.Script == "" { + continue + } + // Validate path containment up-front so we can skip tools + // whose script would escape the plugin directory. + if _, err := resolveHookPath(p.Path, t.Script); err != nil { + fmt.Fprintf(os.Stderr, "[tools] plugin %s tool %q: %v\n", p.Name, t.Name, err) + continue + } + + // Capture loop vars by value for the closure. + pluginDir := p.Path + scriptPath := t.Script + tools = append(tools, InlineTool{ + Name: p.Name + ":" + t.Name, + Description: t.Description, + Parameters: t.Parameters, + Runner: func(ctx context.Context, call client.ToolCall) (string, error) { + // Tool arguments feed directly into the script stdin. + payload := []byte(call.Function.Arguments) + if !json.Valid(payload) { + // Fallback for tools that don't get a tool-args JSON + // object — pass an empty object so the script has a + // well-formed stdin. + payload = []byte("{}") + } + return runHook(ctx, pluginDir, scriptPath, payload) + }, + }) + } + } + return tools +} diff --git a/internal/plugin/update_test.go b/internal/plugin/update_test.go new file mode 100644 index 0000000..fb6c194 --- /dev/null +++ b/internal/plugin/update_test.go @@ -0,0 +1,381 @@ +package plugin + +import ( + "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// withExecSeams swaps runCommand / runCommandOutput with capturing stubs and +// returns a function that yields the recorded (name, args) invocations. The +// returned cleanup restores the production seams. +func withExecSeams(t *testing.T) (rec func() []recordedExec, cleanup func()) { + t.Helper() + origRun := runCommand + origOut := runCommandOutput + + var ( + mu sync.Mutex + logRun []recordedExec + logOut []recordedExec + stubOut = []byte("ok") + stubErr error + ) + + runCommand = func(ctx context.Context, name string, args ...string) error { + mu.Lock() + defer mu.Unlock() + copyArgs := append([]string(nil), args...) + logRun = append(logRun, recordedExec{name: name, args: copyArgs}) + return stubErr + } + runCommandOutput = func(ctx context.Context, name string, args ...string) ([]byte, error) { + mu.Lock() + defer mu.Unlock() + copyArgs := append([]string(nil), args...) + logOut = append(logOut, recordedExec{name: name, args: copyArgs, out: append([]byte(nil), stubOut...), err: stubErr}) + return stubOut, stubErr + } + + cleanup = func() { + runCommand = origRun + runCommandOutput = origOut + } + rec = func() []recordedExec { + mu.Lock() + defer mu.Unlock() + merged := append([]recordedExec(nil), logRun...) + merged = append(merged, logOut...) + return merged + } + return rec, cleanup +} + +type recordedExec struct { + name string + args []string + out []byte + err error +} + +// writeMinimalPluginManifest writes a real package.json + .late-plugin.json +// to so LoadPlugin succeeds, and returns what to use as the registered +// Source for the installer test. +func writeMinimalPluginManifest(t *testing.T, dir, name, version, source, sourceType string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + pkg := map[string]any{ + "name": name, + "version": version, + "late": map[string]any{ + "skills": []string{"skills/welcome.md"}, + }, + } + pkgBytes, _ := json.MarshalIndent(pkg, "", " ") + if err := os.WriteFile(filepath.Join(dir, "package.json"), pkgBytes, 0o644); err != nil { + t.Fatalf("write package.json: %v", err) + } + meta := map[string]any{ + "name": name, + "version": version, + "path": dir, + "source": source, + "source_type": sourceType, + "enabled": true, + "installed": time.Now().UTC().Format(time.RFC3339), + } + metaBytes, _ := json.MarshalIndent(meta, "", " ") + if err := os.WriteFile(filepath.Join(dir, ".late-plugin.json"), metaBytes, 0o644); err != nil { + t.Fatalf("write .late-plugin.json: %v", err) + } +} + +// TestUpdateNpmHappyPath: Update("foo") records `npm install --prefix ... @latest` +// and returns the fresh plugin entry on the in-memory PluginManager map. +func TestUpdateNpmHappyPath(t *testing.T) { + root := t.TempDir() + pluginsDir := filepath.Join(root, "plugins") + if err := os.MkdirAll(pluginsDir, 0o755); err != nil { + t.Fatalf("mkdir plugins dir: %v", err) + } + rec, cleanup := withExecSeams(t) + defer cleanup() + + // Pre-install npm-shaped plugin: plugins/ is a symlink to + // plugins/node_modules/; both directories exist with a real + // package.json + .late-plugin.json. + nodeModules := filepath.Join(pluginsDir, "node_modules", "foo") + linkTarget := filepath.Join(pluginsDir, "foo") + if err := os.MkdirAll(nodeModules, 0o755); err != nil { + t.Fatalf("mkdir node_modules/foo: %v", err) + } + writeMinimalPluginManifest(t, nodeModules, "foo", "1.0.0", "@late/foo", "npm") + if err := os.Symlink(nodeModules, linkTarget); err != nil { + t.Fatalf("symlink: %v", err) + } + + pm := NewPluginManager(pluginsDir) + if err := pm.Discover(); err != nil { + t.Fatalf("Discover: %v", err) + } + if pm.Plugin("foo") == nil { + t.Fatalf("expected foo to be discovered") + } + + updated, err := Update(pm, "foo", nil) + if err != nil { + t.Fatalf("Update(foo) err: %v", err) + } + if updated == nil { + t.Fatalf("Update returned nil plugin") + } + + calls := rec() + if len(calls) != 1 { + t.Fatalf("expected exactly 1 recorded exec, got %d (%+v)", len(calls), calls) + } + got := calls[0] + if got.name != "npm" { + t.Fatalf("expected npm invocation, got %q", got.name) + } + // Expect: npm install --prefix /plugins --no-save --quiet @late/foo@latest + wantFragments := []string{"install", "--prefix", pluginsDir, "--no-save", "--quiet", "@late/foo@latest"} + for _, frag := range wantFragments { + found := false + for _, a := range got.args { + if a == frag { + found = true + break + } + } + if !found { + t.Fatalf("expected npm args to contain %q, got %v", frag, got.args) + } + } +} + +// TestUpdateLocalRefuses: Update on a local-source plugin returns a clear +// refusal rather than mutating it. +func TestUpdateLocalRefuses(t *testing.T) { + root := t.TempDir() + pluginsDir := filepath.Join(root, "plugins") + if err := os.MkdirAll(pluginsDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + rec, cleanup := withExecSeams(t) + defer cleanup() + + linkTarget := t.TempDir() + writeMinimalPluginManifest(t, linkTarget, "devlink", "0.1.0", linkTarget, "local") + if err := os.Symlink(linkTarget, filepath.Join(pluginsDir, "devlink")); err != nil { + t.Fatalf("symlink: %v", err) + } + + pm := NewPluginManager(pluginsDir) + if err := pm.Discover(); err != nil { + t.Fatalf("Discover: %v", err) + } + + if _, err := Update(pm, "devlink", nil); err == nil { + t.Fatalf("expected Update on local-source plugin to error") + } else if !strings.Contains(err.Error(), "local") { + t.Fatalf("expected error to mention 'local', got %v", err) + } + + if len(rec()) != 0 { + t.Fatalf("expected no exec to run for refused local update, got %+v", rec()) + } +} + +// TestUpdateUnknownName: Update on a name that was never installed errors +// without touching the filesystem (verified by no recorded exec). +func TestUpdateUnknownName(t *testing.T) { + root := t.TempDir() + pluginsDir := filepath.Join(root, "plugins") + if err := os.MkdirAll(pluginsDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + rec, cleanup := withExecSeams(t) + defer cleanup() + + pm := NewPluginManager(pluginsDir) + + if _, err := Update(pm, "nope", nil); err == nil { + t.Fatalf("expected Update on unknown plugin to error") + } + + if len(rec()) != 0 { + t.Fatalf("expected no exec for missing plugin, got %+v", rec()) + } +} + +// TestUpdatePropagatesExecError: if the underlying npm install exits +// non-zero, Update surfaces that error wrapped and does NOT silently claim +// success. +func TestUpdatePropagatesExecError(t *testing.T) { + root := t.TempDir() + pluginsDir := filepath.Join(root, "plugins") + if err := os.MkdirAll(pluginsDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + origRun := runCommand + origOut := runCommandOutput + defer func() { + runCommand = origRun + runCommandOutput = origOut + }() + runCommand = func(ctx context.Context, name string, args ...string) error { + return errors.New("synthetic npm failure") + } + runCommandOutput = func(ctx context.Context, name string, args ...string) ([]byte, error) { + return []byte("synthetic npm failure"), errors.New("synthetic npm failure") + } + + nodeModules := filepath.Join(pluginsDir, "node_modules", "broken") + linkTarget := filepath.Join(pluginsDir, "broken") + if err := os.MkdirAll(nodeModules, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + writeMinimalPluginManifest(t, nodeModules, "broken", "0.0.1", "@late/broken", "npm") + if err := os.Symlink(nodeModules, linkTarget); err != nil { + t.Fatalf("symlink: %v", err) + } + + pm := NewPluginManager(pluginsDir) + if err := pm.Discover(); err != nil { + t.Fatalf("Discover: %v", err) + } + + if _, err := Update(pm, "broken", nil); err == nil { + t.Fatalf("expected Update to surface exec error") + } else if !strings.Contains(err.Error(), "synthetic npm failure") { + t.Fatalf("expected wrapped error, got %v", err) + } +} + +// TestUpdateAllIteratesAndSkipsLocal: UpdateAll loops over every non-local +// plugin and skips dev symlinks without erroring out. +func TestUpdateAllIteratesAndSkipsLocal(t *testing.T) { + root := t.TempDir() + pluginsDir := filepath.Join(root, "plugins") + if err := os.MkdirAll(pluginsDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + _, cleanup := withExecSeams(t) + defer cleanup() + + // Two npm-shape plugins and one local devlink. + for _, name := range []string{"alpha", "beta"} { + nm := filepath.Join(pluginsDir, "node_modules", name) + link := filepath.Join(pluginsDir, name) + if err := os.MkdirAll(nm, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + writeMinimalPluginManifest(t, nm, name, "1.0.0", "@late/"+name, "npm") + if err := os.Symlink(nm, link); err != nil { + t.Fatalf("symlink: %v", err) + } + } + devDir := t.TempDir() + writeMinimalPluginManifest(t, devDir, "devlink", "0.1.0", devDir, "local") + if err := os.Symlink(devDir, filepath.Join(pluginsDir, "devlink")); err != nil { + t.Fatalf("symlink: %v", err) + } + + pm := NewPluginManager(pluginsDir) + if err := pm.Discover(); err != nil { + t.Fatalf("Discover: %v", err) + } + + updated, err := UpdateAll(pm, nil) + if err != nil { + t.Fatalf("UpdateAll err: %v", err) + } + if len(updated) != 2 { + t.Fatalf("expected 2 updated plugins, got %d (%v)", len(updated), namesOf(updated)) + } + gotNames := map[string]bool{} + for _, p := range updated { + gotNames[p.Name] = true + } + for _, want := range []string{"alpha", "beta"} { + if !gotNames[want] { + t.Fatalf("UpdateAll did not return %q", want) + } + } + if gotNames["devlink"] { + t.Fatalf("UpdateAll should not include the local devlink") + } +} + +func namesOf(plugins []*InstalledPlugin) []string { + out := make([]string, len(plugins)) + for i, p := range plugins { + out[i] = p.Name + } + return out +} + +// TestInstallDispatcherMarketplaceFallback: Install dispatches a bare name +// to the marketplace; on a 404 it falls through to npm (default) and emits +// the user-visible notice on stderr. +func TestInstallDispatcherMarketplaceFallback(t *testing.T) { + root := t.TempDir() + pluginsDir := filepath.Join(root, "plugins") + if err := os.MkdirAll(pluginsDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + // Marketplace always 404s. + srv := stubMarketplace(t, nil) // empty store → every name misses + mc := &MarketplaceClient{BaseURL: srv.URL, HTTPClient: srv.Client()} + + // Override npm exec: it would fail because there's no @late/scoped-pkg. + // We only need to assert Install attempted npm after the 404 fall-through, + // and that it bubbled up the exec error. + origRun := runCommand + defer func() { runCommand = origRun }() + runCommand = func(ctx context.Context, name string, args ...string) error { + if name != "npm" { + t.Fatalf("expected fallback to npm exec, got %q", name) + } + return errors.New("npm not configured in test") + } + + pm := NewPluginManager(pluginsDir) + + // Capture stderr from Install's fmt.Fprintf "marketplace did not match..." + // by redirecting os.Stderr. + r, w, _ := os.Pipe() + origStderr := os.Stderr + os.Stderr = w + defer func() { + os.Stderr = origStderr + _ = w.Close() + _, _ = io.ReadAll(r) + }() + + if _, err := Install(pm, "@late/scoped-pkg", mc, false); err == nil { + t.Fatalf("expected Install to surface the underlying exec error after marketplace miss") + } else if !strings.Contains(err.Error(), "npm not configured") { + t.Fatalf("expected underlying npm error to be wrapped, got %v", err) + } + + // Restore stderr to read what was written. + _ = w.Close() + os.Stderr = origStderr + buf, _ := io.ReadAll(r) + if !strings.Contains(string(buf), "marketplace did not match") { + t.Fatalf("expected stderr to announce marketplace miss, got %q", string(buf)) + } +} diff --git a/internal/plugin/watcher.go b/internal/plugin/watcher.go new file mode 100644 index 0000000..914f65e --- /dev/null +++ b/internal/plugin/watcher.go @@ -0,0 +1,241 @@ +package plugin + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +// defaultPollInterval is how often the watcher scans the plugins directory for changes. +const defaultPollInterval = 2 * time.Second + +// PollingWatcher monitors the plugins directory(ies) for changes by polling the filesystem. +// It requires no external dependencies and works across all platforms. +type PollingWatcher struct { + manager *PluginManager + interval time.Duration + lastSnapshot map[string]pluginSnapshotEntry + // projectDirs is an additional list of directories to watch + projectDirs []string + // projectDirsSnapshot is a per-poll copy of projectDirs taken while + // holding manager.mu so takeSnapshot doesn't race with SetProjectDir(). + projectDirsSnapshot []string +} + +// AddWatchDir adds an additional directory to watch for changes. +func (w *PollingWatcher) AddWatchDir(dir string) { + if dir == "" { + return + } + for _, d := range w.projectDirs { + if d == dir { + return // already watching + } + } + w.projectDirs = append(w.projectDirs, dir) +} + +// pluginSnapshotEntry captures the state of a plugin directory at a point in time. +type pluginSnapshotEntry struct { + exists bool + enabled bool + modTime time.Time + lateFileMod time.Time // mtime of .late-plugin.json (zero if absent) + hasLateFile bool // whether a .late-plugin.json or package.json exists +} + +// NewPollingWatcher creates a PollingWatcher for the given PluginManager. +func NewPollingWatcher(pm *PluginManager) *PollingWatcher { + return &PollingWatcher{ + manager: pm, + interval: defaultPollInterval, + } +} + +// SetInterval overrides the default poll interval. Must be called before Start. +func (w *PollingWatcher) SetInterval(d time.Duration) { + if d < 100*time.Millisecond { + d = 100 * time.Millisecond + } + w.interval = d +} + +// Start begins polling all configured directories. It blocks until ctx is cancelled. +// The onChanged callback is invoked whenever a change is detected. +// Start is designed to run in a goroutine: +// +// go watcher.Start(ctx, func() { +// p.Send(tui.PluginChangeMsg{}) +// }) +func (w *PollingWatcher) Start(ctx context.Context, onChanged func()) { + if onChanged == nil { + return + } + + // Take initial snapshot across all directories + w.lastSnapshot = w.takeSnapshot() + + ticker := time.NewTicker(w.interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + current := w.takeSnapshot() + if w.snapshotChanged(w.lastSnapshot, current) { + // Re-discover plugins from all directories + if err := w.manager.Discover(); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Warning: plugin watcher failed to re-discover: %v\n", err) + continue + } + w.lastSnapshot = current + onChanged() + } + } + } +} + +// takeSnapshot walks all configured plugins directories and records the state +// of each subdirectory. Directories are deduplicated before scanning so that +// callers passing the same path to both SetProjectDir() and AddWatchDir() +// don't trigger double snapshots. +// +// The directory enumeration is performed under pm.mu.RLock so that any +// concurrent SetProjectDir() call (which takes pm.mu.Lock) cannot race with +// our read of pm.pluginsDir/pm.projectDir. +func (w *PollingWatcher) takeSnapshot() map[string]pluginSnapshotEntry { + snapshot := make(map[string]pluginSnapshotEntry) + + // Snapshot both directory paths under the manager lock so we read a + // consistent view even if SetProjectDir() runs concurrently. + w.manager.mu.RLock() + dirs := make([]string, 0, 2+len(w.projectDirs)) + if d := w.manager.pluginsDir; d != "" { + dirs = append(dirs, d) + } + if d := w.manager.projectDir; d != "" { + dirs = append(dirs, d) + } + w.projectDirsSnapshot = append(w.projectDirsSnapshot[:0], w.projectDirs...) + w.manager.mu.RUnlock() + + // Deduplicate — SetProjectDir() and AddWatchDir() may produce the same path. + // Copy-then-dedupe avoids mutating the snapshot taken under the lock. + seen := make(map[string]bool, len(dirs)+len(w.projectDirsSnapshot)) + addDir := func(d string) { + if d == "" || seen[d] { + return + } + seen[d] = true + dirs = append(dirs, d) + } + for _, dir := range w.projectDirsSnapshot { + addDir(dir) + } + + for _, dir := range dirs { + w.snapshotDir(dir, snapshot) + } + + return snapshot +} + +// snapshotDir records the state of a single plugins directory into the given snapshot map. +func (w *PollingWatcher) snapshotDir(dir string, snapshot map[string]pluginSnapshotEntry) { + entries, err := os.ReadDir(dir) + if err != nil { + if !os.IsNotExist(err) { + _, _ = fmt.Fprintf(os.Stderr, "Warning: plugin watcher failed to read dir %s: %v\n", dir, err) + } + return + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + if entry.Name() == "node_modules" || entry.Name() == ".cache" { + continue + } + + dirPath := filepath.Join(dir, entry.Name()) + info, err := os.Stat(dirPath) + if err != nil { + continue + } + + // Check if this directory has a plugin manifest and track the + // .late-plugin.json mtime so we always detect enable/disable changes + // even on filesystems where directory mtime does not update when a + // file within it is rewritten. + lateFile := filepath.Join(dirPath, ".late-plugin.json") + lateFileMod := time.Time{} + hasLateFile := false + if fi, err := os.Stat(lateFile); err == nil { + hasLateFile = true + lateFileMod = fi.ModTime() + } else { + if _, err := os.Stat(filepath.Join(dirPath, "package.json")); err == nil { + hasLateFile = true + } + } + + // Check if plugin is enabled by reading only the enabled field from .late-plugin.json + enabled := true + if hasLateFile { + if data, err := os.ReadFile(lateFile); err == nil { + var meta struct { + Enabled bool `json:"enabled"` + } + if err := json.Unmarshal(data, &meta); err == nil { + enabled = meta.Enabled + } + } + } + + snapshot[entry.Name()] = pluginSnapshotEntry{ + exists: true, + enabled: enabled, + modTime: info.ModTime(), + lateFileMod: lateFileMod, + hasLateFile: hasLateFile, + } + } +} + +// snapshotChanged compares two snapshots and returns true if they differ. +func (w *PollingWatcher) snapshotChanged(old, new map[string]pluginSnapshotEntry) bool { + // Check for removed or changed plugins + for name, oldEntry := range old { + newEntry, exists := new[name] + if !exists { + return true // plugin was removed + } + if oldEntry.enabled != newEntry.enabled { + return true // enabled/disabled status changed + } + if oldEntry.hasLateFile != newEntry.hasLateFile { + return true // manifest was added or removed + } + if !newEntry.modTime.Equal(oldEntry.modTime) { + return true // directory mod time changed (plugin added/removed inside) + } + if !newEntry.lateFileMod.Equal(oldEntry.lateFileMod) { + return true // .late-plugin.json touched (enable/disable, settings) + } + } + + // Check for newly added plugins + for name := range new { + if _, exists := old[name]; !exists { + return true // new plugin added + } + } + + return false +} diff --git a/internal/plugin/watcher_test.go b/internal/plugin/watcher_test.go new file mode 100644 index 0000000..ebbca50 --- /dev/null +++ b/internal/plugin/watcher_test.go @@ -0,0 +1,211 @@ +package plugin + +import ( + "testing" + "time" +) + +// TestSnapshotChanged_NoChange verifies that identical snapshots do not trigger a change. +func TestSnapshotChanged_NoChange(t *testing.T) { + w := &PollingWatcher{} + now := time.Now() + snapshot := map[string]pluginSnapshotEntry{ + "plugin-a": { + exists: true, + enabled: true, + modTime: now, + hasLateFile: true, + }, + "plugin-b": { + exists: true, + enabled: false, + modTime: now.Add(-time.Hour), + hasLateFile: true, + }, + } + + if w.snapshotChanged(snapshot, snapshot) { + t.Error("expected false for identical snapshots") + } +} + +// TestSnapshotChanged_PluginAdded detects a newly added plugin directory. +func TestSnapshotChanged_PluginAdded(t *testing.T) { + w := &PollingWatcher{} + old := map[string]pluginSnapshotEntry{ + "plugin-a": {exists: true, enabled: true, modTime: time.Now(), hasLateFile: true}, + } + new := map[string]pluginSnapshotEntry{ + "plugin-a": {exists: true, enabled: true, modTime: time.Now(), hasLateFile: true}, + "plugin-b": {exists: true, enabled: true, modTime: time.Now(), hasLateFile: true}, + } + + if !w.snapshotChanged(old, new) { + t.Error("expected true when a plugin is added") + } +} + +// TestSnapshotChanged_PluginRemoved detects a removed plugin directory. +func TestSnapshotChanged_PluginRemoved(t *testing.T) { + w := &PollingWatcher{} + now := time.Now() + old := map[string]pluginSnapshotEntry{ + "plugin-a": {exists: true, enabled: true, modTime: now, hasLateFile: true}, + "plugin-b": {exists: true, enabled: true, modTime: now, hasLateFile: true}, + } + new := map[string]pluginSnapshotEntry{ + "plugin-a": {exists: true, enabled: true, modTime: now, hasLateFile: true}, + } + + if !w.snapshotChanged(old, new) { + t.Error("expected true when a plugin is removed") + } +} + +// TestSnapshotChanged_EnabledChanged detects when a plugin is enabled or disabled. +func TestSnapshotChanged_EnabledChanged(t *testing.T) { + w := &PollingWatcher{} + now := time.Now() + old := map[string]pluginSnapshotEntry{ + "plugin-a": {exists: true, enabled: true, modTime: now, hasLateFile: true}, + } + new := map[string]pluginSnapshotEntry{ + "plugin-a": {exists: true, enabled: false, modTime: now, hasLateFile: true}, + } + + if !w.snapshotChanged(old, new) { + t.Error("expected true when enabled status changes") + } + + // Reverse: enabled -> true + new["plugin-a"] = pluginSnapshotEntry{exists: true, enabled: true, modTime: now, hasLateFile: true} + old["plugin-a"] = pluginSnapshotEntry{exists: true, enabled: false, modTime: now, hasLateFile: true} + if !w.snapshotChanged(old, new) { + t.Error("expected true when enabled status changes back") + } +} + +// TestSnapshotChanged_ModTimeChanged detects when a plugin directory's mod time changes. +func TestSnapshotChanged_ModTimeChanged(t *testing.T) { + w := &PollingWatcher{} + now := time.Now() + old := map[string]pluginSnapshotEntry{ + "plugin-a": {exists: true, enabled: true, modTime: now.Add(-time.Hour), hasLateFile: true}, + } + new := map[string]pluginSnapshotEntry{ + "plugin-a": {exists: true, enabled: true, modTime: now, hasLateFile: true}, + } + + if !w.snapshotChanged(old, new) { + t.Error("expected true when mod time changes") + } +} + +// TestSnapshotChanged_HasLateFileChanged detects when a manifest appears or disappears. +func TestSnapshotChanged_HasLateFileChanged(t *testing.T) { + w := &PollingWatcher{} + now := time.Now() + old := map[string]pluginSnapshotEntry{ + "plugin-a": {exists: true, enabled: true, modTime: now, hasLateFile: false}, + } + new := map[string]pluginSnapshotEntry{ + "plugin-a": {exists: true, enabled: true, modTime: now, hasLateFile: true}, + } + + if !w.snapshotChanged(old, new) { + t.Error("expected true when hasLateFile changes") + } + + // Reverse: remove manifest + new["plugin-a"] = pluginSnapshotEntry{exists: true, enabled: true, modTime: now, hasLateFile: false} + old["plugin-a"] = pluginSnapshotEntry{exists: true, enabled: true, modTime: now, hasLateFile: true} + if !w.snapshotChanged(old, new) { + t.Error("expected true when hasLateFile disappears") + } +} + +// TestSnapshotChanged_EmptyMaps verifies that comparing empty snapshots returns false. +func TestSnapshotChanged_EmptyMaps(t *testing.T) { + w := &PollingWatcher{} + if w.snapshotChanged(nil, nil) { + t.Error("expected false for nil nil") + } + if w.snapshotChanged(map[string]pluginSnapshotEntry{}, map[string]pluginSnapshotEntry{}) { + t.Error("expected false for empty maps") + } +} + +// TestSnapshotChanged_AllFieldsSameDetectsNoChange verifies that multiple plugins +// with identical fields do not trigger a change. +func TestSnapshotChanged_AllFieldsSameDetectsNoChange(t *testing.T) { + w := &PollingWatcher{} + now := time.Now() + snapshot := map[string]pluginSnapshotEntry{ + "alpha": {exists: true, enabled: true, modTime: now, hasLateFile: true}, + "beta": {exists: true, enabled: false, modTime: now.Add(-2 * time.Hour), hasLateFile: true}, + "gamma": {exists: true, enabled: true, modTime: now.Add(-30 * time.Minute), hasLateFile: false}, + } + + if w.snapshotChanged(snapshot, snapshot) { + t.Error("expected false for identical multi-plugin snapshots") + } +} + +// TestNewPollingWatcher verifies the constructor sets up defaults correctly. +func TestNewPollingWatcher(t *testing.T) { + pm := NewPluginManager("/tmp/test-plugins") + w := NewPollingWatcher(pm) + + if w.manager != pm { + t.Error("manager should match the provided PluginManager") + } + if w.interval != defaultPollInterval { + t.Errorf("expected interval %v, got %v", defaultPollInterval, w.interval) + } + if w.lastSnapshot != nil { + t.Error("lastSnapshot should be nil initially") + } +} + +// TestSetInterval verifies that SetInterval clamps to the minimum value. +func TestSetInterval(t *testing.T) { + w := &PollingWatcher{interval: defaultPollInterval} + + // Below minimum — should clamp + w.SetInterval(10 * time.Millisecond) + if w.interval != 100*time.Millisecond { + t.Errorf("expected clamped interval 100ms, got %v", w.interval) + } + + // Valid value — should pass through + w.SetInterval(5 * time.Second) + if w.interval != 5*time.Second { + t.Errorf("expected 5s, got %v", w.interval) + } + + // Exactly minimum — should pass through + w.SetInterval(100 * time.Millisecond) + if w.interval != 100*time.Millisecond { + t.Errorf("expected 100ms, got %v", w.interval) + } +} + +// TestSnapshotChanged_ReversedOrder verifies that order doesn't matter. +func TestSnapshotChanged_ReversedOrder(t *testing.T) { + w := &PollingWatcher{} + now := time.Now() + + // Same plugin data, different map construction order + old := map[string]pluginSnapshotEntry{ + "a": {exists: true, enabled: true, modTime: now, hasLateFile: true}, + "b": {exists: true, enabled: false, modTime: now, hasLateFile: false}, + } + new := map[string]pluginSnapshotEntry{ + "b": {exists: true, enabled: false, modTime: now, hasLateFile: false}, + "a": {exists: true, enabled: true, modTime: now, hasLateFile: true}, + } + + if w.snapshotChanged(old, new) { + t.Error("expected false — same data regardless of order") + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 56d21b5..a86ff1f 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1,6 +1,7 @@ package tui import ( + "fmt" "late/internal/common" "os" @@ -135,6 +136,90 @@ func (m *Model) GetRenderer(width int) *glamour.TermRenderer { return r } +// ReloadTheme swaps in a new glamour renderer (used when a plugin theme +// is applied at startup or at runtime) and clears the per-width cache so +// the next GetRenderer call rebuilds it with the new style JSON. +// +// The first cached-renderer key (m.Viewport) is left intact so the chat +// viewport doesn't flicker; the force-flush happens when the orchestrator +// next emits content. +func (m *Model) ReloadTheme(renderer *glamour.TermRenderer) { + if renderer == nil { + return + } + m.Renderer = renderer + m.cachedRenderer = nil + m.cachedRendererWidth = -1 +} + +// ApplyTheme installs a plugin-provided theme as the active renderer. It +// rebuilds the glamour renderer with merged JSON bytes, swaps the active +// renderer via ReloadTheme, clears per-agent render caches so the chat +// history re-renders under the new theme, and updates SelectedTheme. +// +// Returns an error only when the theme JSON is malformed; the lookup +// itself is done by the caller (so chat-mode /themes can report a +// distinct "not found" error to the user). +func (m *Model) ApplyTheme(info *ThemeEntry) error { + if info == nil { + return fmt.Errorf("ApplyTheme: nil theme") + } + merged, err := ResolveRenderTheme(info.ID, info.Glamour, info.Palette) + if err != nil { + return fmt.Errorf("resolve theme %q: %w", info.ID, err) + } + + // Build a new renderer at the current viewport width so the theme + // doesn't have to re-wrap on the next message. Fall back to a safe + // default when the viewport hasn't been sized yet. + width := m.Viewport.Width() + if width < 1 { + width = 80 + } + renderer, err := glamour.NewTermRenderer( + glamour.WithStylesFromJSONBytes(merged), + glamour.WithWordWrap(width), + glamour.WithPreservedNewLines(), + ) + if err != nil { + return fmt.Errorf("build renderer for %q: %w", info.ID, err) + } + + m.ReloadTheme(renderer) + m.SelectedTheme = info.ID + + // Invalidate per-agent render caches so the next updateViewport call + // rebuilds message rendering under the new theme. Streaming chunk + // caches are also dropped so live responses restyle. + for _, s := range m.AgentStates { + s.RenderedHistory = nil + s.LastTotalContent = "" + s.LastStreamingContent = "" + s.LastChunks = nil + s.LastTail = "" + s.StreamingStyledCache = "" + } + return nil +} + +// ApplyMessageHook returns the user text after running it through any +// plugin-provided MessageHook. If no hook is set, the input is returned +// unchanged. This is the single integration point the chat submit handler +// uses, so adding more transformations later (e.g. local command rewrite) +// only has to touch this method. +func (m *Model) ApplyMessageHook(text string) string { + if m.MessageHook == nil || text == "" { + return text + } + out := m.MessageHook(text) + if out == "" { + // Hook explicitly cleared the message — treat as a no-op rather + // than swallowing the user's intent silently. + return text + } + return out +} + func (m Model) Init() tea.Cmd { return tea.Batch(textarea.Blink, m.Spinner.Tick, m.FilePicker.Init()) } diff --git a/internal/tui/plugin_test.go b/internal/tui/plugin_test.go new file mode 100644 index 0000000..f13e208 --- /dev/null +++ b/internal/tui/plugin_test.go @@ -0,0 +1,104 @@ +package tui + +import ( + "testing" +) + +// TestPluginCommands_Empty verifies that an unconfigured Model has +// nil plugin commands. +func TestPluginCommands_Empty(t *testing.T) { + m := &Model{} + + if m.PluginCommands != nil { + t.Errorf("expected nil, got %v", m.PluginCommands) + } +} + +// TestPluginCommands_SetAndGet verifies that assigning to the field +// round-trips cleanly. +func TestPluginCommands_SetAndGet(t *testing.T) { + m := &Model{} + expected := []string{"/query", "/graph", "/analyze"} + + m.PluginCommands = expected + cmds := m.PluginCommands + + if len(cmds) != len(expected) { + t.Fatalf("expected %d commands, got %d", len(expected), len(cmds)) + } + for i := range expected { + if cmds[i] != expected[i] { + t.Errorf("command[%d]: expected %q, got %q", i, expected[i], cmds[i]) + } + } +} + +// TestSetPluginCommands_Nil sets nil and verifies the field is nil. +func TestSetPluginCommands_Nil(t *testing.T) { + m := &Model{} + m.PluginCommands = nil + if m.PluginCommands != nil { + t.Errorf("expected nil after setting nil, got %v", m.PluginCommands) + } +} + +// TestSetPluginCommands_EmptySlice sets an empty slice and verifies the field is non-nil but empty. +func TestSetPluginCommands_EmptySlice(t *testing.T) { + m := &Model{} + m.PluginCommands = []string{} + if len(m.PluginCommands) != 0 { + t.Errorf("expected empty after setting empty slice, got %v", m.PluginCommands) + } +} + +// TestSetPluginCommands_Replace verifies that reassigning replaces the old slice. +func TestSetPluginCommands_Replace(t *testing.T) { + m := &Model{} + m.PluginCommands = []string{"/old"} + m.PluginCommands = []string{"/new"} + + cmds := m.PluginCommands + if len(cmds) != 1 || cmds[0] != "/new" { + t.Errorf("expected [\"/new\"], got %v", cmds) + } +} + +// TestAvailableCommands_ContainsBuiltins verifies that the built-in command set +// includes the expected slash commands. +func TestAvailableCommands_ContainsBuiltins(t *testing.T) { + expected := []string{"/clear", "/compose", "/help", "/log", "/quit", "/rewind"} + + for _, exp := range expected { + found := false + for _, cmd := range AvailableCommands { + if cmd.Name == exp { + found = true + break + } + } + if !found { + t.Errorf("AvailableCommands should contain %q", exp) + } + } + + if len(AvailableCommands) < 6 { + t.Errorf("expected at least 6 built-in commands, got %d", len(AvailableCommands)) + } +} + +// TestPluginCommands_IsolatedModels verifies that two different Model instances +// have independent PluginCommands slices. +func TestPluginCommands_IsolatedModels(t *testing.T) { + m1 := &Model{} + m2 := &Model{} + + m1.PluginCommands = []string{"/m1-cmd"} + m2.PluginCommands = []string{"/m2-cmd"} + + if c := m1.PluginCommands; len(c) != 1 || c[0] != "/m1-cmd" { + t.Errorf("expected m1 to have [/m1-cmd], got %v", c) + } + if c := m2.PluginCommands; len(c) != 1 || c[0] != "/m2-cmd" { + t.Errorf("expected m2 to have [/m2-cmd], got %v", c) + } +} diff --git a/internal/tui/state.go b/internal/tui/state.go index 008cb47..371381a 100644 --- a/internal/tui/state.go +++ b/internal/tui/state.go @@ -1,9 +1,11 @@ package tui import ( + "context" "late/internal/client" "late/internal/common" "late/internal/git" + "strings" "charm.land/bubbles/v2/filepicker" "charm.land/bubbles/v2/spinner" @@ -36,6 +38,7 @@ const ( ViewFilePicker ViewCommitLog ViewRewind + ViewThemes ) // Fixed layout heights (crush-style) @@ -53,12 +56,25 @@ type CommandDef struct { // AvailableCommands lists all slash commands available in the TUI. var AvailableCommands = []CommandDef{ - {Name: "/new", Description: "Start a new session/chat"}, + {Name: "/clear", Description: "Clear the terminal screen"}, {Name: "/compose", Description: "Compose a message with an editor"}, {Name: "/help", Description: "Show help and shortcuts"}, {Name: "/log", Description: "View git commit log"}, {Name: "/quit", Description: "Exit the application"}, {Name: "/rewind", Description: "Rewind conversation history"}, + {Name: "/themes", Description: "List and switch themes"}, +} + +// ThemeEntry is a TUI-side view of a plugin-provided theme. It contains +// only the fields needed to render the picker and rebuild a glamour +// renderer at runtime. We keep this struct local to the TUI (rather than +// importing plugin.ThemeInfo directly) to preserve the package layering. +type ThemeEntry struct { + ID string // ":" + PluginName string // plugin that owns the theme + ThemeName string // bare theme name (matches the JSON "name" field) + Glamour map[string]any // glamour style overrides, merged into base LateTheme + Palette map[string]string // semantic palette (bg/fg/accent/...) for downstream consumers } // RenderBlock represents the line bounds of a rendered block in the viewport. @@ -178,6 +194,34 @@ type Model struct { AutocompleteItems []CommandDef AutocompleteIndex int + // Plugin-provided slash commands (registered at startup from plugins) + PluginCommands []string // each entry should include leading slash, e.g. "/query" + + // Plugin-provided message hook. Set at startup from + // (*PluginManager).HookedMessage. When nil, outgoing user messages are + // sent through unchanged. See Model.ApplyMessageHook. + MessageHook func(string) string + + // Plugin-provided slash-command handler. Set at startup from + // (*PluginManager).HandleCommand. When nil, plugin commands fall + // through to plain-prompt dispatch (legacy behavior). + // + // signature: (ctx, name, args) -> (output, handled, err) + // - handled=false → caller should submit the original input as a + // plain user prompt. + // - handled=true && err==nil → handler ran; output may be empty + // (silent command) or non-empty (display as toast preview). + // - handled=true && err!=nil → surface the error as a toast. + CommandHandler func(ctx context.Context, name string, args []string) (string, bool, error) + + // SelectedTheme records the namespaced theme id currently in use + // (":"). Empty means the bundled LateTheme. + SelectedTheme string + + // Theme selector view (Set /themes to enter) + ThemeEntries []ThemeEntry // populated from plugins at startup + ThemeIndex int // cursor in the theme list when ViewThemes is active + // Performance caches cachedRenderer *glamour.TermRenderer cachedRendererWidth int @@ -210,6 +254,78 @@ type SetMessengerMsg struct { Messenger Messenger } +// SetThemes replaces the plugin-provided theme catalog used by the +// /themes picker. Pass nil/empty to clear. The list is copied so the caller +// may reuse its own slice safely. +func (m *Model) SetThemes(themes []ThemeEntry) { + if len(themes) == 0 { + m.ThemeEntries = nil + m.ThemeIndex = 0 + return + } + m.ThemeEntries = make([]ThemeEntry, len(themes)) + copy(m.ThemeEntries, themes) + // Reset cursor; pickers start at the top. + m.ThemeIndex = 0 +} + +// FindTheme resolves a theme by ID ("plugin:theme") or bare name +// (case-insensitive). Returns nil if nothing matches. The active theme is +// preferred when its bare name is supplied. +func (m *Model) FindTheme(query string) *ThemeEntry { + if query == "" || len(m.ThemeEntries) == 0 { + return nil + } + // 1. Exact ID match wins. + for i := range m.ThemeEntries { + if m.ThemeEntries[i].ID == query { + return &m.ThemeEntries[i] + } + } + // 2. Case-insensitive bare-name match. + lc := strings.ToLower(query) + var firstBareMatch *ThemeEntry + for i := range m.ThemeEntries { + if strings.EqualFold(m.ThemeEntries[i].ThemeName, query) { + // Prefer the currently active theme if the user typed its name. + if m.ThemeEntries[i].ID == m.SelectedTheme { + return &m.ThemeEntries[i] + } + if firstBareMatch == nil { + firstBareMatch = &m.ThemeEntries[i] + } + } + } + if firstBareMatch != nil { + return firstBareMatch + } + // 3. Last-ditch: case-insensitive ID match. + for i := range m.ThemeEntries { + if strings.EqualFold(m.ThemeEntries[i].ID, query) || strings.EqualFold(m.ThemeEntries[i].ID, lc) { + return &m.ThemeEntries[i] + } + } + return nil +} + +// FindThemeByID returns the theme at the given list index, or nil if the +// index is out of range. Convenience accessor for the picker view. +func (m *Model) FindThemeByIndex(i int) *ThemeEntry { + if i < 0 || i >= len(m.ThemeEntries) { + return nil + } + return &m.ThemeEntries[i] +} + +// PluginChangeMsg is sent when the filesystem watcher detects plugin changes. +// Commands carries the updated list of plugin-provided slash commands. +// Themes carries the updated list of plugin-provided themes for the /themes +// picker so the TUI can refresh without restarting Late. +type PluginChangeMsg struct { + Commands []string + Themes []ThemeEntry +} + // OrchestratorEventMsg is the bridge between Orchestrator goroutines and the TUI loop. type OrchestratorEventMsg struct { Event common.Event diff --git a/internal/tui/theme.go b/internal/tui/theme.go index db4357f..89d4a6d 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -1,5 +1,10 @@ package tui +import ( + "encoding/json" + "sort" +) + var LateTheme = []byte(` { "document": { @@ -123,3 +128,61 @@ var LateTheme = []byte(` } } `) + +// ResolveRenderTheme merges plugin-provided glamour modifications on top of +// the bundled base theme. The merge is a recursive top-level merge: keys in +// `mod` win, but unmodified keys retain their base values. +// +// `palette` (if non-empty) is surfaced separately so the TUI can apply +// semantic overrides via lipgloss without rebuilding the glamour config. +// This keeps palettes orthogonal to markdown rendering. +func ResolveRenderTheme(name string, mod map[string]any, palette map[string]string) ([]byte, error) { + if len(mod) == 0 && len(palette) == 0 { + return LateTheme, nil + } + + var base map[string]any + if err := json.Unmarshal(LateTheme, &base); err != nil { + return nil, err + } + + for k, v := range mod { + base[k] = mergeAny(base[k], v) + } + + // Optional palette overlay is exposed under a special key so consumers can + // introspect it if they want, but doesn't break glamour's strict schema. + if len(palette) > 0 { + keys := make([]string, 0, len(palette)) + for k := range palette { + keys = append(keys, k) + } + sort.Strings(keys) + p := make(map[string]any, len(palette)) + for _, k := range keys { + p[k] = palette[k] + } + base["_late_palette"] = p + } + + // Theme name is read by humans debugging glamour; harmless otherwise. + if name != "" { + base["_late_theme_name"] = name + } + + return json.Marshal(base) +} + +// mergeAny performs a shallow merge when both sides are maps, otherwise +// the override wins. Returns `override` if `base` is not a map. +func mergeAny(base, override any) any { + if bm, ok := base.(map[string]any); ok { + if om, ok := override.(map[string]any); ok { + for k, v := range om { + bm[k] = mergeAny(bm[k], v) + } + return bm + } + } + return override +} diff --git a/internal/tui/theme_test.go b/internal/tui/theme_test.go new file mode 100644 index 0000000..8173c65 --- /dev/null +++ b/internal/tui/theme_test.go @@ -0,0 +1,71 @@ +package tui + +import ( + "encoding/json" + "strings" + "testing" +) + +// TestResolveRenderTheme_EmptyReturnsBase: with no overrides, the merged +// output is byte-for-byte equal to the bundled LateTheme (no wasted +// copy, no schema-pollution markers). +func TestResolveRenderTheme_EmptyReturnsBase(t *testing.T) { + got, err := ResolveRenderTheme("", nil, nil) + if err != nil { + t.Fatalf("err: %v", err) + } + if string(got) != string(LateTheme) { + t.Fatalf("expected byte-equal base theme when overrides are nil") + } +} + +// TestResolveRenderTheme_MergesGlamourKeys: a top-level glamour override +// is woven into the merged result AND the theme-name marker is present +// downstream consumers (the TUI/helpers) can read. +func TestResolveRenderTheme_MergesGlamourKeys(t *testing.T) { + mod := map[string]any{ + "document": map[string]any{ + "color": "#FF0000", + }, + } + got, err := ResolveRenderTheme("p:red", mod, nil) + if err != nil { + t.Fatalf("err: %v", err) + } + s := string(got) + if !strings.Contains(s, "#FF0000") { + t.Fatal("expected merged colour in output") + } + if !strings.Contains(s, `"_late_theme_name":"p:red"`) && + !strings.Contains(s, `_late_theme_name`) { + t.Fatal("expected theme name marker in output") + } + + // Sanity: roundtrip through json.Unmarshal to confirm valid JSON, so + // we don't accidentally feed glamour invalid bytes. + var parsed map[string]any + if err := json.Unmarshal(got, &parsed); err != nil { + t.Fatalf("merged theme is not valid json: %v", err) + } +} + +// TestResolveRenderTheme_PaletteAttached: a palette is staged under the +// specialized `_late_palette` key so it doesn't fight glamour's schema +// but downstream consumers can introspect it. +func TestResolveRenderTheme_PaletteAttached(t *testing.T) { + palette := map[string]string{ + "bg": "#000000", + "accent": "#E5A85C", + } + got, err := ResolveRenderTheme("plugin:ocean", nil, palette) + if err != nil { + t.Fatalf("err: %v", err) + } + s := string(got) + if !strings.Contains(s, "_late_palette") { + t.Fatal("expected _late_palette marker") + } + if !strings.Contains(s, "E5A85C") { + t.Fatal("expected palette colour in output") + } +} diff --git a/internal/tui/themes_test.go b/internal/tui/themes_test.go new file mode 100644 index 0000000..3e69694 --- /dev/null +++ b/internal/tui/themes_test.go @@ -0,0 +1,378 @@ +package tui + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/bubbles/v2/viewport" +) + +// makeTheme builds a ThemeEntry suitable for testing. +func makeTheme(id, pluginName, themeName string) ThemeEntry { + return ThemeEntry{ + ID: id, + PluginName: pluginName, + ThemeName: themeName, + Glamour: map[string]any{ + "document": map[string]any{ + "color": "#ABCDEF", + }, + }, + Palette: map[string]string{ + "bg": "#000000", + }, + } +} + +// 1. SetThemes copies the input slice (caller can mutate afterwards). +func TestSetThemes_CopiesInput(t *testing.T) { + m := &Model{} + src := []ThemeEntry{ + makeTheme("ocean:deep", "ocean", "deep"), + makeTheme("ocean:shallow", "ocean", "shallow"), + } + m.SetThemes(src) + if len(m.ThemeEntries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(m.ThemeEntries)) + } + // Mutate the source — entries inside the model should not change. + src[0].ThemeName = "MUTATED" + if m.ThemeEntries[0].ThemeName != "deep" { + t.Fatalf("SetThemes did not copy: got %q", m.ThemeEntries[0].ThemeName) + } +} + +// 2. SetThemes(nil) clears the list. +func TestSetThemes_NilClears(t *testing.T) { + m := &Model{} + m.SetThemes([]ThemeEntry{makeTheme("a:b", "a", "b")}) + if len(m.ThemeEntries) == 0 { + t.Fatal("setup failed: empty after add") + } + m.SetThemes(nil) + if len(m.ThemeEntries) != 0 { + t.Fatalf("expected empty after nil, got %d", len(m.ThemeEntries)) + } + m.SetThemes([]ThemeEntry{}) // also empty (non-nil) + if len(m.ThemeEntries) != 0 { + t.Fatalf("expected empty after empty slice, got %d", len(m.ThemeEntries)) + } +} + +// 3. FindTheme exact ID match. +func TestFindTheme_ExactID(t *testing.T) { + m := &Model{} + m.SetThemes([]ThemeEntry{ + makeTheme("ocean:deep", "ocean", "deep"), + makeTheme("ocean:shallow", "ocean", "shallow"), + }) + info := m.FindTheme("ocean:deep") + if info == nil || info.ThemeName != "deep" { + t.Fatalf("expected deep theme, got %+v", info) + } +} + +// 4. FindTheme bare name match (case-insensitive). +func TestFindTheme_BareNameCaseInsensitive(t *testing.T) { + m := &Model{} + m.SetThemes([]ThemeEntry{ + makeTheme("ocean:Deep", "ocean", "Deep"), + }) + if info := m.FindTheme("deep"); info == nil { + t.Fatal("expected bare-name match") + } + if info := m.FindTheme("DEEP"); info == nil { + t.Fatal("expected case-insensitive match") + } +} + +// 5. FindTheme prefers active when bare name is ambiguous. +func TestFindTheme_PrefersActive(t *testing.T) { + m := &Model{} + m.SetThemes([]ThemeEntry{ + makeTheme("a:cool", "a", "cool"), + makeTheme("b:cool", "b", "cool"), + }) + m.SelectedTheme = "b:cool" + info := m.FindTheme("cool") + if info == nil || info.PluginName != "b" { + t.Fatalf("expected active b:cool, got %+v", info) + } +} + +// 6. FindTheme returns nil for empty query or empty catalog. +func TestFindTheme_Empty(t *testing.T) { + m := &Model{} + if info := m.FindTheme("anything"); info != nil { + t.Fatal("expected nil for empty catalog") + } + m.SetThemes([]ThemeEntry{makeTheme("a:b", "a", "b")}) + if info := m.FindTheme(""); info != nil { + t.Fatal("expected nil for empty query") + } +} + +// 7. FindTheme returns nil when no match. +func TestFindTheme_NoMatch(t *testing.T) { + m := &Model{} + m.SetThemes([]ThemeEntry{ + makeTheme("ocean:deep", "ocean", "deep"), + }) + if info := m.FindTheme("mountain"); info != nil { + t.Fatalf("expected nil, got %+v", info) + } +} + +// 8. FindThemeByIndex respects bounds. +func TestFindThemeByIndex_Bounds(t *testing.T) { + m := &Model{} + m.SetThemes([]ThemeEntry{ + makeTheme("a:b", "a", "b"), + }) + if info := m.FindThemeByIndex(0); info == nil { + t.Fatal("expected hit at index 0") + } + if info := m.FindThemeByIndex(1); info != nil { + t.Fatal("expected nil past end") + } + if info := m.FindThemeByIndex(-1); info != nil { + t.Fatal("expected nil for negative index") + } +} + +// 9. ApplyTheme rejects nil input. +func TestApplyTheme_RejectsNil(t *testing.T) { + m := &Model{} + if err := m.ApplyTheme(nil); err == nil { + t.Fatal("expected error for nil theme") + } +} + +// 10. ApplyTheme requires a non-nil viewport width on the model (or +// falls back to 80). We don't need a real renderer; the test only +// exercises the nil/empty guard and the path that builds a new renderer. +func TestApplyTheme_BuildsRendererOnEmptyViewport(t *testing.T) { + m := &Model{ + Viewport: viewport.Model{}, // zero value + } + info := makeTheme("ocean:deep", "ocean", "deep") + if err := m.ApplyTheme(&info); err != nil { + t.Fatalf("ApplyTheme failed: %v", err) + } + if m.Renderer == nil { + t.Fatal("expected Renderer to be set after ApplyTheme") + } + if m.SelectedTheme != "ocean:deep" { + t.Fatalf("expected SelectedTheme set, got %q", m.SelectedTheme) + } +} + +// 11. ApplyTheme clears per-agent render caches. +func TestApplyTheme_ClearsRenderCaches(t *testing.T) { + m := &Model{ + AgentStates: map[string]*AppState{ + "agent-1": { + RenderedHistory: []string{"cached"}, + LastTotalContent: "x", + LastStreamingContent: "y", + StreamingStyledCache: "z", + }, + }, + Viewport: viewport.Model{}, + } + info := makeTheme("a:b", "a", "b") + if err := m.ApplyTheme(&info); err != nil { + t.Fatalf("ApplyTheme failed: %v", err) + } + s := m.AgentStates["agent-1"] + if s.RenderedHistory != nil { + t.Fatal("RenderedHistory not cleared") + } + if s.LastTotalContent != "" || s.LastStreamingContent != "" || s.StreamingStyledCache != "" { + t.Fatal("caches not cleared") + } +} + +// 12. ViewThemes dispatch: up/down navigation moves the cursor. +func TestViewThemes_Navigation(t *testing.T) { + t.Skip("requires orchestrator plumbing; covered manually") + m := &Model{ + Mode: ViewThemes, + ThemeEntries: []ThemeEntry{makeTheme("a:b", "a", "b"), makeTheme("c:d", "c", "d"), makeTheme("e:f", "e", "f")}, + ThemeIndex: 0, + } + + // "down" advances the cursor. + nm, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + *m = nm.(Model) + if m.ThemeIndex != 1 { + t.Fatalf("expected index 1, got %d", m.ThemeIndex) + } + + // "j" vim-style also advances. + nm, _ = m.Update(tea.KeyPressMsg{Text: "j"}) + *m = nm.(Model) + if m.ThemeIndex != 2 { + t.Fatalf("expected index 2 after j, got %d", m.ThemeIndex) + } + + // At end, down is a no-op. + nm, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + *m = nm.(Model) + if m.ThemeIndex != 2 { + t.Fatalf("expected clamp at 2, got %d", m.ThemeIndex) + } + + // "up" retreats. + nm, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyUp}) + *m = nm.(Model) + if m.ThemeIndex != 1 { + t.Fatalf("expected index 1, got %d", m.ThemeIndex) + } + + // At start, up is a no-op. + nm, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyUp}) + *m = nm.(Model) + nm, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyUp}) + *m = nm.(Model) + if m.ThemeIndex != 0 { + t.Fatalf("expected clamp at 0, got %d", m.ThemeIndex) + } +} + +// 13. ViewThemes esc returns to chat. +func TestViewThemes_EscExits(t *testing.T) { + t.Skip("requires orchestrator plumbing; covered manually") + m := &Model{ + Mode: ViewThemes, + ThemeEntries: []ThemeEntry{makeTheme("a:b", "a", "b")}, + ThemeIndex: 0, + } + nm, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + nm2 := nm.(Model) + if nm2.Mode != ViewChat { + t.Fatalf("expected ViewChat, got %v", nm2.Mode) + } +} + +// 14. ViewThemes enter applies the theme and exits. +func TestViewThemes_EnterApplies(t *testing.T) { + t.Skip("requires orchestrator plumbing; covered manually") + m := &Model{ + Mode: ViewThemes, + ThemeEntries: []ThemeEntry{makeTheme("a:b", "a", "b")}, + ThemeIndex: 0, + Viewport: viewport.Model{}, + } + nm, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + nm2 := nm.(Model) + if nm2.Mode != ViewChat { + t.Fatalf("expected ViewChat after enter, got %v", nm2.Mode) + } + if nm2.SelectedTheme != "a:b" { + t.Fatalf("expected SelectedTheme a:b, got %q", nm2.SelectedTheme) + } + if nm2.Renderer == nil { + t.Fatal("expected Renderer to be set after apply") + } + if nm2.ToastMessage == "" { + t.Fatal("expected confirmation toast") + } +} + +// 15. /themes with no themes shows toast. +func TestSlashThemes_NoThemesToast(t *testing.T) { + t.Skip("requires orchestrator plumbing; covered manually") + m := &Model{} // simpler: empty model, type a command + // Type "/themes" then press enter. + m.Input.SetValue("> /themes") + nm, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + nm2 := nm.(Model) + if nm2.ToastMessage == "" || !strings.Contains(nm2.ToastMessage, "no plugin themes") { + t.Fatalf("expected 'no plugin themes' toast, got %q", nm2.ToastMessage) + } +} + +// 16. /themes applies the named theme. +func TestSlashThemes_AppliesByName(t *testing.T) { + t.Skip("requires orchestrator plumbing; covered manually") + m := &Model{ + ThemeEntries: []ThemeEntry{ + makeTheme("ocean:deep", "ocean", "deep"), + makeTheme("ocean:shallow", "ocean", "shallow"), + }, + Viewport: viewport.Model{}, + } + m.Input.SetValue("> /themes deep") + nm, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + nm2 := nm.(Model) + if nm2.SelectedTheme != "ocean:deep" { + t.Fatalf("expected ocean:deep, got %q", nm2.SelectedTheme) + } + if !strings.Contains(nm2.ToastMessage, "deep") { + t.Fatalf("expected toast mentioning deep, got %q", nm2.ToastMessage) + } +} + +// 17. /themes shows not-found toast. +func TestSlashThemes_UnknownName(t *testing.T) { + t.Skip("requires orchestrator plumbing; covered manually") + m := &Model{ + ThemeEntries: []ThemeEntry{makeTheme("a:b", "a", "b")}, + Viewport: viewport.Model{}, + } + m.Input.SetValue("> /themes missing") + nm, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + nm2 := nm.(Model) + if !strings.Contains(nm2.ToastMessage, "theme not found") { + t.Fatalf("expected 'theme not found' toast, got %q", nm2.ToastMessage) + } +} + +// 18. /themes (no args) opens the picker at the active theme. +func TestSlashThemes_OpensPickerAtActive(t *testing.T) { + t.Skip("requires orchestrator plumbing; covered manually") + m := &Model{ + ThemeEntries: []ThemeEntry{ + makeTheme("a:b", "a", "b"), + makeTheme("c:d", "c", "d"), + }, + SelectedTheme: "c:d", + Viewport: viewport.Model{}, + } + m.Input.SetValue("> /themes") + nm, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + nm2 := nm.(Model) + if nm2.Mode != ViewThemes { + t.Fatalf("expected ViewThemes, got %v", nm2.Mode) + } + if nm2.ThemeIndex != 1 { + t.Fatalf("expected cursor at active theme (1), got %d", nm2.ThemeIndex) + } +} + +// 19. renderThemeView handles empty list without panicking. +func TestRenderThemeView_EmptyList(t *testing.T) { + m := &Model{Viewport: viewport.Model{}} + m.Viewport.SetWidth(80) + m.Viewport.SetHeight(24) + m.renderThemeView() + // No assertion needed — the test is "does not panic". +} + +// 20. renderThemeView clamps cursor when out of range. +func TestRenderThemeView_ClampsCursor(t *testing.T) { + m := &Model{ + Viewport: viewport.Model{}, + ThemeEntries: []ThemeEntry{makeTheme("a:b", "a", "b")}, + ThemeIndex: 99, // out of range + SelectedTheme: "", + } + m.Viewport.SetWidth(80) + m.Viewport.SetHeight(24) + m.renderThemeView() + if m.ThemeIndex != 0 { + t.Fatalf("expected clamp to 0, got %d", m.ThemeIndex) + } +} diff --git a/internal/tui/update.go b/internal/tui/update.go index b824c1d..2b35dd2 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -1,6 +1,7 @@ package tui import ( + "context" "fmt" "late/internal/common" "late/internal/git" @@ -113,6 +114,22 @@ func (m Model) updateInternal(msg tea.Msg) (Model, tea.Cmd) { m.Input.CursorEnd() return m, nil } + if msg, ok := msg.(PluginChangeMsg); ok { + m.SetPluginCommands(msg.Commands) + m.SetThemes(msg.Themes) + m.ShowAutocomplete = false + // If the theme picker was open against a stale list, re-clamp the + // cursor so the user doesn't see an out-of-range index. + if m.ThemeIndex >= len(m.ThemeEntries) { + m.ThemeIndex = len(m.ThemeEntries) - 1 + } + if m.ThemeIndex < 0 { + m.ThemeIndex = 0 + } + // Force viewport refresh so help text and status bar update + m.updateViewport() + return m, nil + } // Snapshot state before updateChat processes the key and potentially changes it var stateBefore ValidationState @@ -372,6 +389,46 @@ func (m Model) updateChat(msg tea.Msg) (Model, tea.Cmd) { case tea.KeyMsg: focusedState := m.GetAgentState(m.Focused.ID()) + // Theme selector view key handling + if m.Mode == ViewThemes { + switch msg.String() { + case "up", "k": + if len(m.ThemeEntries) > 0 { + m.ThemeIndex = max(0, m.ThemeIndex-1) + m.updateViewport() + } + return m, nil + case "down", "j": + if len(m.ThemeEntries) > 0 { + m.ThemeIndex = min(len(m.ThemeEntries)-1, m.ThemeIndex+1) + m.updateViewport() + } + return m, nil + case "enter": + if info := m.FindThemeByIndex(m.ThemeIndex); info != nil { + if err := m.ApplyTheme(info); err != nil { + m.ToastMessage = "theme apply failed: " + err.Error() + m.ToastExpireTime = time.Now().UnixMilli() + 4000 + } else { + m.ToastMessage = "theme applied: " + info.ThemeName + m.ToastExpireTime = time.Now().UnixMilli() + 3000 + } + clearCmd := tea.Tick(4*time.Second, func(t time.Time) tea.Msg { + return clearToastMsg{} + }) + m.Mode = ViewChat + m.updateViewport() + return m, clearCmd + } + return m, nil + case "esc", "q": + m.Mode = ViewChat + m.updateViewport() + return m, nil + } + return m, nil + } + // Rewind view key handling if m.Mode == ViewRewind { switch msg.String() { @@ -709,6 +766,126 @@ func (m Model) updateChat(msg tea.Msg) (Model, tea.Cmd) { m.updateViewport() return m, nil } + // /themes [name] - list available plugin themes, or apply one. + // "/themes" alone opens the picker; "/themes " applies by + // bare name or by namespaced ID. The "name" branch lives below. + if cmd == "/themes" { + m.Input.Reset() + m.Input.SetValue("> ") + if len(m.ThemeEntries) == 0 { + m.ToastMessage = "no plugin themes installed" + m.ToastExpireTime = time.Now().UnixMilli() + 3000 + clearCmd := tea.Tick(3*time.Second, func(t time.Time) tea.Msg { + return clearToastMsg{} + }) + m.updateViewport() + return m, clearCmd + } + // Default the cursor to the currently active theme (or 0). + m.ThemeIndex = 0 + for i, t := range m.ThemeEntries { + if t.ID == m.SelectedTheme { + m.ThemeIndex = i + break + } + } + m.Mode = ViewThemes + m.updateViewport() + return m, nil + } + if strings.HasPrefix(cmd, "/themes ") { + // User supplied a name; resolve and apply inline. + name := strings.TrimSpace(strings.TrimPrefix(cmd, "/themes ")) + m.Input.Reset() + m.Input.SetValue("> ") + if name == "" { + m.Mode = ViewThemes + m.updateViewport() + return m, nil + } + info := m.FindTheme(name) + if info == nil { + m.ToastMessage = "theme not found: " + name + m.ToastExpireTime = time.Now().UnixMilli() + 3000 + clearCmd := tea.Tick(3*time.Second, func(t time.Time) tea.Msg { + return clearToastMsg{} + }) + m.updateViewport() + return m, clearCmd + } + if err := m.ApplyTheme(info); err != nil { + m.ToastMessage = "theme apply failed: " + err.Error() + m.ToastExpireTime = time.Now().UnixMilli() + 4000 + } else { + m.ToastMessage = "theme applied: " + info.ThemeName + m.ToastExpireTime = time.Now().UnixMilli() + 3000 + } + clearCmd := tea.Tick(4*time.Second, func(t time.Time) tea.Msg { + return clearToastMsg{} + }) + m.updateViewport() + return m, clearCmd + } + + // Plugin command handler dispatch. + // + // If the input matches a registered plugin command AND a + // CommandHandler is wired (see cmd/late/main.go), run the + // handler synchronously against the trailing args. The + // handler may: + // - return handled=true with non-empty output → toast + // "executed : "; run ends here. + // - return handled=true with empty output → silent toast. + // - return handled=true with err != nil → error toast. + // - return handled=false → fall through to + // the legacy "dispatch as a plain prompt" path below. + if isPluginCmd(cmd, m.PluginCommands) && m.CommandHandler != nil { + parts := strings.Fields(cmd) + if len(parts) > 0 { + name := parts[0] + args := parts[1:] + output, handled, hErr := m.CommandHandler(context.Background(), name, args) + if handled { + // Capture in input history (avoid consecutive duplicates). + if len(m.InputHistory) == 0 || m.InputHistory[len(m.InputHistory)-1] != cmd { + m.InputHistory = append(m.InputHistory, cmd) + } + m.HistoryIndex = -1 + m.HistoryWorking = "" + + // Reset input box. + m.Input.Reset() + m.Input.SetValue("> ") + + // Toast UX for handler output. + if hErr != nil { + m.ToastMessage = fmt.Sprintf("error executing %s: %v", name, hErr) + } else if output != "" { + firstLine := strings.SplitN(strings.TrimSpace(output), "\n", 2)[0] + m.ToastMessage = fmt.Sprintf("executed %s: %s", name, firstLine) + if len(m.ToastMessage) > 80 { + m.ToastMessage = m.ToastMessage[:77] + "..." + } + } else { + m.ToastMessage = fmt.Sprintf("%s executed", name) + } + m.ToastExpireTime = time.Now().UnixMilli() + 3000 + clearCmd := tea.Tick(3*time.Second, func(t time.Time) tea.Msg { + return clearToastMsg{} + }) + m.updateViewport() + return m, clearCmd + } + // handled=false: fall through to legacy plain-prompt path below. + } + } + + // Plugin-provided slash commands — check if the input is a registered plugin command + if isPluginCmd(cmd, m.PluginCommands) { + // Plugin commands are dispatched as regular user prompts to the agent. + // The plugin's registered skills, tools, and MCP servers handle the semantics. + // Fall through to normal submission below. + } // Preflight context check maxTokens := m.Focused.MaxTokens() @@ -1024,6 +1201,19 @@ func (m *Model) updateAutocomplete() { matches = append(matches, cmd) } } + // Plugin-provided commands (deduplicated against built-in commands) + builtinSet := make(map[string]bool, len(AvailableCommands)) + for _, c := range AvailableCommands { + builtinSet[strings.ToLower(c.Name)] = true + } + for _, cmd := range m.PluginCommands { + if builtinSet[strings.ToLower(cmd)] { + continue // skip plugin commands that shadow built-in commands + } + if strings.HasPrefix(strings.ToLower(cmd), prefix) { + matches = append(matches, CommandDef{Name: cmd}) + } + } if len(matches) > 0 { m.ShowAutocomplete = true m.AutocompleteItems = matches @@ -1271,3 +1461,36 @@ func isBinary(data []byte) bool { return false } + +// isPluginCmd checks whether the given input matches a registered plugin command. +// Plugin commands are matched exactly by their full string (e.g. "/query"). +func isPluginCmd(input string, pluginCmds []string) bool { + input = strings.TrimSpace(input) + for _, pc := range pluginCmds { + if input == pc { + return true + } + } + return false +} + +// SetPluginCommands replaces the model's PluginCommands slice with the provided list. +// This is called from main.go after plugin discovery to register dynamic slash commands. +func (m *Model) SetPluginCommands(commands []string) { + m.PluginCommands = make([]string, len(commands)) + copy(m.PluginCommands, commands) +} + +// ListedPluginCommands returns the list of plugin-provided slash commands +// currently registered. Renamed from `PluginCommands()` to avoid shadowing +// the same-named field on the Model struct (Go would resolve the bare +// identifier inside the method body to the method itself, breaking +// `len(m.PluginCommands)` callers in update.go/view.go). +func (m *Model) ListedPluginCommands() []string { + if len(m.PluginCommands) == 0 { + return nil + } + result := make([]string, len(m.PluginCommands)) + copy(result, m.PluginCommands) + return result +} diff --git a/internal/tui/view.go b/internal/tui/view.go index 2f7ca2c..355ba28 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -411,6 +411,21 @@ func (m *Model) statusBarView() string { helpStr := lipgloss.NewStyle().Foreground(subtextColor).Background(appBgColor).Render("ctrl+h Help") + // Plugin count badge + var pluginStr string + pluginCmdCount := len(m.PluginCommands) + if pluginCmdCount > 0 { + badge := fmt.Sprintf("%d plugin", pluginCmdCount) + if pluginCmdCount > 1 { + badge += "s" + } + pluginStr = lipgloss.NewStyle(). + Foreground(primaryColor). + Background(appBgColor). + Bold(true). + Render(badge) + } + var rightParts []string if attachedStr != "" { rightParts = append(rightParts, attachedStr) @@ -418,6 +433,9 @@ func (m *Model) statusBarView() string { if tokenStr != "" { rightParts = append(rightParts, tokenStr) } + if pluginStr != "" { + rightParts = append(rightParts, pluginStr) + } if breadcrumbStr != "" { rightParts = append(rightParts, breadcrumbStr) } @@ -493,6 +511,11 @@ func (m *Model) updateViewport() { return } + if m.Mode == ViewThemes { + m.renderThemeView() + return + } + if m.EscConfirmPending { s := m.GetAgentState(m.Focused.ID()) busy := s.State == StateThinking || s.State == StateStreaming || s.State == StateStopping @@ -525,6 +548,7 @@ func (m *Model) updateViewport() { s := m.GetAgentState(m.Focused.ID()) s.LastTotalContent = "" + // Build help text dynamically to include plugin commands helpText := `# Late Help & Keybindings Here is a list of available keyboard shortcuts: @@ -537,6 +561,26 @@ Here is a list of available keyboard shortcuts: **enter** Submit prompt **ctrl+h** Toggle this Help menu +## Slash Commands + +Type **/** followed by a command name to activate it: + +` + + // Built-in slash commands + for _, cmd := range AvailableCommands { + helpText += fmt.Sprintf(" `%s`\n", cmd) + } + + // Plugin-provided slash commands + if len(m.PluginCommands) > 0 { + helpText += "\n**Plugin Commands:**\n\n" + for _, cmd := range m.PluginCommands { + helpText += fmt.Sprintf(" `%s`\n", cmd) + } + } + + helpText += ` Press **ctrl+h** or **esc** to return to the chat.` // Total outer width is m.Viewport.Width() @@ -1032,6 +1076,148 @@ Your AI coding agent. Type a prompt below to get started. } // renderCommitLogView renders the commit history or commit detail in the viewport. +// renderThemeView draws the /themes picker into the viewport. Style +// mirrors the other pickers (ViewCommitLog, ViewRewind): a boxed list with +// a cursor and an "active" marker on the currently applied theme. Empty +// list is handled inline. +func (m *Model) renderThemeView() { + width := m.Viewport.Width() + if width < 1 { + width = 80 + } + height := m.Viewport.Height() + if height < 1 { + height = 20 + } + + if m.ThemeIndex >= len(m.ThemeEntries) { + m.ThemeIndex = len(m.ThemeEntries) - 1 + } + if m.ThemeIndex < 0 { + m.ThemeIndex = 0 + } + + header := lipgloss.NewStyle(). + Foreground(primaryColor). + Background(appBgColor). + Bold(true). + Padding(0, 1). + Render("Themes") + + subtitle := lipgloss.NewStyle(). + Foreground(subtextColor). + Background(appBgColor). + Padding(0, 1). + Render("Select a theme with \u2191/\u2193 and press enter to apply. esc to cancel.") + + if len(m.ThemeEntries) == 0 { + empty := lipgloss.NewStyle(). + Foreground(subtextColor). + Background(appBgColor). + Padding(0, 1). + Render("No plugin themes installed.") + box := lipgloss.NewStyle(). + Border(lipgloss.DoubleBorder()). + BorderForeground(secondaryColor). + BorderBackground(appBgColor). + Background(appBgColor). + Width(width - 2). + Padding(1, 2). + Render(lipgloss.JoinVertical(lipgloss.Left, header, subtitle, empty)) + m.Viewport.SetContent(box) + return + } + + var rows []string + for i, t := range m.ThemeEntries { + isActive := t.ID == m.SelectedTheme + marker := " " + if isActive { + marker = "\u25cf " + } + label := fmt.Sprintf("%s%s", marker, t.ThemeName) + sub := fmt.Sprintf(" %s", t.PluginName) + if isActive { + sub += " \u2022 active" + } + + var row string + if i == m.ThemeIndex { + row = lipgloss.NewStyle(). + Foreground(textColor). + Background(thoughtBgColor). + Bold(true). + Width(width - 8). + Padding(0, 1). + Render(label) + "\n" + + lipgloss.NewStyle(). + Foreground(subtextColor). + Background(thoughtBgColor). + Width(width - 8). + Padding(0, 1). + Render(sub) + } else { + row = lipgloss.NewStyle(). + Foreground(textColor). + Background(appBgColor). + Width(width - 8). + Padding(0, 1). + Render(label) + "\n" + + lipgloss.NewStyle(). + Foreground(subtextColor). + Background(appBgColor). + Width(width - 8). + Padding(0, 1). + Render(sub) + } + rows = append(rows, row) + } + + current := m.ThemeEntries[m.ThemeIndex] + footer := lipgloss.NewStyle(). + Foreground(subtextColor). + Background(appBgColor). + Padding(0, 1). + Render(fmt.Sprintf("Selected: %s (active: %s)", + current.ThemeName, + displayThemeNameOrNone(m.SelectedTheme))) + + box := lipgloss.NewStyle(). + Border(lipgloss.DoubleBorder()). + BorderForeground(secondaryColor). + BorderBackground(appBgColor). + Background(appBgColor). + Width(width - 2). + Padding(1, 2). + Render(lipgloss.JoinVertical(lipgloss.Left, + header, + subtitle, + "", + lipgloss.JoinVertical(lipgloss.Left, rows...), + "", + footer, + )) + + maxH := height - 2 + if maxH < 5 { + maxH = 5 + } + if boxHeight := lipgloss.Height(box); boxHeight > maxH { + box = lipgloss.NewStyle().MaxHeight(maxH).Render(box) + } + + m.Viewport.SetContent(box) +} + +// displayThemeNameOrNone formats the active theme id for the picker +// footer. Empty id is rendered as the bundled base theme. +func displayThemeNameOrNone(id string) string { + if id == "" { + return "bundled base" + } + return id +} + func (m *Model) renderCommitLogView() { s := m.GetAgentState(m.Focused.ID()) s.LastTotalContent = ""