Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,9 +207,29 @@ Inside chat, `/skill` lists discovered skills. Use `/skill my-skill` or `/skill:

Extensions are trusted local code. librecode follows the Unix philosophy here: extensions are powerful, low-level, and allowed to footgun if you ask them to. Lua is the first supported extension runtime; the host is designed so additional runtimes can be added later.

Default extension roots come from `extensions.paths` in config, defaulting to:
Extensions are declared with `extensions.use` in config. The default source is:

- `.librecode/extensions`
```yaml
extensions:
enabled: true
use:
- path:.librecode/extensions
```

The extension manager interface supports source strings and object entries with versions:

```yaml
extensions:
use:
- official:vim-mode
- github:example/librecode-extension
- github:example/monorepo//extensions/fancy
- path:.librecode/extensions/local-dev
- source: github:example/librecode-extension
version: v1.2.3
```

Startup loads only entries declared in `extensions.use`; extra directories on disk are ignored. `path:` sources load from disk today, while `official:` and `github:` sources are installed and pinned by the extension manager.
Comment thread
omarluq marked this conversation as resolved.

The default chat UI is Go-owned and extensions are optional customization. Use `--no-extensions` to disable configured extensions for a single run.

Expand All @@ -228,15 +248,22 @@ For architecture, roadmap, and API details, see:
- [`docs/adr/0001-programmable-runtime.md`](docs/adr/0001-programmable-runtime.md)
- [`docs/runtime-architecture.md`](docs/runtime-architecture.md)
- [`docs/extension-runtime.md`](docs/extension-runtime.md)
- [`docs/extension-manager.md`](docs/extension-manager.md)
- [`docs/extension-roadmap.md`](docs/extension-roadmap.md)
- [`docs/extension-api.md`](docs/extension-api.md)
- [`docs/rendering-boundary.md`](docs/rendering-boundary.md)
- [`docs/skills.md`](docs/skills.md)

Inspect loaded extensions:
Inspect and manage extensions:

```bash
librecode extension list
librecode extension add <source> [--version vX.Y.Z]
librecode extension remove <source-or-name>
librecode extension install
librecode extension update
librecode extension tidy
librecode extension doctor
librecode extension run <command> [args...]
```

Expand Down Expand Up @@ -275,6 +302,12 @@ librecode skill validate
librecode tool list
librecode tool run <name> [json-args|-] [--cwd path]
librecode extension list
librecode extension add <source> [--version vX.Y.Z]
librecode extension remove <source-or-name>
librecode extension install
librecode extension update
librecode extension tidy
librecode extension doctor
librecode extension run <command> [args...]
librecode config show
librecode config validate
Expand Down
12 changes: 11 additions & 1 deletion cmd/librecode/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ func configEntries(cfg *config.Config) []configEntry {
{key: "database.max_idle_conns", value: fmt.Sprint(cfg.Database.MaxIdleConns)},
{key: "database.conn_max_lifetime", value: cfg.Database.ConnMaxLifetime.String()},
{key: "extensions.enabled", value: fmt.Sprint(cfg.Extensions.Enabled)},
{key: "extensions.paths", value: strings.Join(cfg.Extensions.Paths, ",")},
{key: "extensions.use", value: strings.Join(extensionUseSources(cfg.Extensions.Use), ",")},
{key: "assistant.provider", value: cfg.Assistant.Provider},
{key: "assistant.model", value: cfg.Assistant.Model},
{key: "assistant.thinking_level", value: cfg.Assistant.ThinkingLevel},
Expand All @@ -146,6 +146,16 @@ func resolveEnv(env, fallback string) string {
return mo.EmptyableToOption(env).OrElse(fallback)
}

func extensionUseSources(entries []config.ExtensionUse) []string {
return lo.Map(entries, func(entry config.ExtensionUse, _ int) string {
if entry.Version == "" {
return entry.Source
}

return fmt.Sprintf("%s@%s", entry.Source, entry.Version)
})
}

// upperEnvKeys returns config keys uppercased with a given prefix (e.g. "LIBRECODE_APP_NAME").
func upperEnvKeys(prefix string, entries []configEntry) []string {
return lo.Map(entries, func(e configEntry, _ int) string {
Expand Down
42 changes: 33 additions & 9 deletions cmd/librecode/extension.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,15 @@ func newExtensionCmd() *cobra.Command {
func newExtensionListCmd() *cobra.Command {
return &cobra.Command{
Use: listUse,
Short: "List loaded workflow extensions, commands, and tools",
Short: "List configured and loaded workflow extensions",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return withContainer(cmd.Context(), func(container *di.Container) error {
manager := di.MustInvoke[*di.ExtensionService](container).Manager
extensions := manager.Extensions()
for index := range extensions {
if err := printExtension(cmd, &extensions[index]); err != nil {
service := di.MustInvoke[*di.ExtensionService](container)
loadedByPath := loadedExtensionsByPath(service.Manager.Extensions())
for index := range service.State.Configured {
configuredExtension := &service.State.Configured[index]
if err := printConfiguredExtension(cmd, configuredExtension, loadedByPath); err != nil {
return err
}
}
Expand Down Expand Up @@ -66,12 +67,25 @@ func newExtensionRunCmd() *cobra.Command {
}
}

func printExtension(cmd *cobra.Command, loadedExtension *extension.LoadedExtension) error {
func printConfiguredExtension(
cmd *cobra.Command,
configuredExtension *extension.ResolvedSource,
loadedByPath map[string]extension.LoadedExtension,
) error {
loadedExtension, loaded := loadedByPath[configuredExtension.LoadPath]
status := configuredExtension.Status
if loaded {
status = "loaded"
}

_, err := fmt.Fprintf(
cmd.OutOrStdout(),
"%s\t%s\tcommands=%s\ttools=%s\tkeymaps=%s\thandlers=%s\ttimers=%d\tduration=%s\n",
loadedExtension.Name,
loadedExtension.Path,
"%s\t%s\t%s\tversion=%s\tpath=%s\tcommands=%s\ttools=%s\tkeymaps=%s\thandlers=%s\ttimers=%d\tduration=%s\n",
configuredExtension.Name,
configuredExtension.Ref.Key(),
status,
configuredExtension.Lock.Version,
configuredExtension.LoadPath,
strings.Join(loadedExtension.Commands, ","),
strings.Join(loadedExtension.Tools, ","),
strings.Join(loadedExtension.Keymaps, ","),
Expand All @@ -85,3 +99,13 @@ func printExtension(cmd *cobra.Command, loadedExtension *extension.LoadedExtensi

return nil
}

func loadedExtensionsByPath(loadedExtensions []extension.LoadedExtension) map[string]extension.LoadedExtension {
loadedByPath := make(map[string]extension.LoadedExtension, len(loadedExtensions))
for index := range loadedExtensions {
loadedExtension := loadedExtensions[index]
loadedByPath[loadedExtension.Path] = loadedExtension
}

return loadedByPath
}
17 changes: 15 additions & 2 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,21 @@ database:

extensions:
enabled: true
paths:
- .librecode/extensions
# Extension sources use scheme:value syntax. Startup loads only entries listed
# here; extra directories on disk are ignored unless explicitly declared.
use:
# Shorthand string form.
- path:.librecode/extensions
- official:vim-mode
- github:example/librecode-extension
- github:example/monorepo//extensions/fancy
- path:/absolute/or/relative/extension

# Object form with version pinning.
- source: official:vim-mode
version: v0.1.0
- source: github:example/librecode-extension
version: v1.2.3

assistant:
provider: openai-codex
Expand Down
59 changes: 45 additions & 14 deletions docs/extension-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,32 +16,63 @@ See also:

## Loading model

Extensions are trusted local Lua files loaded from configured `extensions.paths`.
Extensions are trusted local Lua files loaded from configured `extensions.use` sources.

By default, configured extension paths are:
Default config:

- `.librecode/extensions`
```yaml
extensions:
enabled: true
use:
- path:.librecode/extensions
```

librecode does not auto-load a bundled `extensions/` directory. The stock chat UI is implemented in Go; Lua extensions are optional customization. Use `--no-extensions` to skip configured extensions for one command.
Supported source declaration forms:

```yaml
extensions:
use:
# shorthand string form
- official:vim-mode
- github:example/librecode-extension
- github:example/monorepo//extensions/fancy
- path:.librecode/extensions/my-extension
- path:/absolute/or/relative/extension

# object form with version pinning
- source: official:vim-mode
version: v0.1.0
- source: github:example/librecode-extension
version: v1.2.3
```

Each Lua file runs in its own Lua state.
Startup loads only entries declared in `extensions.use`; extra directories on disk are ignored. `path:` sources load from disk today. `official:` and `github:` sources are installed and pinned by the extension manager. Unknown schemes are configuration errors.

Lua helper modules can live under a `lua/` subdirectory inside any configured extension root, or next to a loaded extension file. The extension manager adds those roots to `package.path` and skips `lua/` helper directories when discovering top-level extension files.
librecode does not auto-load a bundled `extensions/` directory. The stock chat UI is implemented in Go; Lua extensions are optional customization. Use `--no-extensions` to skip configured extensions for one command.

Example:
Each Lua file or directory extension entry runs in its own Lua state. Directory extensions use a small manifest plus entry file:

```text
.librecode/extensions/
my-workflow.lua
lua/
my_workflow/
helpers.lua
.librecode/extensions/my-workflow/
init.lua
workflow.lua
helpers.lua
```

```lua
-- init.lua
return {
name = "my-workflow",
version = "0.1.0",
api_version = "v1alpha1",
entry = "workflow.lua",
}
```

Then extensions can do:
The extension root is added to `package.path`, so entry files can require sibling modules:

```lua
local helpers = require("my_workflow.helpers")
local helpers = require("helpers")
```

Helper modules are convenience wrappers over primitive APIs. They are not a separate Go host API family.
Expand Down
Loading
Loading