feat(plugin): a package's prompts and themes reach Studio

Both were parsed by pluginpkg and went nowhere, while the docs said they
worked. A package's prompts are now discovered with its commands and invoked
the same way, /<plugin>:<name>, which is what the manifest documented. Its
themes are Studio theme packs: an entry names a pack's theme.json, and the
theme library lists it as plugin:<plugin>:<directory>, read in place from the
plugin root with its own images, never copied into the user's library, and
gone when the plugin is disabled. The retired desktop's .reasonix-theme
archives are not a format Studio reads, and the docs now say so.

The plugin docs are brought in line with the code: the Studio installer takes
a repository address or a folder (a .zip installs through the CLI), the routes
Studio drives replace a list of Wails methods that no longer exist, and the
protocol documents the panel and view surface kinds it already carries.
This commit is contained in:
yhh committed 2026-09-23 17:23:35 -07:00
1 parent de375d26a5
commit 2c7bde8710
13 files changed
+255 -54

No files matched your search

+4 -2
View File
@@ -54,8 +54,10 @@ install runtimes you trust completely.
rendered natively in the CLI transcript, the Desktop app, and ACP clients
(with text fallbacks), plus `/<plugin>:<action>` actions in the slash
menu, the Desktop command palette, and ACP's discoverable commands.
- **Prompts and themes** — `/<plugin>:<name>` prompt templates and
read-only plugin themes (`plugin:<plugin>:<theme>`) in Desktop Settings.
- **Prompts and themes** — `/<plugin>:<name>` prompt templates, discovered
with the package's commands, and theme packs (`theme.json` directories)
offered read-only in Studio's appearance settings as
`plugin:<plugin>:<directory>`.
## Runtime reload
+8 -2
View File
@@ -110,8 +110,9 @@ Streams follow `extension/provider/stream/open` → `stream/chunk` →
## Structured UI
Extensions with the `ui` capability publish `status`, `card`, `form`, and
`notification` payloads (`host/ui/publish`) and ask questions
Extensions with the `ui` capability publish `status`, `card`, `form`,
`notification`, `panel`, and `view` payloads (`host/ui/publish`) and ask
questions
(`host/ui/request`: confirm, input, select, multiselect). Surfaces are
**structured only**: no HTML, CSS, JavaScript, remote scripts, arbitrary
frontend components, or uncontrolled URLs; Markdown renders through each
@@ -120,6 +121,11 @@ ID, surface ID, session ID, and runtime generation; stale-generation
updates are dropped so late results after a tab switch or reload can never
overwrite current state.
A `panel` holds a place in the frontend's side rail rather than scrolling
past in the transcript: re-publishing the same surface id updates what is
already on screen. A `view` is composed rather than filled in: its payload is a
tree of host primitives, for a shape the fixed kinds do not have.
Actions declared at initialize are namespaced `/<plugin>:<action>` and are
invoked via `extension/ui/action`; form submissions arrive via
`extension/ui/submit`.
+34 -28
View File
@@ -148,20 +148,20 @@ the CLI.
### Install Plugins
The installer has two modes:
The installer takes one source:
- **Local folder**: click **Choose plugin folder** and select a plugin directory
on disk. The selected path is shown next to the button.
- **Git repository**: enter a Git source such as
`git:github.com/obra/superpowers`. **Install name (optional)** can override
the plugin manifest name for this install or overwrite.
- **A repository address**, such as `https://github.com/obra/superpowers`, a
branch, or a subdirectory of one.
- **A local folder**: drag it onto the installer or click **选文件夹** (Choose
folder).
Use the action buttons after choosing the source and options:
A local `.zip` archive, such as one made by **Export**, installs through the CLI
(`reasonix plugin install ./pkg.zip`) and the `install_source` tool.
- **Preview** validates the source and shows the planned install actions without
writing files.
- **Install plugin** installs the selected source using the current options.
- **Refresh plugins** reloads the installed-plugin list from disk and config.
Installing is always two steps: the installer first reads the source and shows
what it will add and what it needs, without writing anything; **Install**
then applies exactly that plan. A changed source between the two is refused
rather than installed.
Installer options:
@@ -282,7 +282,7 @@ Native Reasonix extensions use the exact v2 `apiVersion`:
"prompts": ["prompts"],
"hooks": {},
"mcpServers": {},
"themes": ["themes/*.reasonix-theme"]
"themes": ["themes/*/theme.json"]
},
"runtime": {
"command": "${REASONIX_PLUGIN_ROOT}/bin/example",
@@ -325,13 +325,16 @@ Parsing rules:
New resource types:
- `prompts` are prompt templates using the same semantics and argument
substitution as commands, invoked as `/<plugin>:<name>`. `commands` remains
a compatible alias.
- `themes` are `.reasonix-theme` files shown read-only in Desktop Settings as
plugin themes (IDs `plugin:<plugin>:<theme>`); they are never copied into
the user theme library. If the plugin is disabled or uninstalled while its
theme is active, the desktop falls back to the base style but keeps the
ID, so reinstalling the same plugin restores the theme.
substitution as commands, invoked as `/<plugin>:<name>`. The two are
discovered together; `commands` remains a compatible alias.
- `themes` are Studio theme packs:
- each entry names a pack's `theme.json`, as a path or a per-segment glob
such as `themes/*/theme.json`; its `background` and `preview` images sit
beside it;
- a pack appears in appearance settings as `plugin:<plugin>:<directory>`;
- packs are read in place, never copied into the user's library, and leave
with the plugin;
- any other file listed under `themes` is ignored.
The `runtime` block declares a code extension — a sidecar process Reasonix
launches and talks to over the Extension Protocol (JSON-RPC 2.0 over stdio;
@@ -521,14 +524,17 @@ Plugin hooks receive these environment variables:
- `CLAUDE_PROJECT_DIR`
- `CLAUDE_PLUGIN_ROOT`
## Desktop Backend Methods
## Studio HTTP routes
Desktop exposes plugin package operations through Wails methods:
Studio drives plugin packages through these kernel routes:
- `Plugins`
- `PlanPluginInstall`
- `InstallPlugin`
- `RemovePlugin`
- `SetPluginEnabled`
- `UpdatePlugin`
- `PluginDoctor`
- `GET /plugins` lists installed packages and what each contributes
- `POST /plugins/plan` reads a source and returns the install plan
- `POST /plugins/install` applies a plan, echoing its `planId`; with `replace`
set it is also the update path
- `POST /plugins/enabled` enables or disables a package
- `DELETE /plugins/{name}` removes a package
- `GET /plugins/{name}/export` packs a package as a `.zip`, credentials stripped
- `POST /extensions/reload` rebuilds the runtime from what is on disk
`reasonix plugin doctor` has no route; run it from the CLI.
+8
View File
@@ -1,5 +1,13 @@
# Reasonix Theme Pack V2
**Status:** this describes the retired Wails desktop and its `.reasonix-theme`
archives. What Reasonix Studio reads instead:
- one directory per pack, holding a `theme.json` (`schemaVersion: 1`) and
optional `background` and `preview` images, read by `internal/theme`;
- plugin themes are packs of that shape, listed as
`plugin:<plugin>:<directory>` (see `docs/PLUGIN_PACKAGES.md`).
Native theme packs for the Reasonix desktop app. Packs are controlled skins:
semantic color tokens, density/corner recipes, and optional local images for
the home and task/workspace scenes. They **cannot** run CSS, JavaScript, fonts,
+5 -6
View File
@@ -120,11 +120,10 @@ func (c *Config) PluginPackageAgentOwners() map[string][]string {
return out
}
// pluginPackageCommandRoots returns the command directories contributed by
// enabled installed plugin packages, in deterministic (name, path) order.
// CommandRootsForRoot places them ahead of every user/project dir so explicit
// commands win exact canonical-name clashes; LoadInstalled filters to enabled
// packages.
// pluginPackageCommandRoots returns the command and prompt directories of
// enabled plugin packages in (name, path) order; both are invoked as
// /<plugin>:<name>. CommandRootsForRoot places them ahead of every user and
// project dir so explicit commands win exact canonical-name clashes.
func (r Roots) pluginPackageCommandRoots() []command.Root {
reasonixHome := r.Home()
if strings.TrimSpace(reasonixHome) == "" {
@@ -133,7 +132,7 @@ func (r Roots) pluginPackageCommandRoots() []command.Root {
installed, _ := pluginpkg.LoadInstalled(reasonixHome)
var out []command.Root
for _, item := range installed {
for _, root := range item.Package.CommandRoots() {
for _, root := range append(item.Package.CommandRoots(), item.Package.PromptRoots()...) {
out = append(out, command.Root{Path: root, Plugin: item.Installed.Name})
}
}
+31
View File
@@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"reasonix/internal/command"
"testing"
"reasonix/internal/pluginpkg"
@@ -186,3 +187,33 @@ func TestCommandDirsWithoutPluginState(t *testing.T) {
t.Fatal("CommandDirsForRoot must still return the conventional dirs")
}
}
// A package's prompts are invoked the way its commands are: /<plugin>:<name>.
func TestPluginPromptsAreInvokedLikeItsCommands(t *testing.T) {
home := testenv.TempDir(t)
t.Setenv("REASONIX_HOME", home)
root := filepath.Join(home, "plugins", "review")
writeConfigTestFile(t, filepath.Join(root, pluginpkg.NativeManifest), `{
"apiVersion": "reasonix.io/plugin/v2",
"name": "review",
"version": "1.0.0",
"contributes": {"prompts": ["prompts"]}
}`)
writeConfigTestFile(t, filepath.Join(root, "prompts", "diff.md"), `---
description: review the diff
---
Review $ARGUMENTS`)
if err := pluginpkg.Upsert(home, pluginpkg.InstalledPlugin{Name: "review", Root: "plugins/review", Version: "1.0.0", ManifestKind: "reasonix", Enabled: true}); err != nil {
t.Fatal(err)
}
cmds, err := command.LoadRoots(CommandRootsForRoot(testenv.TempDir(t))...)
if err != nil {
t.Fatal(err)
}
for _, c := range cmds {
if c.Name == "review:diff" {
return
}
}
t.Fatalf("no /review:diff among %d commands", len(cmds))
}
+3 -3
View File
@@ -14,9 +14,9 @@
// lives in the existing packages (internal/skill, internal/command,
// internal/hook, internal/tool, internal/plugin, internal/provider), which
// run it and hand the kernel the resolved records; adapters.go maps one such
// record onto one Contribution. KindPrompt and KindTheme are declared in the
// catalog but have no contributor: a plugin package's prompts and themes are
// parsed by pluginpkg and reach no snapshot. Winner rules are resolved here,
// record onto one Contribution. KindPrompt and KindTheme have no contributor:
// a package's prompts are found with its commands, and internal/theme reads
// its themes. Winner rules are resolved here,
// once, identically for every caller: a higher-tier scope shadows a lower one
// for the same canonical ID, same-tier duplicates from different sources are
// hard conflicts instead of silent overrides, and hooks/interceptors stay
+9 -9
View File
@@ -99,7 +99,7 @@ type CommandRef struct {
// PromptRef is one prompt template a v2 plugin contributes from a prompts
// directory (distinct from legacy command contributions).
// Prompt files share the slash-command file shape (flat <name>.md with
// frontmatter) but map to kernel KindPrompt contributions, not commands.
// frontmatter) and are discovered with the package's commands.
type PromptRef struct {
Name string
Description string
@@ -151,9 +151,9 @@ type Manifest struct {
Commands []string
Hooks map[string][]Hook
MCPServers map[string]MCPServer
// Prompts are directories of flat <name>.md prompt templates. The two are
// separate semantic sets: commands become slash commands, prompts become
// kernel KindPrompt contributions. A path listed under both stays in both.
// Prompts are directories of flat <name>.md prompt templates, invoked the
// same way as commands: /<plugin>:<name>. A path listed under both is read
// once.
Prompts []string
// Themes are *.reasonix-theme file paths or per-segment glob patterns
// (e.g. "themes/*.reasonix-theme"), all lexically inside the plugin root.
@@ -894,7 +894,7 @@ func (p Package) AgentCount() int { return len(p.agentRefs()) }
func (p Package) PromptCount() int { return len(p.promptRefs()) }
// ThemeCount counts the theme files resolved from Manifest.Themes.
func (p Package) ThemeCount() int { return len(p.themeRefs()) }
func (p Package) ThemeCount() int { return len(p.ThemeFiles()) }
// CapabilitySummary is the full per-package capability count set. The
// four-value CapabilityCounts predates native runtime manifests and keeps its
@@ -922,7 +922,7 @@ func (p Package) CapabilitySummary() CapabilitySummary {
Hooks: hooks,
MCPServers: mcp,
Prompts: len(p.promptRefs()),
Themes: len(p.themeRefs()),
Themes: len(p.ThemeFiles()),
Runtime: p.Manifest.Runtime != nil,
}
}
@@ -933,7 +933,7 @@ func (p Package) Inventory() Inventory {
Agents: p.agentRefs(),
Commands: p.commandRefs(),
Prompts: p.promptRefs(),
Themes: p.themeRefs(),
Themes: p.ThemeFiles(),
Hooks: p.hookRefs(),
MCPServers: p.mcpServerRefs(),
}
@@ -983,11 +983,11 @@ func (p Package) promptRefs() []PromptRef {
return out
}
// themeRefs resolves the manifest's themes list (plain paths and
// ThemeFiles resolves the manifest's themes list (plain paths and
// per-segment globs) to concrete theme files. Parse-time validation has
// already rejected escapes and non-regular files, so unreadable entries
// here simply drop out (they were reported as parse warnings).
func (p Package) themeRefs() []ThemeRef {
func (p Package) ThemeFiles() []ThemeRef {
seen := map[string]bool{}
var out []ThemeRef
for _, pattern := range p.Manifest.Themes {
+16 -2
View File
@@ -55,8 +55,12 @@ func Asset(id string, kind Kind) ([]byte, string, error) {
if err := validID(id); err != nil {
return nil, "", err
}
dir, local := assetDir(id)
for _, ext := range extensions {
path := filepath.Join(Dir(), id, string(kind)+ext)
if !local {
break
}
path := filepath.Join(dir, string(kind)+ext)
info, err := os.Stat(path)
if err != nil || info.IsDir() {
continue
@@ -71,6 +75,9 @@ func Asset(id string, kind Kind) ([]byte, string, error) {
return raw, contentTypes[ext], nil
}
for _, ext := range extensions {
if isPluginID(id) {
break
}
raw, err := builtin.ReadFile(builtinAssetName(id, kind, ext))
if err != nil {
continue
@@ -84,10 +91,17 @@ func hasAsset(id string, kind Kind) bool {
if validID(id) != nil {
return false
}
dir, local := assetDir(id)
for _, ext := range extensions {
if info, err := os.Stat(filepath.Join(Dir(), id, string(kind)+ext)); err == nil && !info.IsDir() {
if !local {
break
}
if info, err := os.Stat(filepath.Join(dir, string(kind)+ext)); err == nil && !info.IsDir() {
return true
}
if isPluginID(id) {
continue
}
if _, err := fs.Stat(builtin, builtinAssetName(id, kind, ext)); err == nil {
return true
}
+57
View File
@@ -0,0 +1,57 @@
package theme
import (
"path/filepath"
"strings"
"reasonix/internal/config"
"reasonix/internal/pluginpkg"
)
// A pack an enabled plugin package contributes is read in place from the
// plugin's root, never copied into the user's library. Its id names the plugin
// and the pack's directory, so it can shadow neither a shipped nor a user pack,
// and it goes away with the plugin rather than lingering after an uninstall.
const pluginPrefix = "plugin:"
type pluginPack struct {
id string
dir string
}
func isPluginID(id string) bool { return strings.HasPrefix(id, pluginPrefix) }
// pluginPacks lists every theme.json an enabled plugin declares under
// contributes.themes. Other theme files are not packs this reader knows.
func pluginPacks() []pluginPack {
installed, _ := pluginpkg.LoadInstalled(config.RootDir(config.RootHome))
var out []pluginPack
for _, item := range installed {
for _, ref := range item.Package.ThemeFiles() {
if filepath.Base(ref.Path) != manifestName {
continue
}
dir := filepath.Dir(ref.Path)
out = append(out, pluginPack{id: pluginPrefix + item.Installed.Name + ":" + filepath.Base(dir), dir: dir})
}
}
return out
}
func pluginDir(id string) (string, bool) {
for _, p := range pluginPacks() {
if p.id == id {
return p.dir, true
}
}
return "", false
}
// assetDir is where a pack's images live on disk. A plugin id is never joined
// onto the user library path: its colon is not a file name on every system.
func assetDir(id string) (string, bool) {
if isPluginID(id) {
return pluginDir(id)
}
return filepath.Join(Dir(), id), true
}
+66
View File
@@ -0,0 +1,66 @@
package theme
import (
"os"
"path/filepath"
"testing"
"reasonix/internal/pluginpkg"
"reasonix/internal/testenv"
)
func installThemePlugin(t *testing.T, home string, enabled bool) string {
t.Helper()
root := filepath.Join(home, "plugins", "neon")
writePack(t, filepath.Join(root, "themes"), "night", minimal)
if err := os.WriteFile(filepath.Join(root, "themes", "night", "background.png"), []byte("png"), 0o644); err != nil {
t.Fatal(err)
}
manifest := `{"apiVersion":"reasonix.io/plugin/v2","name":"neon","version":"1.0.0","contributes":{"themes":["themes/*/theme.json"]}}`
if err := os.WriteFile(filepath.Join(root, pluginpkg.NativeManifest), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
if err := pluginpkg.Upsert(home, pluginpkg.InstalledPlugin{Name: "neon", Root: "plugins/neon", Version: "1.0.0", ManifestKind: "reasonix", Enabled: enabled}); err != nil {
t.Fatal(err)
}
return root
}
// A plugin's pack is read in place under a name that says whose it is, and it
// serves its own images; it is never copied into the user's library.
func TestAnEnabledPluginContributesAPackUnderItsOwnName(t *testing.T) {
home := testenv.TempDir(t)
t.Setenv("REASONIX_HOME", home)
installThemePlugin(t, home, true)
const id = "plugin:neon:night"
var listed bool
for _, p := range List() {
listed = listed || p.ID == id
}
if !listed {
t.Fatalf("List() lacks %s: %+v", id, List())
}
pack, err := Load(id)
if err != nil || pack.Name != "Dusk" || pack.Background == nil || !pack.Background.Image {
t.Fatalf("Load = %+v, %v; want the pack with its background", pack, err)
}
if raw, _, err := Asset(id, assetBackground); err != nil || string(raw) != "png" {
t.Fatalf("Asset = %q, %v", raw, err)
}
if entries, _ := os.ReadDir(Dir()); len(entries) != 0 {
t.Fatalf("the user library gained %d entries", len(entries))
}
}
func TestADisabledPluginContributesNoPack(t *testing.T) {
home := testenv.TempDir(t)
t.Setenv("REASONIX_HOME", home)
installThemePlugin(t, home, false)
if _, err := Load("plugin:neon:night"); err == nil {
t.Fatal("a disabled plugin's pack loaded")
}
if _, _, err := Asset("plugin:neon:night", assetBackground); err == nil {
t.Fatal("a disabled plugin's image was served")
}
}
+12
View File
@@ -143,6 +143,11 @@ func List() []Pack {
byID[pack.ID] = pack
}
}
for _, p := range pluginPacks() {
if pack, err := load(filepath.Join(p.dir, manifestName), p.id); err == nil {
byID[pack.ID] = pack
}
}
out := make([]Pack, 0, len(byID))
for _, pack := range byID {
out = append(out, pack)
@@ -178,6 +183,13 @@ func Load(id string) (Pack, error) {
if err := validID(id); err != nil {
return Pack{}, err
}
if isPluginID(id) {
dir, ok := pluginDir(id)
if !ok {
return Pack{}, fmt.Errorf("theme: no enabled plugin contributes %q", id)
}
return load(filepath.Join(dir, manifestName), id)
}
if pack, err := load(filepath.Join(Dir(), id, manifestName), id); err == nil {
return pack, nil
}
+2 -2
View File
@@ -8,7 +8,7 @@
"complexity": 1009,
"doc-language": 0,
"doc-owner": 43,
"doc-prose": 2326,
"doc-prose": 2325,
"error-text": 9,
"essay": 2907,
"file-size": 24032,
@@ -153,7 +153,7 @@
},
"docs/PLUGIN_PACKAGES.md": {
"doc-owner": 1,
"doc-prose": 103
"doc-prose": 102
},
"docs/REASONING_LANGUAGE.md": {
"doc-owner": 1,