opencode-plugin-kit 1.0.0-alpha.1

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/src/schemas.ts ADDED
@@ -0,0 +1,70 @@
1
+ // Zod schemas for the untrusted shapes crossing the OpenCode boundary.
2
+ //
3
+ // These are the shapes that silently change under us: the service's
4
+ // integration list (connected-provider gating) and the usage endpoint's
5
+ // quota windows. Parse, degrade to last-known-good on failure — never cast.
6
+ // See docs/opencode2-api.md for where each shape comes from.
7
+ import { z } from "zod"
8
+
9
+ /** One window of plan quota: server-reported percent + reset time. */
10
+ export const windowSchema = z.object({
11
+ status: z.string().optional(),
12
+ percent: z.number().optional(),
13
+ resetsAt: z.string().optional(),
14
+ })
15
+ export type Window = z.infer<typeof windowSchema>
16
+
17
+ /** The usage endpoint (`https://opencode.ai/zen/go/v1/usage`): workspace
18
+ * plan quota over rolling 5h / weekly / monthly windows. */
19
+ export const usageResponseSchema = z.object({
20
+ usage: z
21
+ .object({
22
+ rolling: windowSchema.optional(),
23
+ weekly: windowSchema.optional(),
24
+ monthly: windowSchema.optional(),
25
+ })
26
+ .optional(),
27
+ })
28
+ export type UsageResponse = z.infer<typeof usageResponseSchema>
29
+
30
+ /** One credential added via /connect (key or OAuth). */
31
+ export const connectionSchema = z.object({
32
+ type: z.string().optional(),
33
+ id: z.string().optional(),
34
+ label: z.string().optional(),
35
+ })
36
+
37
+ /** An entry of `GET /api/integration` (same source /connect reads). An
38
+ * entry is "connected" iff its `connections` array is non-empty. */
39
+ export const integrationSchema = z.object({
40
+ id: z.string(),
41
+ name: z.string().optional(),
42
+ connections: z.array(connectionSchema).optional(),
43
+ })
44
+ export type Integration = z.infer<typeof integrationSchema>
45
+
46
+ /** Parse a usage-endpoint payload. Returns null instead of throwing when
47
+ * the shape is unrecognized — callers fall back to their cache. */
48
+ export function parseUsage(raw: unknown): UsageResponse | null {
49
+ const result = usageResponseSchema.safeParse(raw)
50
+ return result.success ? result.data : null
51
+ }
52
+
53
+ /** Parse the integration list. Accepts both the wrapped `{ data: [...] }`
54
+ * and bare-array responses. */
55
+ export function parseIntegrationList(raw: unknown): Integration[] | null {
56
+ const result = z.union([z.object({ data: z.array(integrationSchema) }), z.array(integrationSchema)]).safeParse(raw)
57
+ if (!result.success) return null
58
+ return "data" in result.data ? result.data.data : result.data
59
+ }
60
+
61
+ /** Provider ids with at least one added connection — the eligibility set
62
+ * for provider-gated views. */
63
+ export function connectedProviderIds(raw: unknown): Set<string> {
64
+ const list = parseIntegrationList(raw)
65
+ const ids = new Set<string>()
66
+ for (const item of list ?? []) {
67
+ if ((item.connections?.length ?? 0) > 0 && item.id) ids.add(item.id)
68
+ }
69
+ return ids
70
+ }
package/src/toast.ts ADDED
@@ -0,0 +1,21 @@
1
+ // Fire-and-forget toast helper.
2
+ //
3
+ // The host's toast UI is optional at the edges (beta API): every plugin
4
+ // hand-rolled the same try/catch fallback around `ui.toast.show`.
5
+ import type { KitContext, KitUI, ToastInput } from "./host.ts"
6
+
7
+ /** Show a toast; silently no-ops when the host has no toast UI or it throws.
8
+ * Takes a minimal structural slice — the full host context's `ui.slot` is
9
+ * over-loaded in newer betas and would not assign to KitContext here. */
10
+ export function showToast(
11
+ context: { readonly ui?: { readonly toast?: { readonly show?: (input: ToastInput) => void } } },
12
+ message: string,
13
+ variant: ToastInput["variant"] = "success",
14
+ ): void {
15
+ try {
16
+ const toast = (context.ui as Partial<KitUI>).toast
17
+ toast?.show?.({ message, variant })
18
+ } catch {
19
+ // Toast unavailable; the caller's inline UI still reflects the change.
20
+ }
21
+ }
@@ -0,0 +1,172 @@
1
+ // Persisted view/filter picker for OpenCode sidebar widgets.
2
+ //
3
+ // The pattern shared by the usage-quota tracker (`/usage-view`) and the model
4
+ // recommender (`/model-view`): a registry of views, a durable selection, a
5
+ // slash/palette command registered through a keymap layer (which only works
6
+ // from a rendered `app` slot), a dialog picker, and a confirmation toast.
7
+ import type { KitContext } from "./host.ts"
8
+ import { registerKeymapCommand } from "./commands.ts"
9
+ import { persistedCell } from "./cache.ts"
10
+ import { createSignal } from "solid-js"
11
+
12
+ export interface PickerOption {
13
+ readonly id: string
14
+ readonly title: string
15
+ readonly description?: string
16
+ }
17
+
18
+ export interface PickerConfig<T extends PickerOption> {
19
+ /** The view registry — the single extension point. */
20
+ readonly registry: readonly T[]
21
+ /** Durable storage key (plugin-scoped) for the selection. */
22
+ readonly storageKey: string
23
+ /** Slash/palette command wiring. `title` may ignore its argument to show a
24
+ * session-derived effective view (the usage plugin does this). */
25
+ readonly command: {
26
+ readonly id: string
27
+ readonly group: string
28
+ /** Slash name, e.g. "usage-view". */
29
+ readonly name: string
30
+ readonly aliases?: string[]
31
+ readonly title: (current: T) => string
32
+ readonly description: string
33
+ }
34
+ /** Dialog texts for the interactive picker. */
35
+ readonly dialog: { readonly title: string; readonly message: string }
36
+ /** Toast message on switch; defaults to `${prefix}: ${entry.title} view`. */
37
+ readonly toastPrefix?: string
38
+ /** Only these entries are selectable (e.g. provider-key gating). Defaults
39
+ * to everything. Read at pick time, so closures see fresh state. */
40
+ readonly selectable?: (entry: T) => boolean
41
+ /** Message when an explicitly requested entry isn't selectable. */
42
+ readonly unavailableMessage?: (entry: T) => string
43
+ }
44
+
45
+ export interface ViewPicker<T extends PickerOption> {
46
+ /** Currently selected entry (persisted pick; auto-pick logic stays
47
+ * plugin-local and can layer on top). */
48
+ readonly current: () => T
49
+ readonly currentID: () => string
50
+ /** Switch without UI: persist + toast. No-op when already selected. */
51
+ readonly apply: (entry: T) => void
52
+ /** Open the picker, or select directly from an argument (`/x zen`). */
53
+ readonly pick: (arg?: string) => Promise<void>
54
+ /** Register the slash/palette command (call once from setup). */
55
+ readonly registerCommand: () => void
56
+ }
57
+
58
+ export function createViewPicker<T extends PickerOption>(context: KitContext, config: PickerConfig<T>): ViewPicker<T> {
59
+ const registry = config.registry
60
+ const first = registry[0]
61
+ if (!first) throw new Error("createViewPicker: empty registry")
62
+
63
+ const [currentID, setCurrentID] = createSignal<string>(first.id)
64
+
65
+ type StoredView = { id?: string }
66
+ const cell = persistedCell<StoredView>(context, config.storageKey, { id: first.id })
67
+
68
+ // Load the persisted pick, if any.
69
+ try {
70
+ const persisted = cell.read()
71
+ if (persisted?.id && registry.some((e) => e.id === persisted.id)) setCurrentID(persisted.id)
72
+ } catch {
73
+ // In-memory only.
74
+ }
75
+
76
+ const persist = (id: string) => {
77
+ cell.persist((s) => {
78
+ s.id = id
79
+ })
80
+ }
81
+
82
+ const current = () => registry.find((e) => e.id === currentID()) ?? first
83
+
84
+ const apply = (entry: T) => {
85
+ if (entry.id === currentID()) return
86
+ setCurrentID(entry.id)
87
+ persist(entry.id)
88
+ try {
89
+ context.ui.toast.show({
90
+ message: config.toastPrefix ? `${config.toastPrefix}: ${entry.title} view` : entry.title,
91
+ variant: "success",
92
+ })
93
+ } catch {
94
+ // Toast unavailable; reactivity still re-renders.
95
+ }
96
+ }
97
+
98
+ const selectable = config.selectable ?? (() => true)
99
+
100
+ const pick = async (arg?: string) => {
101
+ const wanted = arg?.trim().toLowerCase()
102
+ if (wanted) {
103
+ const match = registry.find((e) => e.id === wanted || e.title.toLowerCase() === wanted)
104
+ if (match) {
105
+ if (selectable(match)) return apply(match)
106
+ try {
107
+ await context.ui.dialog.alert({
108
+ title: config.dialog.title,
109
+ message: config.unavailableMessage?.(match) ?? `${match.title} is not available right now.`,
110
+ })
111
+ } catch {
112
+ // Dialog unavailable.
113
+ }
114
+ return
115
+ }
116
+ try {
117
+ await context.ui.dialog.alert({
118
+ title: config.dialog.title,
119
+ message: `Unknown view "${arg}". Available: ${
120
+ registry
121
+ .filter(selectable)
122
+ .map((e) => e.id)
123
+ .join(", ") || "none"
124
+ }`,
125
+ })
126
+ } catch {
127
+ // Dialog unavailable.
128
+ }
129
+ return
130
+ }
131
+ try {
132
+ const selected = await context.ui.dialog.select({
133
+ title: config.dialog.title,
134
+ message: config.dialog.message,
135
+ current: currentID(),
136
+ options: registry.filter(selectable).map((e) => ({
137
+ title: e.title,
138
+ value: e.id,
139
+ description: e.description ?? "",
140
+ disabled: false,
141
+ })),
142
+ })
143
+ const match = registry.find((e) => e.id === selected)
144
+ if (match) apply(match)
145
+ } catch {
146
+ // Dialog unavailable.
147
+ }
148
+ }
149
+
150
+ // Keymap layers are owned by the calling component, so the command is
151
+ // registered from a rendered `app` slot (see registerKeymapCommand). The
152
+ // factory form keeps the title fresh across palette renders.
153
+ const registerCommand = () =>
154
+ registerKeymapCommand(context, () => ({
155
+ id: config.command.id,
156
+ title: config.command.title(current()),
157
+ description: config.command.description,
158
+ group: config.command.group,
159
+ palette: true,
160
+ slash: {
161
+ name: config.command.name,
162
+ aliases: config.command.aliases,
163
+ arguments: true,
164
+ },
165
+ suggested: true,
166
+ run: (input?: string) => {
167
+ void pick(input)
168
+ },
169
+ }))
170
+
171
+ return { current, currentID, apply, pick, registerCommand }
172
+ }
@@ -0,0 +1,21 @@
1
+ // Workspace location resolution shared by the TUI plugins.
2
+ //
3
+ // The client may expose the workspace location directly (`context.location`)
4
+ // or behind `data.location.default()`, and the directory may live on
5
+ // `.directory` or be the location value itself. The plugin-manager and the
6
+ // skill lister each hand-rolled this dance.
7
+
8
+ /**
9
+ * Resolve the workspace location object for a plugin context. Returns
10
+ * whatever the client exposes — callers that need a path should prefer
11
+ * `workspaceDirectory`.
12
+ */
13
+ export function resolveLocation(context: any): any {
14
+ return context.location ?? context.data.location.default()
15
+ }
16
+
17
+ /** Resolve the workspace directory as a string ("." when nothing resolves). */
18
+ export function workspaceDirectory(context: any): string {
19
+ const location = resolveLocation(context)
20
+ return String(location?.directory ?? location ?? ".")
21
+ }