opencode-plugin-kit 1.0.0-alpha.5 → 1.0.0-alpha.6
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.
- package/README.md +13 -21
- package/package.json +7 -6
- package/src/collapsible.tsx +6 -6
- package/src/host.ts +24 -13
- package/src/providers.ts +77 -12
- package/src/viewPicker.ts +1 -1
- package/tsconfig.json +1 -2
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
6
|
|
|
7
7
|
Shared building blocks for [OpenCode](https://opencode.ai) sidebar plugins —
|
|
8
|
-
the pieces that every sidebar widget ends up reimplementing. Used by
|
|
8
|
+
the pieces that every sidebar widget ends up reimplementing. Used by four
|
|
9
9
|
production plugins with **100% test coverage**.
|
|
10
10
|
|
|
11
11
|
## Install
|
|
@@ -16,24 +16,16 @@ Published on [npm](https://www.npmjs.com/package/opencode-plugin-kit):
|
|
|
16
16
|
npm install opencode-plugin-kit
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
Plugins depending on the kit declare it in `dependencies`; the
|
|
20
|
-
(`@opencode
|
|
19
|
+
Plugins depending on the kit declare it in `dependencies`; the host peer
|
|
20
|
+
(`@opencode/plugin`) must match your OpenCode v2 runtime version (see each
|
|
21
21
|
plugin's README).
|
|
22
22
|
|
|
23
23
|
## Status
|
|
24
24
|
|
|
25
|
-
**v1.0.0-alpha.
|
|
25
|
+
**v1.0.0-alpha.6** — API stabilized, ready for integration testing.
|
|
26
26
|
|
|
27
27
|
## Quick Start
|
|
28
28
|
|
|
29
|
-
```sh
|
|
30
|
-
# npm
|
|
31
|
-
npm install opencode-plugin-kit
|
|
32
|
-
|
|
33
|
-
# bun
|
|
34
|
-
bun add opencode-plugin-kit
|
|
35
|
-
```
|
|
36
|
-
|
|
37
29
|
```ts
|
|
38
30
|
import { createViewPicker, createCachedStore } from "opencode-plugin-kit"
|
|
39
31
|
|
|
@@ -95,7 +87,7 @@ their unique data and rendering.
|
|
|
95
87
|
| `commands.ts` | `createToggle()`, `registerKeymapCommand()` | Toggle command + keymap registration |
|
|
96
88
|
| `workspace.ts` | `resolveLocation()`, `workspaceDirectory()` | Workspace location resolution |
|
|
97
89
|
| `toast.ts` | `showToast()` | Toast helper |
|
|
98
|
-
| `host.ts` | `KitContext`, `KitMessageShape`
|
|
90
|
+
| `host.ts` | `KitContext`, `KitMessageShape`, `KitModelShape` | Structural host-contract types |
|
|
99
91
|
|
|
100
92
|
Everything is re-exported from the package root (`src/index.ts`).
|
|
101
93
|
|
|
@@ -199,22 +191,22 @@ const { input, output, cost } = sumProviderTokens(context, sessionID, "opencode"
|
|
|
199
191
|
|
|
200
192
|
## Consuming Plugins
|
|
201
193
|
|
|
202
|
-
| Plugin
|
|
203
|
-
|
|
|
204
|
-
| [opencode-usage-quota-tracker](
|
|
205
|
-
| [opencode-model-recommender](
|
|
206
|
-
| [opencode-skill-lister](
|
|
207
|
-
| [opencode-plugin-manager](
|
|
194
|
+
| Plugin | What it does |
|
|
195
|
+
| ------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
|
|
196
|
+
| [opencode-usage-quota-tracker](https://github.com/ranjithrajv/opencode-usage-quota-tracker) | Live provider quota + usage in sidebar footer |
|
|
197
|
+
| [opencode-model-recommender](https://github.com/ranjithrajv/opencode-model-recommender) | Model recommendations by cache ratio, token cost, session cost |
|
|
198
|
+
| [opencode-skill-lister](https://github.com/ranjithrajv/opencode-skill-lister) | Skills list in sidebar |
|
|
199
|
+
| [opencode-plugin-manager](https://github.com/ranjithrajv/opencode-plugin-manager) | Plugin manager in sidebar |
|
|
208
200
|
|
|
209
201
|
Link locally with `"opencode-plugin-kit": "file:../opencode-plugin-kit"` in the
|
|
210
202
|
consumer's `package.json`, then `npm install` (or `bun install`).
|
|
211
203
|
|
|
212
204
|
## Compatibility
|
|
213
205
|
|
|
214
|
-
The host plugin API
|
|
206
|
+
The OpenCode v2 host plugin API's types are the spec. Kit consumes the context
|
|
215
207
|
structurally — anything the host ships with the expected members satisfies it.
|
|
216
208
|
|
|
217
|
-
- **No runtime dependency** on `@opencode
|
|
209
|
+
- **No runtime dependency** on `@opencode/plugin` — types only
|
|
218
210
|
- **Optional peer** — declared as optional so consumers share one copy
|
|
219
211
|
- **CI tripwire** — `host.test-d.ts` pins published host types against kit's
|
|
220
212
|
contract; drift fails CI with a readable diff
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-plugin-kit",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.6",
|
|
4
4
|
"description": "Shared building blocks for OpenCode sidebar plugins: provider vocabulary, row formatting, defensive message traversal, connected-provider tracking, polling fetcher, cached resources, and a persisted view/filter picker (registry + slash command + dialog + toast).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"opencode",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"author": "Ranjith Raj",
|
|
13
13
|
"repository": {
|
|
14
14
|
"type": "git",
|
|
15
|
-
"url": "git+https://github.com/
|
|
15
|
+
"url": "git+https://github.com/ranjithrajv/opencode-plugin-kit.git"
|
|
16
16
|
},
|
|
17
17
|
"files": [
|
|
18
18
|
"src",
|
|
@@ -36,8 +36,9 @@
|
|
|
36
36
|
"test:coverage": "vitest run --coverage.enabled"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
|
-
"@opencode
|
|
40
|
-
"@opencode
|
|
39
|
+
"@opencode/plugin": "2.0.15",
|
|
40
|
+
"@opencode/sdk": "2.0.15",
|
|
41
|
+
"@opencode/theme": "2.0.15",
|
|
41
42
|
"@opentui/core": "^0.5.11",
|
|
42
43
|
"@opentui/solid": "^0.5.11",
|
|
43
44
|
"@types/node": "^26.5.0",
|
|
@@ -52,12 +53,12 @@
|
|
|
52
53
|
"zod": "^4.5.4"
|
|
53
54
|
},
|
|
54
55
|
"peerDependencies": {
|
|
55
|
-
"@opencode
|
|
56
|
+
"@opencode/plugin": "^2.0.15",
|
|
56
57
|
"solid-js": "^1.9.12",
|
|
57
58
|
"zod": "^4.5.4"
|
|
58
59
|
},
|
|
59
60
|
"peerDependenciesMeta": {
|
|
60
|
-
"@opencode
|
|
61
|
+
"@opencode/plugin": {
|
|
61
62
|
"optional": true
|
|
62
63
|
}
|
|
63
64
|
},
|
package/src/collapsible.tsx
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// count, always toggleable.
|
|
13
13
|
import { createSignal, Show } from "solid-js"
|
|
14
14
|
import type { JSX } from "solid-js"
|
|
15
|
-
import { usePlugin } from "@opencode
|
|
15
|
+
import { usePlugin } from "@opencode/plugin/tui"
|
|
16
16
|
|
|
17
17
|
/** The top-level collapsible sidebar section. */
|
|
18
18
|
export function CollapsibleSection(props: {
|
|
@@ -37,13 +37,13 @@ export function CollapsibleSection(props: {
|
|
|
37
37
|
<box flexDirection="column">
|
|
38
38
|
<box flexDirection="row" gap={1} onMouseDown={() => expandable() && setExpanded((e) => !e)}>
|
|
39
39
|
<Show when={expandable()}>
|
|
40
|
-
<text fg={theme.text.
|
|
40
|
+
<text fg={theme.text.base}>{expanded() ? "▼" : "▶"}</text>
|
|
41
41
|
</Show>
|
|
42
|
-
<text fg={theme.text.
|
|
42
|
+
<text fg={theme.text.base}>
|
|
43
43
|
<b>{props.title}</b>
|
|
44
44
|
</text>
|
|
45
45
|
<Show when={!expanded()}>
|
|
46
|
-
<text fg={theme.text.
|
|
46
|
+
<text fg={theme.text.muted}> ({props.summary ?? count()})</text>
|
|
47
47
|
</Show>
|
|
48
48
|
</box>
|
|
49
49
|
{props.pinned}
|
|
@@ -69,8 +69,8 @@ export function CollapsibleGroup(props: {
|
|
|
69
69
|
return (
|
|
70
70
|
<box flexDirection="column">
|
|
71
71
|
<box flexDirection="row" gap={1} onMouseDown={() => setCollapsed((c) => !c)}>
|
|
72
|
-
<text fg={theme.text.
|
|
73
|
-
<text fg={theme.text.
|
|
72
|
+
<text fg={theme.text.muted}>{collapsed() ? "▸" : "▾"}</text>
|
|
73
|
+
<text fg={theme.text.muted}>
|
|
74
74
|
{props.title} ({props.count})
|
|
75
75
|
</text>
|
|
76
76
|
</box>
|
package/src/host.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Narrow host-contract types for opencode-plugin-kit.
|
|
2
2
|
//
|
|
3
|
-
// `@opencode
|
|
3
|
+
// `@opencode/plugin` (v2) types ARE the spec. Kit consumes only a
|
|
4
4
|
// small slice of the host context, so instead of accepting `any` everywhere
|
|
5
5
|
// (which silently survives host upgrades and breaks at runtime), this module
|
|
6
6
|
// defines the *structural minimum* kit needs and re-exports the exact host
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// Host types kit re-exports (single import point for consumers)
|
|
15
15
|
// ---------------------------------------------------------------------------
|
|
16
16
|
|
|
17
|
-
/** Toast payload —
|
|
17
|
+
/** Toast payload — structural subset of the host's v2 `ToastOptions`; drift breaks CI. */
|
|
18
18
|
export type ToastInput = {
|
|
19
19
|
message: string
|
|
20
20
|
variant?: "success" | "error" | "warning" | "info"
|
|
@@ -32,10 +32,10 @@ export type SelectOption<Value = string> = {
|
|
|
32
32
|
// Structural minimum of the host context kit touches
|
|
33
33
|
// ---------------------------------------------------------------------------
|
|
34
34
|
|
|
35
|
-
/** `context.storage.store(key, { initial })` —
|
|
36
|
-
* `[
|
|
37
|
-
*
|
|
38
|
-
* Both shapes satisfy this union; kit
|
|
35
|
+
/** `context.storage.store(key, { initial })` — v2 types this as
|
|
36
|
+
* `readonly [Store<T>, (mutation: (draft: T) => void) => Promise<void>]`;
|
|
37
|
+
* the older host returned `[T, T]` (mutating the second entry persisted).
|
|
38
|
+
* Both shapes satisfy this union; kit reads `[0]` and mutates it, which
|
|
39
39
|
* both hosts persist. */
|
|
40
40
|
export interface KitStorage {
|
|
41
41
|
store<T extends object>(
|
|
@@ -60,7 +60,7 @@ export interface KitUI {
|
|
|
60
60
|
alert(input: { title: string; message: string }): Promise<unknown>
|
|
61
61
|
select(input: {
|
|
62
62
|
title: string
|
|
63
|
-
|
|
63
|
+
placeholder?: string
|
|
64
64
|
current?: string
|
|
65
65
|
options: readonly SelectOption[]
|
|
66
66
|
}): Promise<unknown>
|
|
@@ -75,9 +75,9 @@ export interface KitCommandEntry {
|
|
|
75
75
|
readonly title: string
|
|
76
76
|
readonly description: string
|
|
77
77
|
readonly group: string
|
|
78
|
-
readonly palette?:
|
|
78
|
+
readonly palette?: true
|
|
79
79
|
readonly suggested?: boolean
|
|
80
|
-
readonly slash?: { name: string; aliases?: string[]; arguments?:
|
|
80
|
+
readonly slash?: { name: string; aliases?: string[]; arguments?: true }
|
|
81
81
|
run: (input?: string) => void
|
|
82
82
|
}
|
|
83
83
|
|
|
@@ -102,7 +102,7 @@ export interface KitClient {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
/** The structural minimum context for every kit factory. Deliberately
|
|
105
|
-
* narrower than the host `
|
|
105
|
+
* narrower than the host `Plugin.Context`: kit only reads these surfaces, so
|
|
106
106
|
* consumers can pass their real context directly. */
|
|
107
107
|
export interface KitContext {
|
|
108
108
|
readonly storage: KitStorage
|
|
@@ -117,9 +117,20 @@ export interface KitContext {
|
|
|
117
117
|
// Structural message/model shapes the defensive readers walk
|
|
118
118
|
// ---------------------------------------------------------------------------
|
|
119
119
|
|
|
120
|
-
/** A
|
|
121
|
-
* `
|
|
122
|
-
*
|
|
120
|
+
/** A model-list entry (host `ModelInfo`) — what the model accessors accept.
|
|
121
|
+
* Deliberately omits `cost`, which is a number on messages but an array on
|
|
122
|
+
* model entries, so the same accessors serve both. */
|
|
123
|
+
export interface KitModelShape {
|
|
124
|
+
readonly id?: string
|
|
125
|
+
readonly modelID?: string
|
|
126
|
+
readonly providerID?: string
|
|
127
|
+
readonly name?: string
|
|
128
|
+
readonly model?: { readonly providerID?: string; readonly modelID?: string; readonly id?: string }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A message in either of the two v2 shapes: a discriminated `type`-tagged
|
|
132
|
+
* object or `{ info: Message }` envelope. Kit's readers (`unwrap`,
|
|
133
|
+
* `isAssistant`, `providerId`, `modelId`) accept both. */
|
|
123
134
|
export interface KitMessageShape {
|
|
124
135
|
readonly type?: string
|
|
125
136
|
readonly role?: string
|
package/src/providers.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// Shared provider vocabulary for OpenCode sidebar widgets.
|
|
2
2
|
// Provider IDs are the OpenCode workspace provider ids ("opencode" = Zen,
|
|
3
3
|
// "opencode-go" = Go).
|
|
4
|
-
import { readFileSync } from "node:fs"
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
5
5
|
import { homedir } from "node:os"
|
|
6
6
|
import { join } from "node:path"
|
|
7
|
+
import { createRequire } from "node:module"
|
|
7
8
|
import { createSignal } from "solid-js"
|
|
8
9
|
import { connectedProviderIds } from "./schemas.ts"
|
|
9
|
-
import type { KitContext, KitMessageShape } from "./host.ts"
|
|
10
|
+
import type { KitContext, KitMessageShape, KitModelShape } from "./host.ts"
|
|
10
11
|
|
|
11
12
|
export const ZEN_PROVIDER = "opencode"
|
|
12
13
|
export const GO_PROVIDER = "opencode-go"
|
|
@@ -52,17 +53,17 @@ export function asArray<T = any>(out: unknown): T[] {
|
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
/** Model id from either a model-list object or a message/message-part shape. */
|
|
55
|
-
export function modelId(m:
|
|
56
|
+
export function modelId(m: KitModelShape): string {
|
|
56
57
|
return m?.model?.modelID ?? m?.modelID ?? m?.model?.id ?? m?.id ?? ""
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
/** Provider id from either a model-list object or a message/message-part shape. */
|
|
60
|
-
export function providerId(m:
|
|
61
|
+
export function providerId(m: KitModelShape): string {
|
|
61
62
|
return m?.model?.providerID ?? m?.providerID ?? ""
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
/** Display name, falling back to the model id when the API omits `name`. */
|
|
65
|
-
export function modelName(m:
|
|
66
|
+
export function modelName(m: KitModelShape): string {
|
|
66
67
|
return m?.name ?? modelId(m)
|
|
67
68
|
}
|
|
68
69
|
|
|
@@ -71,21 +72,85 @@ export function modelName(m: KitMessageShape): string {
|
|
|
71
72
|
// ---------------------------------------------------------------------------
|
|
72
73
|
|
|
73
74
|
const AUTH_PATH = () => join(homedir(), ".local/share/opencode/auth.json")
|
|
75
|
+
const DB_PATH = () => join(homedir(), ".local/share/opencode/opencode.db")
|
|
74
76
|
|
|
75
|
-
/**
|
|
76
|
-
|
|
77
|
+
/** Run a query with either SQLite driver's API and return rows. */
|
|
78
|
+
function queryRows(db: any, sql: string): any[] {
|
|
79
|
+
try {
|
|
80
|
+
return db.query(sql).all()
|
|
81
|
+
} catch {}
|
|
82
|
+
try {
|
|
83
|
+
return db.prepare(sql).all()
|
|
84
|
+
} catch {}
|
|
85
|
+
return []
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Open OpenCode 2's SQLite store read-only, resolving the driver across
|
|
89
|
+
* Bun (`bun:sqlite`) and Node 22+ (`node:sqlite`). Returns null when the
|
|
90
|
+
* store is absent or no driver is available. */
|
|
91
|
+
function openCredentialDb(): any | null {
|
|
92
|
+
if (!existsSync(DB_PATH())) return null
|
|
93
|
+
const attempts: Array<() => any> = [
|
|
94
|
+
() => (import.meta as any).require?.("bun:sqlite"),
|
|
95
|
+
() => (globalThis as any).require?.("bun:sqlite"),
|
|
96
|
+
() => createRequire(import.meta.url)("bun:sqlite"),
|
|
97
|
+
() => (Function("return require")() as any)("bun:sqlite"),
|
|
98
|
+
]
|
|
99
|
+
for (const attempt of attempts) {
|
|
100
|
+
try {
|
|
101
|
+
const { Database } = attempt()
|
|
102
|
+
if (Database) return new Database(DB_PATH(), { readonly: true })
|
|
103
|
+
} catch {}
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite")
|
|
107
|
+
if (DatabaseSync) return new DatabaseSync(DB_PATH(), { readOnly: true })
|
|
108
|
+
} catch {}
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let _dbCache: { at: number; keys: Record<string, string> } | null = null
|
|
113
|
+
|
|
114
|
+
/** API keys from OpenCode 2's SQLite `credential` table (integration_id →
|
|
115
|
+
* key). Cached briefly because `readAuth` runs on every render. */
|
|
116
|
+
function readAuthDb(): Record<string, string> {
|
|
117
|
+
const now = Date.now()
|
|
118
|
+
if (_dbCache && now - _dbCache.at < 30_000) return _dbCache.keys
|
|
119
|
+
const keys: Record<string, string> = {}
|
|
120
|
+
try {
|
|
121
|
+
const db = openCredentialDb()
|
|
122
|
+
if (db) {
|
|
123
|
+
for (const row of queryRows(db, "SELECT integration_id, value FROM credential")) {
|
|
124
|
+
const id = String(row?.integration_id ?? "").trim()
|
|
125
|
+
try {
|
|
126
|
+
const key = JSON.parse(String(row?.value ?? ""))?.key
|
|
127
|
+
if (id && typeof key === "string" && key.trim()) keys[id] = key.trim()
|
|
128
|
+
} catch {}
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
db.close?.()
|
|
132
|
+
} catch {}
|
|
133
|
+
}
|
|
134
|
+
} catch {}
|
|
135
|
+
_dbCache = { at: now, keys }
|
|
136
|
+
return keys
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Single defensive read of the auth sources, keyed by provider id.
|
|
140
|
+
* OpenCode 2 keeps credentials in its SQLite store; `auth.json` is the
|
|
141
|
+
* legacy V1 store. Both are read (SQLite wins on conflict) so consumers see
|
|
142
|
+
* the same keys regardless of OpenCode version. */
|
|
77
143
|
export function readAuth(): Record<string, string> {
|
|
144
|
+
const out: Record<string, string> = {}
|
|
78
145
|
try {
|
|
79
146
|
const auth = JSON.parse(readFileSync(AUTH_PATH(), "utf8"))
|
|
80
|
-
const out: Record<string, string> = {}
|
|
81
147
|
for (const [id, cfg] of Object.entries(auth)) {
|
|
82
148
|
const key = (cfg as any)?.key
|
|
83
149
|
if (typeof key === "string" && key.trim()) out[id] = key.trim()
|
|
84
150
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
151
|
+
} catch {}
|
|
152
|
+
for (const [id, key] of Object.entries(readAuthDb())) out[id] = key
|
|
153
|
+
return out
|
|
89
154
|
}
|
|
90
155
|
|
|
91
156
|
/** The API key for one provider, or "" when absent. */
|
package/src/viewPicker.ts
CHANGED
|
@@ -131,7 +131,7 @@ export function createViewPicker<T extends PickerOption>(context: KitContext, co
|
|
|
131
131
|
try {
|
|
132
132
|
const selected = await context.ui.dialog.select({
|
|
133
133
|
title: config.dialog.title,
|
|
134
|
-
|
|
134
|
+
placeholder: config.dialog.message,
|
|
135
135
|
current: currentID(),
|
|
136
136
|
options: registry.filter(selectable).map((e) => ({
|
|
137
137
|
title: e.title,
|