Skip to content

feat(plugin): universal plugin system — OMP and Claude Code compatibility - #105

Open
giveen wants to merge 28 commits into
mlhher:mainfrom
giveen:plugin
Open

feat(plugin): universal plugin system — OMP and Claude Code compatibility#105
giveen wants to merge 28 commits into
mlhher:mainfrom
giveen:plugin

Conversation

@giveen

@giveen giveen commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Description of Changes

What

The Late plugin system now supports installing plugins from three ecosystems — not just plugins written specifically for Late:

Format Manifest Auto-detected surfaces
Late (native) package.json"late" field skills, commands, MCP, hooks, themes, tools
OMP (Oh My Pi) package.json"omp" field skills, commands, MCP, hooks
Claude Code .claude-plugin/plugin.json skills/, commands/, .mcp.json (flat + wrapped), hooks/hooks.json

Why

Late should be able to install plugins from any ecosystem without requiring authors to publish specifically for Late. This makes the entire OMP and Claude Code plugin catalogs available to Late users.

How

  • internal/plugin/manifest.go — Added OmpManifest and ClaudePluginManifest structs. LoadPlugin now tries three format loaders in order: native Late → OMP → Claude Code. Both OMP and Claude Code formats are translated into LateManifest so all surface registration (skills, MCP, commands, hooks) works identically.
  • .mcp.json detection handles both {"mcpServers": {...}} (wrapped) and {"name": {...}} (flat Claude Code convention) formats.
  • internal/plugin/installer.go — Fixed relative symlink path for scoped npm packages (@scope/name).
  • internal/plugin/manager.godiscoverFromDir now accepts symlinks (Go os.ReadDir().IsDir() returns false for symlinks) and recurses into @-prefixed scope directories.
  • internal/plugin/hooks.go — Hook subprocesses get sandboxing via setCmdSysProcAttr.
  • internal/plugin/sandbox_linux.go — Go 1.26 compat: NoNewPrivs removed from syscall.SysProcAttr.

Verified With Real Plugins

OMP plugin from npm:

late plugin install @a5c-ai/babysitter-omp
→ 1 skill(s) detected

Claude Code official plugins:

late plugin install ./asana     (.claude-plugin/plugin.json + flat .mcp.json)
→ 1 MCP server(s) detected

late plugin install ./discord   (.claude-plugin/plugin.json + skills/ + wrapped .mcp.json)
→ 1 skill(s), 1 MCP server(s) detected

All standard plugin lifecycle commands verified: install, link, list, disable, enable, remove, update.

Open Items

  • Claude Code hooks/hooks.json auto-detection is stubbed but not yet wired into the hook execution pipeline (the hook event model differs between systems).
  • No marketplace registry published yet (the default endpoint registry.late.dev is a placeholder that falls through to npm).

CLA

  • By checking this box, I confirm that I have read and agree to the terms of the CLA.md in this repository.

giveen and others added 11 commits July 25, 2026 23:08
…nToolResult pipeline, quickstart docs

New surfaces:
- marketplace.go: MarketplaceClient resolving bare names via JSON registry
  (LATE_PLUGIN_REGISTRY), falls back to plain npm on 404.
- hooks.go: CallOnToolResultHooks now returns ([]byte, error) — sequential
  mutation pipeline; empty stdout = pass-through, valid JSON = replace result,
  literal "blocked" = veto.
- installer.go: Install() dispatcher (URL/path/npm/marketplace), Update() and
  UpdateAll() with atomic git temp-clone+rename and npm @latest.
- command.go: handlePluginInstall routes through Install(); handlePluginUpdate
  routes through Update/UpdateAll; removed dead isGitURL/isLocalPath helpers.
- update_test.go: 6 tests covering npm happy-path, local-source refusal,
  unknown-name error, exec propagation, bulk update, marketplace fallback.
- marketplace_test.go: stub server + resolve tests for npm/git/404 entries.
- manifest.go: InstalledPlugin.Source string field tracks original install arg.

Quickstart docs:
- quickstart.md + quickstart.zh-CN.md: new Plugins section covering discovery
  paths, install from npm/git/local/marketplace, enable/disable/remove/update
  commands, and surface table (skills/MCP/commands/themes/hooks/tools).
- plugin-sdk.md: documented marketplace install, update command, tool-result
  mutation semantics.
…oop via BuildToolResultMiddlewares

- hooks.go: new BuildToolResultMiddlewares() returns post-execution middleware
  that calls CallOnToolResultHooks after tool success, skips hooks on error.
- main.go: appends the new middlewares to the root agent's middleware chain
  right after the existing BuildHookMiddlewares() line.
- hooks_themes_test.go: two tests — one verifies result mutation after a
  successful tool call, one verifies hooks are skipped on tool errors.
- manager.go: drop unused 'context' import (replaced by package-level seams)
- command.go: drop unused 'os' and 'os/exec' imports (removed inline
  updatePlugin that used exec.Command; new Update/UpdateAll APIs own it)
- command.go: change `for i, a := range args` to `for _, a := range args`
  since the loop index was unused
- hooks.go: add 'sort' to imports (sort.Slice is used at L150)
- hooks.go: wrap runHook's string return in []byte before assigning to
  json.RawMessage (RawMessage is []byte, not string)
- watcher.go: use = instead of := for struct field reassignment
- watcher.go: remove redundant `_ = os.Stat(...)
  if _, err := os.Stat(...)` duplicate
- update.go: rename `func (m *Model) PluginCommands() []string` to
  `func (m *Model) ListedPluginCommands() []string` to fix field/method
  shadowing — inside the method body, `m.PluginCommands` was resolving
  to the method itself, breaking `len(m.PluginCommands)` callers in
  view.go and other call sites
- plugin_test.go: update Empty test to read field directly,
  SetAndGet test to call the renamed ListedPluginCommands() method
Last commit was too aggressive — removed the os import and renamed the
loop index from i to _ in parseProjectFlag, but both are actually used:

- `os` is required for os.Stderr, os.Stdout and os.UserHomeDir calls
  throughout the file (HandlePluginCommand, printPluginUsage,
  handlePluginList, handlePluginLink and the install/remove/enable paths)

- The loop index `i` is consumed by the inner `for j, r := range args`
  to omit the --project/--local flag from the returned args. Renaming
  to _ broke that comparison.

Restoring both to their pre-fix state.
- main.go: import late/internal/pathutil and call pathutil.LateSkillsDir()
  at the plugin-skills registration site. The common.LateSkillsDir export
  was moved upstream.
- main.go: stop using tool.ScriptTool as the registration shape for
  plugin-declared inline tools. Upstream repurposed that struct's fields
  (SkillName/ScriptName/ScriptPath) for skill dispatch only, so plugin
  tools no longer fit. New local pluginInlineTool type at the top of
  cmd/late/main.go implements the common.Tool interface (Name,
  Description, Parameters, Execute) and bridges to plugin.InlineTool by
  synthesizing a client.ToolCall at Execute time from the registered
  name + the executor's json.RawMessage args.
- main.go: registration call updated to pluginInlineTool{ Name, Description,
  Parameters, Runner }.
- model.MessageHook = pluginManager.HookedMessage unchanged — signatures
  already match.
Three changes to make Late's plugin system actually installable and
compatible with omp and Claude Code ecosystems:

1. Universal manifest loading (manifest.go)
   - Recognize 'omp' field in package.json (Oh My Pi plugins)
   - Recognize .claude-plugin/plugin.json + auto-detect skills/,
     commands/, .mcp.json, hooks/hooks.json (Claude Code format)
   - Both are translated into LateManifest at load time so all
     surface registration works identically

2. Install fixes (installer.go, manager.go)
   - Symlink discovery: os.ReadDir.IsDir() returns false for
     symlinks, so linked plugins were invisible to the watcher
   - Scoped npm packages (@scope/name): relative symlink target
     was computed from the wrong parent directory
   - @-prefixed scope dirs are now recursed into to find plugins
   - Sandbox subprocesses via setCmdSysProcAttr (hooks.go)

3. Go 1.26 compat (sandbox_linux.go, sandbox_other.go)
   - NoNewPrivs removed from SysProcAttr (not present in Go 1.26)
   - CLONE_NEWPID retained for PID namespace isolation
Claude Code .mcp.json supports two shapes:
  {'mcpServers': {'name': {...}}}  — wrapped (omp/Late convention)
  {'name': {'type':'sse','url':...}} — flat map (Claude Code convention)

The asana, github, linear, and other official plugins use the flat
format and were silently skipped. Now try both.

Verified with two real official Claude Code plugins:
- asana  -> surfaces: 1 MCP server(s)
- discord -> surfaces: 1 skill(s), 1 MCP server(s)
Upstream changed AvailableCommands from []string to []CommandDef.
Our plugin branch added /clear, /themes commands and ThemeEntry struct.
Kept both: use CommandDef format with all commands from both sides.

Also fixed downstream code that still treated entries as strings:
- builtinSet: c → c.Name
- plugin command append: wrap string in CommandDef{}
@giveen
giveen marked this pull request as ready for review July 27, 2026 03:50
@giveen

giveen commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

@mlhher we had talked about a plugin system, so here you go, its compatible with OMP and Claude Code plugins plus gives us room to make our own plugins as well.

@mlhher

mlhher commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Lol @giveen what a champ. This will take some time to go over everything and test everything so please bear with me.

@giveen

giveen commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

No worries, I was on vacation and this was my work every morning with my coffee at the beach.

I've only done preliminary tests with it, but more in depth testing should be done.

Im going to be installing and uninstall lots of plugins this week to see what screw ups I made.

giveen added 7 commits July 27, 2026 12:47
LateCommands.UnmarshalJSON now accepts mixed-shape arrays via []json.RawMessage element dispatch. resolveArgs tightened to ./ and ../ prefixes only. MCPServerConfig.Dir (json:"-") threads plugin.Path into the MCP stdio transport.
HandlePluginCommand short-circuits -h/--help/help at top level AND per subcommand via isHelpToken + hasHelpFlag. Fixes install --help falling through to npm install docs and link --help treating --help as a filesystem path.
RegisterPluginSkills now loads skills from either /skills/SKILL.md at root OR one /skills/<skillname>/SKILL.md per subdirectory, matching the Claude Code / Agent Skills convention.
setCmdSysProcAttr sets SysProcAttr=nil on Linux. Unprivileged PID namespaces trigger EPERM on many kernels and abort plugin execution; security trade-off documented in the function comment.
writeBarePlugin/mkPlugin now emit late:{} package.json so LoadPlugin accepts the fixture. parseProjectFlag flipped expectation verified. Cross-package ResolveRenderTheme tests dropped from hooks_themes_test.go. update_test.go rec/recorded() rename reconciled.
themes_test.go uses bubbletea v2 KeyPressMsg{Code, Text} shape. plugin_test.go rewritten for field-level access to Model.PluginCommands. New theme_test.go restores ResolveRenderTheme coverage previously in hooks_themes_test.go.
Two related uninstall-path fixes discovered during plugin testing:

1) removeFromDir now prunes the empty <pluginsdir>/@scope/ parent in
   addition to the existing <pluginsdir>/node_modules/@scope/ path,
   so link-installed scoped plugins no longer leave empty @scope
   orphan directories after `late plugin remove`.

2) handlePluginRemove now calls pm.Discover() and
   pm.RegisterPluginSkills("") after a successful RemovePlugin,
   self-cleaning stale ~/.config/late/skills/<plugin>:<skill>
   symlinks instead of waiting for the next watcher tick or TUI
   bootstrap. Both calls are best-effort warnings, not fatal, so a
   post-remove failure does not undo the on-disk removal.

Tests (all in project_test.go):
- TestRemovePlugin_ScopedLink_CleansEmptyScopeParent (bug verifier)
- TestRemovePlugin_ScopedLink_KeepsNonEmptyScopeParent (non-empty preserve)
- TestRemovePlugin_Project_ScopedLink_CleansEmptyScopeParent (project mirror)
- TestHandlePluginRemove_PurgesStaleSkillSymlink (sandboxed via XDG_CONFIG_HOME)
- TestHandlePluginRemove_PreservesSiblingSkillSymlink (over-pruning guard)
@giveen

giveen commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

@mlhher okay I "think" thats everything.

Repository owner deleted a comment from Sergio87Felix Jul 30, 2026
@mlhher

mlhher commented Aug 4, 2026

Copy link
Copy Markdown
Owner

@giveen Due to the size I will go at this in multiple passes. For now these are things that I did notice. Please take a look at them.

  • LoadPluginInstallFromLocal / removeFromDir: A manifest's plugin name is only checked for emptiness, then used in filepath.Join. Names containing .. can make install/remove operate outside the plugin store.
  • isPluginCmdCommandHandler: The full input is matched before arguments are split. For example, /lint file.go does not match the registered /lint, so handlers cannot receive arguments.
  • pluginInlineTool.RequiresConfirmation: This always returns false, allowing arbitrary plugin scripts to run without the normal tool confirmation. The documentation notes it should ask for permission. Please clarify which one is correct and settle them on whatever behavior is expected.
  • PluginManager.HandleCommandPluginManager.All: HandleCommand holds RLock and then calls All(), which acquires it again. A queued writer can deadlock this nested read lock.
  • runHook: All hook and inline-tool stdin payloads are limited to 256 bytes. Normal messages, tool calls, results, and arguments can easily exceed this.
  • CallOnToolResultHooks: Plain-text tool results are wrapped in json.RawMessage. json.Marshal then fails, and its error is ignored, so the hook receives an empty payload.
  • CallOnInputHooks / CallOnTurnStartHooks / CallOnTurnEndHooks: These hooks are defined and documented but are not called by production code. For now I'd suggest removing them entirely as they can be added later on. If you want to add them now please wire them up so plugins can use them.
  • TestInstallFromGit_CleansUpOnCloneFailure: This test clones a nonexistent GitHub repository and can prompt for credentials during make test. Please replace the live network call with a local fake Git command or repository so the test is deterministic and non-interactive.

giveen added 5 commits August 4, 2026 10:01
Names were only checked for emptiness before being joined into
filesystem paths. A manifest with '..' components (e.g. ../../x) could
make InstallFromLocal create symlinks outside the store and make
RemovePlugin remove arbitrary directories.

Validate names at every load point (native, omp, claude-code loaders and
LoadPluginMeta), allowing plain and npm-scoped (@scope/pkg) names but
rejecting empty, '.' or '..' path components and backslashes.
# Conflicts:
#	cmd/late/main.go
#	internal/tui/state.go
#	internal/tui/update.go
isPluginCmd compared the full input string against registered command
names, so '/lint file.go' never matched '/lint' and handlers received no
arguments. Match on the first whitespace-separated field instead, keeping
lookalike prefixes ('/lint2') unmatched.
pluginInlineTool.RequiresConfirmation always returned false, so the TUI
confirmation middleware skipped the prompt and arbitrary plugin scripts
ran without approval. The docs (plugin-sdk.md, plugin-example.md)
promise plugin tools respect user confirmation, matching the precedent
of skill scripts (tool.ScriptTool) and MCP tools. Return true so the
normal confirmation flow applies.
HandleCommand held pm.mu.RLock and then called All(), which takes the
same RLock again. When a writer queues between the two read locks, Go's
RWMutex blocks the second RLock forever (readers yield to waiting
writers), deadlocking the manager.

Extract the sorted-copy body into allLocked() (caller must already hold
the lock); All() and HandleCommand both use it, so HandleCommand takes
exactly one RLock. Audit of every other lock-holder found no further
nested acquisitions.

Also fix writeExecutableShell to create the script parent dir — three
tests (HandleCommand dispatcher/duplicate, GetInlineTools) failed with
'no such file or directory' because scripts/ never existed.

Add TestHandleCommand_ConcurrentWithWriters: bounded stress test racing
handler calls against writer traffic under a watchdog, so a regression
fails the test instead of hanging the suite.
giveen added 5 commits August 4, 2026 10:16
runHook rejected any stdin payload over 256 bytes, but tool arguments,
tool results, full user messages, and inline-tool args routinely exceed
that, so hooks and handlers silently failed on normal input. The cap is
a sanity bound (the caller already holds the payload in memory and the
15s hook timeout bounds consumption), not a functional limit — bump to
16 MiB and rename hookCommandMax -> hookStdinMax to say what it bounds.

Add TestRunHook_LargePayloadPassesThrough: a 64 KiB payload round-trips
through a hook script intact (256x the old cap).
…strings

CallOnToolResultHooks wrapped the tool result in json.RawMessage, which
embeds bytes verbatim — json.Marshal failed on any non-JSON result
(plain command output, file contents), the error was discarded, and the
hook received empty stdin. The documented contract (plugin-sdk.md,
manifest.go) is {"tool": ..., "result": "..."} with result as a
string. Marshal as string(result), which always produces valid JSON with
proper escaping, and surface marshal errors instead of silently sending
nil payloads.

Add TestCallOnToolResultHooks_PlainTextResultPayload: a plain-text
result with quotes and backslashes round-trips through a capture hook as
valid JSON. The existing middleware test only used a JSON-shaped result,
which is why the bug went unnoticed.
These three hooks were defined and documented but never called by
production code: onInput duplicated onMessageSend's sequential
transform contract, and the turn hooks have no lifecycle moment wired
to them. Plugins declaring them silently never fired.

Remove the functions (CallOnInputHooks, CallOnTurnStartHooks,
CallOnTurnEndHooks), the snapshotHooks cases, the manifest fields, and
the documentation (plugin-sdk.md, plugin-example.md, quickstart). The
manifest parser now only exposes hooks that actually run: onToolCall,
onToolResult, onSessionStart, onMessageSend. Restoring the removed
hooks later is a matter of re-adding the fields and wiring the calls.
TestInstallFromGit_CleansUpOnCloneFailure cloned a nonexistent GitHub
URL, hitting live DNS/HTTPS and potentially prompting for credentials
under make test. Replace it with a fake 'git' on PATH that creates the
clone target directory and exits 1: the same partial-clone cleanup path
runs, with zero network, and the fake actually exercises the stated
contract (a half-populated dir must be removed) — the live-network
failure never created the dir in the first place.
- resolveHookPath/resolveThemePath: reject absolute paths outright.
  filepath.Join silently flattens a leading slash ('/etc/passwd' became
  pluginDir/etc/passwd), so absolute manifest paths passed the
  containment check. TestResolveHookPath_RejectsTraversal and
  TestResolveThemePath_RejectsTraversal now pass; this was a real
  contract gap, not a test bug.
- updateNpm: compute the relative symlink from linkDir's parent, not
  targetDir. For scoped sources (@scope/pkg) the link is nested one
  level deeper; the old rel produced a broken symlink and update
  reload failed. Mirrors InstallFromNpm. Real production bug.
- Update local-source refusal now says 'local dev symlink' (test
  contract expects the word 'local').
- InstallFromNpm routes through the runCommandOutput seam (like
  updateNpm): npm output is captured and surfaced on failure instead of
  streamed, and the exec is interceptable in tests.
- npm update test fixtures: scoped sources (@late/<name>) install under
  node_modules/@late/<name>, matching real npm layout; the old fixtures
  put them at node_modules/<name>.
- theme lookup test fixtures: theme JSON files must live inside the
  plugin directory (p.Path), not its parent — the declared path
  resolves against the plugin root.
- marketplace-fallback test: '@late/scoped-pkg' was mis-classified as a
  direct npm package (contains '/'), so the marketplace branch never
  ran. Use a bare name, which is what actually exercises the
  404-fall-through path.

Full suite green: go test -race ./... (15 packages).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants