@brimveyn/aimux-plugin 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,109 @@
1
+ // The manifest — `aimux-plugin.json` — is everything the host must know
2
+ // *before* it runs a line of plugin code: which halves exist (so it knows
3
+ // which process to reload), which API version the code was written against,
4
+ // what configuration it takes (so the settings screen can be generated), and
5
+ // which subprocess commands it contributes.
6
+ //
7
+ // It is therefore parsed and validated on its own, never by importing the
8
+ // plugin. `validateManifest` in the host reports the offending field by name.
9
+
10
+ /** API generation the plugin was written against. Bumped only on a break. */
11
+ export const PLUGIN_API_VERSION = 1
12
+
13
+ /** Which host process a plugin half runs in. The terminal manager loads none. */
14
+ export type PluginHost = 'ui' | 'daemon'
15
+
16
+ export type PluginConfigFieldType = 'string' | 'number' | 'boolean'
17
+
18
+ export interface PluginConfigField {
19
+ type: PluginConfigFieldType
20
+ /** Shown as the settings-row label; defaults to the field key. */
21
+ label?: string
22
+ description?: string
23
+ /** Applied when neither the registry nor `aimux.config.ts` provides a value. */
24
+ default?: string | number | boolean
25
+ /** Required fields make the plugin fail to load rather than misbehave. */
26
+ required?: boolean
27
+ /**
28
+ * Secrets are never echoed: not in `aimux plugin list`, not in the settings
29
+ * screen, not in the plugin log. Storage is still plaintext JSON — this is
30
+ * shoulder-surfing hygiene, not encryption.
31
+ */
32
+ secret?: boolean
33
+ }
34
+
35
+ /**
36
+ * A subprocess contribution, herdr-style. Interpreted by the built-in
37
+ * `aimux.exec` plugin (phase 3); parsed and carried here from day one so a
38
+ * manifest written against the documented schema does not have to change.
39
+ */
40
+ export interface PluginCommandSpec {
41
+ id: string
42
+ title?: string
43
+ /** argv, not a shell string — no quoting rules to get wrong. */
44
+ command: string[]
45
+ /** Where the command may be invoked from. */
46
+ contexts?: string[]
47
+ }
48
+
49
+ /**
50
+ * Where a plugin's bar widget goes when nothing has placed it yet.
51
+ *
52
+ * A proposal, not a claim: the host places it once, marks the placement as the
53
+ * plugin's, and never re-places it — so a user who moves it, hides it, or
54
+ * throws it out has the last word, and an unload withdraws only what it put
55
+ * there and the user left alone.
56
+ */
57
+ export interface PluginBarContribution {
58
+ /** Unqualified widget id — the same one `ctx.ui.widgets.register` takes. */
59
+ widget: string
60
+ /** Default `left`. */
61
+ side?: 'left' | 'right'
62
+ /** Default `end`. */
63
+ position?: 'start' | 'end'
64
+ /** Share of the bar, relative to its neighbours. Default 50. */
65
+ grow?: number
66
+ }
67
+
68
+ /**
69
+ * A keybinding a plugin asks for. Refused rather than applied when the key is
70
+ * already bound in `aimux.config.ts`: the file the user writes by hand outranks
71
+ * every plugin, here as everywhere else.
72
+ */
73
+ export interface PluginKeymapContribution {
74
+ /** Mode id, e.g. `navigation`, or the plugin's own pane mode. */
75
+ mode: string
76
+ /** Key notation, `<leader>` included. */
77
+ key: string
78
+ /** Unqualified action verb — the host prefixes it with the plugin id. */
79
+ action: string
80
+ }
81
+
82
+ export interface PluginContributions {
83
+ bars?: PluginBarContribution[]
84
+ keymaps?: PluginKeymapContribution[]
85
+ }
86
+
87
+ export interface PluginManifest {
88
+ /** Reverse-DNS-ish, `<vendor>.<name>`; the namespace for every registration. */
89
+ id: string
90
+ /** Human-facing name. Defaults to `id` when absent. */
91
+ name?: string
92
+ version: string
93
+ description?: string
94
+ /** Refuses to load on an older aimux. Semver, compared numerically. */
95
+ minAimuxVersion?: string
96
+ apiVersion: number
97
+ /** Entry files per host, relative to the plugin root. Both are optional. */
98
+ entries?: Partial<Record<PluginHost, string>>
99
+ /** argv lists run once at install/link time (typically `bun install`). */
100
+ build?: string[][]
101
+ config?: Record<string, PluginConfigField>
102
+ commands?: PluginCommandSpec[]
103
+ /**
104
+ * What the plugin asks the interface for: a place for its widget, a key for
105
+ * its action. Applied by the host when the UI half loads, withdrawn when it
106
+ * unloads, and outranked by anything the user has decided.
107
+ */
108
+ contributes?: PluginContributions
109
+ }
@@ -0,0 +1,215 @@
1
+ import type {
2
+ Disposer,
3
+ PluginContext,
4
+ PluginDefinition,
5
+ PluginEventListener,
6
+ PluginPaths,
7
+ } from './types'
8
+
9
+ import { EffectStack } from './effects'
10
+ import { PluginEventBus } from './event-bus'
11
+ import { PLUGIN_API_VERSION, type PluginHost, type PluginManifest } from './manifest'
12
+
13
+ export interface TestLogEntry {
14
+ level: 'debug' | 'info' | 'warn' | 'error'
15
+ message: string
16
+ data?: Record<string, unknown>
17
+ }
18
+
19
+ export interface TestRpcCall {
20
+ verb: string
21
+ payload: unknown
22
+ }
23
+
24
+ export interface TestContextOptions {
25
+ id?: string
26
+ host?: PluginHost
27
+ config?: Record<string, unknown>
28
+ manifest?: Partial<PluginManifest>
29
+ paths?: Partial<PluginPaths>
30
+ /** Services the plugin can read through `ctx.service(...)`. */
31
+ services?: Record<string, unknown>
32
+ /**
33
+ * Stands in for the other half. Return a value (or a promise) to resolve
34
+ * `ctx.rpc.call`; leave it out and every call rejects the way an unhandled
35
+ * verb does in production.
36
+ */
37
+ onCall?: (verb: string, payload: unknown) => unknown
38
+ /**
39
+ * Attaches host services to the context, the way the real hosts do through
40
+ * the kernel's `extendContext`. Without it a plugin that touches `ctx.ui` or
41
+ * `ctx.tabs` cannot be applied here at all — which is what made
42
+ * `aimux plugin doctor` unable to check the plugins that need checking most.
43
+ *
44
+ * A test usually wants recording stubs; `doctor` supplies exactly that.
45
+ */
46
+ extend?: (ctx: PluginContext) => void
47
+ }
48
+
49
+ export interface TestContextHandle {
50
+ ctx: PluginContext
51
+ bus: PluginEventBus
52
+ /** Every `ctx.log.*` line, in order. */
53
+ logs: TestLogEntry[]
54
+ /** Every `ctx.rpc.call` the plugin made. */
55
+ calls: TestRpcCall[]
56
+ /** Every `ctx.rpc.broadcast` the plugin sent. */
57
+ broadcasts: TestRpcCall[]
58
+ /** Services the plugin published via `ctx.provide`. */
59
+ provided: Map<string, unknown>
60
+ /**
61
+ * Run a definition's `apply` against this context. Generic so an inline
62
+ * `{ apply(ctx) { … } }` infers the base context and a typed
63
+ * `PluginDefinition<UiPluginContext>` is accepted as written.
64
+ */
65
+ apply: <Ctx extends PluginContext = PluginContext>(
66
+ definition: PluginDefinition<Ctx>
67
+ ) => Promise<void>
68
+ /** Call a verb the plugin registered with `ctx.rpc.handle`. */
69
+ invoke: <T = unknown>(verb: string, payload?: unknown) => Promise<T>
70
+ /** Verbs the plugin currently handles. */
71
+ handledVerbs: () => string[]
72
+ /** How many disposers the plugin has registered and not yet released. */
73
+ effectCount: () => number
74
+ /** Unwind, exactly as an unload would. Resolves with any disposer errors. */
75
+ dispose: () => Promise<unknown[]>
76
+ }
77
+
78
+ /**
79
+ * A standalone plugin context for tests, with no aimux process behind it.
80
+ * The event bus and effect stack are the real implementations, so `bail`,
81
+ * `waterfall` and disposal ordering behave as they do at runtime; only the
82
+ * process-crossing parts (RPC, filesystem paths) are stubs the test drives.
83
+ *
84
+ * ```ts
85
+ * const t = createTestContext({ config: { botToken: 'x' } })
86
+ * await t.apply(plugin)
87
+ * t.bus.emit('tab:turnComplete', { tabId: 't1' })
88
+ * expect(t.calls).toHaveLength(1)
89
+ * await t.dispose()
90
+ * expect(t.effectCount()).toBe(0)
91
+ * ```
92
+ */
93
+ export function createTestContext(options: TestContextOptions = {}): TestContextHandle {
94
+ const id = options.id ?? 'test.plugin'
95
+ const host: PluginHost = options.host ?? 'daemon'
96
+ const logs: TestLogEntry[] = []
97
+ const calls: TestRpcCall[] = []
98
+ const broadcasts: TestRpcCall[] = []
99
+ const provided = new Map<string, unknown>()
100
+ const handlers = new Map<string, (payload: unknown) => unknown>()
101
+ const services = new Map<string, unknown>(Object.entries(options.services ?? {}))
102
+
103
+ const bus = new PluginEventBus({
104
+ onError: (error, context) => {
105
+ logs.push({
106
+ data: { error: String(error), event: context.event },
107
+ level: 'error',
108
+ message: 'listener failed',
109
+ })
110
+ },
111
+ })
112
+ const effects = new EffectStack()
113
+
114
+ const record =
115
+ (level: TestLogEntry['level']) =>
116
+ (message: string, data?: Record<string, unknown>): void => {
117
+ logs.push(data === undefined ? { level, message } : { data, level, message })
118
+ }
119
+
120
+ const manifest: PluginManifest = {
121
+ apiVersion: PLUGIN_API_VERSION,
122
+ id,
123
+ name: id,
124
+ version: '0.0.0',
125
+ ...options.manifest,
126
+ }
127
+
128
+ const paths: PluginPaths = {
129
+ config: `/tmp/aimux-test/${id}/config`,
130
+ log: `/tmp/aimux-test/${id}/state/plugin.log`,
131
+ root: `/tmp/aimux-test/${id}`,
132
+ state: `/tmp/aimux-test/${id}/state`,
133
+ ...options.paths,
134
+ }
135
+
136
+ const ctx: PluginContext = {
137
+ bail: async (event, payload) => bus.bail(event, payload),
138
+ config: options.config ?? {},
139
+ effect: (setup) => {
140
+ void effects.run(setup)
141
+ },
142
+ emit: (event, payload) => {
143
+ bus.emit(event, payload)
144
+ },
145
+ host,
146
+ id,
147
+ log: {
148
+ debug: record('debug'),
149
+ error: record('error'),
150
+ info: record('info'),
151
+ warn: record('warn'),
152
+ },
153
+ manifest,
154
+ on: <T = unknown>(event: string, listener: PluginEventListener<T>): Disposer => {
155
+ const off = bus.on(event, listener, id)
156
+ effects.add(off)
157
+ return off
158
+ },
159
+ parallel: async (event, payload) => bus.parallel(event, payload),
160
+ paths,
161
+ provide: (name, value) => {
162
+ provided.set(name, value)
163
+ services.set(name, value)
164
+ effects.add(() => {
165
+ provided.delete(name)
166
+ services.delete(name)
167
+ })
168
+ },
169
+ rpc: {
170
+ broadcast: (verb, payload) => {
171
+ broadcasts.push({ payload, verb })
172
+ },
173
+ call: async <T = unknown>(verb: string, payload?: unknown): Promise<T> => {
174
+ calls.push({ payload, verb })
175
+ if (!options.onCall) {
176
+ throw new Error(`no handler for plugin rpc verb: ${id}.${verb}`)
177
+ }
178
+ return (await options.onCall(verb, payload)) as T
179
+ },
180
+ handle: (verb, handler) => {
181
+ handlers.set(verb, handler)
182
+ const off = (): void => {
183
+ if (handlers.get(verb) === handler) handlers.delete(verb)
184
+ }
185
+ effects.add(off)
186
+ return off
187
+ },
188
+ },
189
+ serial: async (event, payload) => bus.serial(event, payload),
190
+ service: <T = unknown>(name: string): T | undefined => services.get(name) as T | undefined,
191
+ waterfall: async <T>(event: string, value: T) => bus.waterfall(event, value),
192
+ }
193
+
194
+ options.extend?.(ctx)
195
+
196
+ return {
197
+ apply: async (definition) => {
198
+ await (definition as PluginDefinition).apply(ctx)
199
+ },
200
+ broadcasts,
201
+ bus,
202
+ calls,
203
+ ctx,
204
+ dispose: async () => effects.dispose(),
205
+ effectCount: () => effects.size,
206
+ handledVerbs: () => [...handlers.keys()],
207
+ invoke: async <T = unknown>(verb: string, payload?: unknown): Promise<T> => {
208
+ const handler = handlers.get(verb)
209
+ if (!handler) throw new Error(`no handler for plugin rpc verb: ${id}.${verb}`)
210
+ return (await handler(payload)) as T
211
+ },
212
+ logs,
213
+ provided,
214
+ }
215
+ }
package/src/types.ts ADDED
@@ -0,0 +1,151 @@
1
+ import type {
2
+ PluginAssistantsApi,
3
+ PluginCliApi,
4
+ PluginHooksApi,
5
+ PluginMetricsApi,
6
+ PluginProjectsApi,
7
+ PluginTabsApi,
8
+ PluginWorkspacesApi,
9
+ } from './daemon-api'
10
+ import type { PluginHost, PluginManifest } from './manifest'
11
+ import type { PluginActionsApi, PluginStoreApi, PluginUiApi } from './ui'
12
+
13
+ /**
14
+ * Everything a plugin registers hands back one of these. The kernel calls
15
+ * every disposer a fiber collected before it re-imports the module, which is
16
+ * what makes hot reload safe by construction rather than by discipline.
17
+ */
18
+ export type Disposer = () => void | Promise<void>
19
+
20
+ export interface PluginLogger {
21
+ debug: (message: string, data?: Record<string, unknown>) => void
22
+ info: (message: string, data?: Record<string, unknown>) => void
23
+ warn: (message: string, data?: Record<string, unknown>) => void
24
+ error: (message: string, data?: Record<string, unknown>) => void
25
+ }
26
+
27
+ export interface PluginPaths {
28
+ /** The plugin's own directory — read-only as far as the plugin is concerned. */
29
+ root: string
30
+ /** `<profile>/plugins-config/<id>`: files a human is expected to edit. */
31
+ config: string
32
+ /** `<profile>/plugins-state/<id>`: caches, databases, anything disposable. */
33
+ state: string
34
+ /** `<state>/plugin.log`, where `ctx.log` and load failures are written. */
35
+ log: string
36
+ }
37
+
38
+ export type PluginEventListener<T = never> = (payload: T) => unknown
39
+
40
+ /**
41
+ * Five dispatch modes, after Cordis. `emit` is the one you want unless you
42
+ * need a result: it never awaits and never lets one listener's rejection
43
+ * reach another.
44
+ */
45
+ export interface PluginEventDispatch {
46
+ /** Fire and forget. Rejections are logged against the emitting plugin. */
47
+ emit: (event: string, payload?: unknown) => void
48
+ /** All listeners at once; resolves with every result. */
49
+ parallel: (event: string, payload?: unknown) => Promise<unknown[]>
50
+ /** One listener at a time, in registration order. */
51
+ serial: (event: string, payload?: unknown) => Promise<unknown[]>
52
+ /** Stops at the first listener returning something other than `undefined`. */
53
+ bail: <T>(event: string, payload?: unknown) => Promise<T | undefined>
54
+ /** Threads a value through every listener; each returns the next input. */
55
+ waterfall: <T>(event: string, value: T) => Promise<T>
56
+ }
57
+
58
+ /**
59
+ * Talk to this plugin's other half. `call` crosses the process boundary
60
+ * (UI ⇄ daemon) over aimux's existing IPC socket; the payload is opaque to
61
+ * the protocol, which validates the envelope once and never the contents.
62
+ *
63
+ * A plugin with only one half still gets `rpc` — `call` simply rejects with
64
+ * "no handler", which is also what an unhandled verb does.
65
+ */
66
+ export interface PluginRpc {
67
+ call: <T = unknown>(verb: string, payload?: unknown) => Promise<T>
68
+ handle: (verb: string, handler: (payload: unknown) => unknown) => Disposer
69
+ /** One-way fanout to every live instance of the other half. */
70
+ broadcast: (verb: string, payload?: unknown) => void
71
+ }
72
+
73
+ /**
74
+ * The object a plugin's `apply` receives. Services beyond this base
75
+ * (`ctx.ui.*`, `ctx.assistants`, `ctx.tabs`…) arrive in later API phases and
76
+ * are always reached through `inject`, so a plugin that asks for one it does
77
+ * not get stays `pending` instead of crashing.
78
+ */
79
+ export interface PluginContext {
80
+ readonly id: string
81
+ readonly manifest: PluginManifest
82
+ /** Which process this half is running in. */
83
+ readonly host: PluginHost
84
+ readonly log: PluginLogger
85
+ /** Merged and defaulted from the manifest schema, the registry and user config. */
86
+ readonly config: Record<string, unknown>
87
+ readonly paths: PluginPaths
88
+ readonly rpc: PluginRpc
89
+
90
+ /**
91
+ * Register teardown. The setup function runs immediately (awaited when it
92
+ * returns a promise); the disposer it returns runs on unload, in reverse
93
+ * registration order. Anything a plugin allocates — a timer, a watcher, a
94
+ * socket — belongs in here.
95
+ */
96
+ effect: (setup: () => Disposer | void | Promise<Disposer | void>) => void
97
+
98
+ /** Subscribe to a host or plugin event. Auto-disposed on unload. */
99
+ on: <T = unknown>(event: string, listener: PluginEventListener<T>) => Disposer
100
+
101
+ emit: PluginEventDispatch['emit']
102
+ parallel: PluginEventDispatch['parallel']
103
+ serial: PluginEventDispatch['serial']
104
+ bail: PluginEventDispatch['bail']
105
+ waterfall: PluginEventDispatch['waterfall']
106
+
107
+ /**
108
+ * Publish a service other plugins may `inject`. Withdrawn automatically on
109
+ * unload, which unloads whoever injected it.
110
+ */
111
+ provide: (name: string, value: unknown) => void
112
+ /** Read a service. Prefer `inject` — it makes the dependency load-ordered. */
113
+ service: <T = unknown>(name: string) => T | undefined
114
+ }
115
+
116
+ /**
117
+ * The UI half's context. `ui`, `actions` and `store` are attached by the UI
118
+ * host; a plugin declaring `entries.ui` always receives them.
119
+ */
120
+ export interface UiPluginContext<Slice = unknown> extends PluginContext {
121
+ readonly host: 'ui'
122
+ readonly ui: PluginUiApi
123
+ readonly actions: PluginActionsApi
124
+ readonly store: PluginStoreApi<Slice>
125
+ }
126
+
127
+ /**
128
+ * The daemon half's context. Attached by the daemon host; a plugin declaring
129
+ * `entries.daemon` always receives them.
130
+ */
131
+ export interface DaemonPluginContext extends PluginContext {
132
+ readonly host: 'daemon'
133
+ readonly tabs: PluginTabsApi
134
+ readonly projects: PluginProjectsApi
135
+ readonly workspaces: PluginWorkspacesApi
136
+ readonly assistants: PluginAssistantsApi
137
+ readonly hooks: PluginHooksApi
138
+ readonly cli: PluginCliApi
139
+ readonly metrics: PluginMetricsApi
140
+ }
141
+
142
+ export interface PluginDefinition<Ctx extends PluginContext = PluginContext> {
143
+ /** Diagnostic label. Defaults to the manifest id plus the half. */
144
+ name?: string
145
+ /**
146
+ * Services this half needs. The fiber stays `pending` until every one is
147
+ * provided, then applies; if one is later withdrawn the fiber unloads.
148
+ */
149
+ inject?: readonly string[]
150
+ apply: (ctx: Ctx) => void | Promise<void>
151
+ }