@encoredigitalgroup/opencode 0.1.3 → 0.2.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/README.md CHANGED
@@ -3,11 +3,11 @@
3
3
  `@encoredigitalgroup/opencode` adds file pickers for OpenCode planning commands.
4
4
 
5
5
  The TUI provides `/plan-review`, `/plan-resync`, `/plan-split`, and `/plan-execute` commands. Each command opens a picker for plans in `docs/_planning` and inserts the
6
- selected plan into the prompt. The server entry expands the selected plan into the matching installed skill prompt.
6
+ selected plan into the prompt. The server entry attaches the matching installed skill to the prompt.
7
7
 
8
8
  ## Install
9
9
 
10
- Add the package to the `plugin` array in your OpenCode configuration:
10
+ OpenCode v2 loads this package from the `plugins` array. Add an object entry with the package name and its options:
11
11
 
12
12
  - Use `~/.config/opencode/opencode.json` to enable it for every project.
13
13
  - Use `opencode.json` in a project to enable it only there.
@@ -15,38 +15,45 @@ Add the package to the `plugin` array in your OpenCode configuration:
15
15
  ```json
16
16
  {
17
17
  "$schema": "https://opencode.ai/config.json",
18
- "plugin": ["@encoredigitalgroup/opencode"]
18
+ "plugins": [
19
+ {
20
+ "package": "@encoredigitalgroup/opencode",
21
+ "options": {}
22
+ }
23
+ ]
19
24
  }
20
25
  ```
21
26
 
22
- Restart OpenCode after changing the configuration. OpenCode downloads missing npm plugins that appear in the `plugin` array, so you do not need to install this package
23
- with Bun or npm yourself.
27
+ Restart OpenCode after changing the configuration. OpenCode downloads missing npm plugins that appear in the `plugins` array, so you do not need to install this package
28
+ with Bun or npm yourself. This package supports OpenCode v2 only; replace the legacy `plugin` configuration entry when migrating from OpenCode v1.
24
29
 
25
30
  The plugin resolves the mapped skills through OpenCode, so those skills can live in any location that OpenCode discovers.
26
31
 
27
32
  ## Configuration
28
33
 
29
- Pass options as the second item in the `plugin` entry. `planDirectory` is relative to the workspace. `skillMappings` can override any plugin command's installed skill name.
30
- Unspecified values use the defaults: `docs/_planning` and the command's existing skill name.
34
+ Set `planDirectory` and `skillMappings` in the object's `options` field. `planDirectory` is a non-empty path relative to the workspace. `skillMappings` can override any
35
+ supported command's installed skill name. Unspecified values use the defaults: `docs/_planning` and the command's existing skill name.
31
36
 
32
37
  ```json
33
38
  {
34
39
  "$schema": "https://opencode.ai/config.json",
35
- "plugin": [
36
- [
37
- "@encoredigitalgroup/opencode",
38
- {
40
+ "plugins": [
41
+ {
42
+ "package": "@encoredigitalgroup/opencode",
43
+ "options": {
39
44
  "planDirectory": "plans",
40
45
  "skillMappings": {
41
46
  "plan-review": "custom-plan-review",
42
47
  "plan-execute": "custom-plan-execute"
43
48
  }
44
49
  }
45
- ]
50
+ }
46
51
  ]
47
52
  }
48
53
  ```
49
54
 
55
+ Launching a picker from an existing session submits to that session. Launching one from the home screen creates and displays a new session before submitting the selected plan.
56
+
50
57
  ## Development
51
58
 
52
59
  ```sh
@@ -1,4 +1,4 @@
1
- import type { PluginOptions } from "@opencode-ai/plugin";
1
+ import type { PluginOptions } from "@opencode/plugin";
2
2
  import { type PluginConfig } from "./ConfigTypes.js";
3
3
  /** Loads plugin settings from the OpenCode plugin options. */
4
4
  export declare class ConfigLoader {
@@ -1,4 +1,4 @@
1
- import type { TuiPluginApi, TuiPromptRef } from "@opencode-ai/plugin/tui";
1
+ import type { Plugin } from "@opencode/plugin/tui";
2
2
  import type { PluginConfig } from "../config";
3
3
  export type FilePickerOption = {
4
4
  argument: string;
@@ -6,14 +6,12 @@ export type FilePickerOption = {
6
6
  label: string;
7
7
  };
8
8
  export type FilePickerCommand = {
9
- argumentPlaceholder: string;
10
9
  command: string;
11
10
  description: string;
12
11
  emptyMessage: string;
13
12
  excludesPlanningDirectory?(directory: string): boolean;
14
13
  matchesSelection(argument: string, filePath: string): boolean;
15
14
  options(root: string, excludesPlanningDirectory?: (directory: string) => boolean): FilePickerOption[];
16
- retainsSelectedFileInPrompt: boolean;
17
15
  skill: string;
18
16
  title: string;
19
17
  };
@@ -24,13 +22,8 @@ export declare class FilePickerCommands {
24
22
  private static isSubPlanSelection;
25
23
  private static subPlanOptions;
26
24
  static getCommands(config: PluginConfig): readonly FilePickerCommand[];
27
- private static findSkill;
28
- private static removeSelectedFile;
29
- private static expandedSkill;
30
- static expand(output: {
31
- parts: unknown[];
32
- }, template: (skill: string) => Promise<string>, config: PluginConfig): Promise<void>;
33
- private static workspaceRoot;
34
- private static setPrompt;
35
- static register(api: TuiPluginApi, prompt: () => TuiPromptRef | undefined, config: PluginConfig): void;
25
+ private static toast;
26
+ private static submit;
27
+ private static run;
28
+ static register(context: Plugin.Context, config: PluginConfig): void;
36
29
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,14 +1,7 @@
1
1
  import type { PluginConfig } from "../config";
2
2
  export declare class SkillCommands {
3
- static getCommands(config: PluginConfig): {
4
- "feature-planning": {
5
- description: string;
6
- skill: string;
7
- };
8
- };
9
- static commandTemplate(skillName: string): string;
10
- static getTemplate(serverUrl: URL, directory: string, skillName: string): Promise<string>;
11
- static expandCommandTemplate(output: {
12
- parts: unknown[];
13
- }, template: (skillName: string) => Promise<string>): Promise<void>;
3
+ static getCommands(config: PluginConfig): Record<"feature-planning", {
4
+ description: string;
5
+ skill: string;
6
+ }>;
14
7
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,3 +1,3 @@
1
- import type { PluginModule } from "@opencode-ai/plugin";
2
- declare const plugin: PluginModule;
3
- export default plugin;
1
+ import { Plugin } from "@opencode/plugin";
2
+ declare const _default: Plugin.Plugin;
3
+ export default _default;
@@ -1,4 +1,7 @@
1
1
  // @bun
2
+ // src/server/index.ts
3
+ import { Plugin } from "@opencode/plugin";
4
+
2
5
  // src/config/ConfigDefaults.ts
3
6
  class ConfigDefaults {
4
7
  static getDefaultSkillMappings() {
@@ -113,292 +116,7 @@ class ConfigLoader {
113
116
  }
114
117
  }
115
118
  }
116
- // src/lib/FilePickerCommands.ts
117
- import { createComponent } from "@opentui/solid";
118
- import { readdirSync, statSync } from "fs";
119
- import { join, resolve } from "path";
120
- import { pathToFileURL } from "url";
121
-
122
- class FilePickerCommands {
123
- static isPlanPath(path, planDirectory) {
124
- return path.startsWith(`${planDirectory}/`) && path.endsWith("/plan.md") && !path.slice(planDirectory.length + 1, -"/plan.md".length).includes("/") && !/\s/.test(path.slice(planDirectory.length + 1, -"/plan.md".length));
125
- }
126
- static planOptions(root, planDirectory, excludesPlanningDirectory = () => false) {
127
- const planningDirectory = join(root, planDirectory);
128
- try {
129
- return readdirSync(planningDirectory, { withFileTypes: true }).filter((entry) => {
130
- if (!entry.isDirectory()) {
131
- return false;
132
- }
133
- if (excludesPlanningDirectory(join(planningDirectory, entry.name))) {
134
- return false;
135
- }
136
- try {
137
- return statSync(join(planningDirectory, entry.name, "plan.md")).isFile();
138
- } catch {
139
- return false;
140
- }
141
- }).map((entry) => ({
142
- argument: `${planDirectory}/${entry.name}/plan.md`,
143
- filePath: `${planDirectory}/${entry.name}/plan.md`,
144
- label: entry.name
145
- })).sort((left, right) => left.label.localeCompare(right.label));
146
- } catch {
147
- return [];
148
- }
149
- }
150
- static hasSubPlans(directory) {
151
- try {
152
- return readdirSync(directory, { withFileTypes: true }).some((entry) => entry.isFile() && /^\d{2}-.+\.md$/.test(entry.name));
153
- } catch {
154
- return false;
155
- }
156
- }
157
- static isSubPlanSelection(argument, filePath, planDirectory) {
158
- return argument.startsWith(`${planDirectory}/`) && !argument.slice(planDirectory.length + 1).includes("/") && !/\s/.test(argument.slice(planDirectory.length + 1)) && filePath.startsWith(`${argument}/`) && filePath !== `${argument}/plan.md` && filePath.endsWith(".md");
159
- }
160
- static subPlanOptions(root, planDirectory) {
161
- const planningDirectory = join(root, planDirectory);
162
- try {
163
- return readdirSync(planningDirectory, { withFileTypes: true }).filter((entry) => entry.isDirectory()).flatMap((entry) => {
164
- const directory = join(planningDirectory, entry.name);
165
- try {
166
- const subPlan = readdirSync(directory, { withFileTypes: true }).filter((file) => file.isFile() && file.name !== "plan.md" && file.name.endsWith(".md")).map((file) => file.name).sort((left, right) => left.localeCompare(right))[0];
167
- return subPlan === undefined ? [] : [{
168
- argument: `${planDirectory}/${entry.name}`,
169
- filePath: `${planDirectory}/${entry.name}/${subPlan}`,
170
- label: entry.name
171
- }];
172
- } catch {
173
- return [];
174
- }
175
- }).sort((left, right) => left.label.localeCompare(right.label));
176
- } catch {
177
- return [];
178
- }
179
- }
180
- static getCommands(config) {
181
- const { planDirectory, skillMappings } = config;
182
- const noPlansMessage = `No plan.md files found in ${planDirectory}.`;
183
- const noSubPlansMessage = `No sub-plan files found in ${planDirectory}.`;
184
- return [
185
- {
186
- argumentPlaceholder: "$ARGUMENTS",
187
- command: "plan-review",
188
- description: "Select a plan to review",
189
- emptyMessage: noPlansMessage,
190
- matchesSelection: (argument, filePath) => argument === filePath && FilePickerCommands.isPlanPath(argument, planDirectory),
191
- options: (root, excludesPlanningDirectory) => FilePickerCommands.planOptions(root, planDirectory, excludesPlanningDirectory),
192
- retainsSelectedFileInPrompt: true,
193
- skill: skillMappings["plan-review"],
194
- title: "Select a plan to review"
195
- },
196
- {
197
- argumentPlaceholder: "$ARGUMENTS",
198
- command: "plan-resync",
199
- description: "Select a plan to resync",
200
- emptyMessage: noPlansMessage,
201
- matchesSelection: (argument, filePath) => argument === filePath && FilePickerCommands.isPlanPath(argument, planDirectory),
202
- options: (root, excludesPlanningDirectory) => FilePickerCommands.planOptions(root, planDirectory, excludesPlanningDirectory),
203
- retainsSelectedFileInPrompt: true,
204
- skill: skillMappings["plan-resync"],
205
- title: "Select a plan to resync"
206
- },
207
- {
208
- argumentPlaceholder: "$ARGUMENTS",
209
- command: "plan-split",
210
- description: "Select a plan to split",
211
- emptyMessage: noPlansMessage,
212
- excludesPlanningDirectory: FilePickerCommands.hasSubPlans,
213
- matchesSelection: (argument, filePath) => argument === filePath && FilePickerCommands.isPlanPath(argument, planDirectory),
214
- options: (root, excludesPlanningDirectory) => FilePickerCommands.planOptions(root, planDirectory, excludesPlanningDirectory),
215
- retainsSelectedFileInPrompt: true,
216
- skill: skillMappings["plan-split"],
217
- title: "Select a plan to split"
218
- },
219
- {
220
- argumentPlaceholder: "$PLAN_DIR",
221
- command: "plan-execute",
222
- description: "Select sub-plans to execute",
223
- emptyMessage: noSubPlansMessage,
224
- matchesSelection: (argument, filePath) => FilePickerCommands.isSubPlanSelection(argument, filePath, planDirectory),
225
- options: (root) => FilePickerCommands.subPlanOptions(root, planDirectory),
226
- retainsSelectedFileInPrompt: false,
227
- skill: skillMappings["plan-execute"],
228
- title: "Select sub-plans to execute"
229
- }
230
- ];
231
- }
232
- static findSkill(text, parts, config) {
233
- const filePaths = parts.flatMap((part) => {
234
- if (typeof part !== "object" || part === null || !("type" in part) || part.type !== "file" || !("source" in part)) {
235
- return [];
236
- }
237
- const { source } = part;
238
- return typeof source === "object" && source !== null && "type" in source && source.type === "file" && "path" in source && typeof source.path === "string" ? [source.path] : [];
239
- });
240
- for (const command of FilePickerCommands.getCommands(config)) {
241
- const prefix = `/${command.command} @`;
242
- if (!text.startsWith(prefix)) {
243
- continue;
244
- }
245
- const argument = text.slice(prefix.length).trim();
246
- const filePath = filePaths.find((path) => command.matchesSelection(argument, path));
247
- if (argument !== "" && filePath !== undefined) {
248
- return {
249
- argument,
250
- argumentPlaceholder: command.argumentPlaceholder,
251
- filePath,
252
- retainsSelectedFileInPrompt: command.retainsSelectedFileInPrompt,
253
- skill: command.skill
254
- };
255
- }
256
- }
257
- }
258
- static removeSelectedFile(parts, filePath) {
259
- const selectedFileIndex = parts.findIndex((candidate) => {
260
- if (typeof candidate !== "object" || candidate === null || !("type" in candidate) || candidate.type !== "file" || !("source" in candidate)) {
261
- return false;
262
- }
263
- const { source } = candidate;
264
- return typeof source === "object" && source !== null && "type" in source && source.type === "file" && "path" in source && source.path === filePath;
265
- });
266
- if (selectedFileIndex !== -1) {
267
- parts.splice(selectedFileIndex, 1);
268
- }
269
- }
270
- static expandedSkill(text, parts, commands) {
271
- for (const part of parts) {
272
- if (typeof part !== "object" || part === null || !("type" in part) || part.type !== "file" || !("source" in part)) {
273
- continue;
274
- }
275
- const { source } = part;
276
- if (typeof source !== "object" || source === null || !("type" in source) || source.type !== "file" || !("path" in source) || !("text" in source)) {
277
- continue;
278
- }
279
- const filePath = source.path;
280
- if (typeof filePath !== "string" || typeof source.text !== "object" || source.text === null || !("value" in source.text) || typeof source.text.value !== "string" || !source.text.value.startsWith("@")) {
281
- continue;
282
- }
283
- const argument = source.text.value.slice(1);
284
- const command = commands.find((candidate) => candidate.matchesSelection(argument, filePath) && text.includes(candidate.argumentPlaceholder));
285
- if (command !== undefined) {
286
- return {
287
- argument,
288
- argumentPlaceholder: command.argumentPlaceholder,
289
- filePath,
290
- retainsSelectedFileInPrompt: command.retainsSelectedFileInPrompt,
291
- skill: command.skill
292
- };
293
- }
294
- }
295
- }
296
- static async expand(output, template, config) {
297
- const commands = FilePickerCommands.getCommands(config);
298
- for (const part of output.parts) {
299
- if (typeof part !== "object" || part === null || !("type" in part) || part.type !== "text" || !("text" in part) || typeof part.text !== "string" || "synthetic" in part && part.synthetic) {
300
- continue;
301
- }
302
- const skill = FilePickerCommands.findSkill(part.text, output.parts, config);
303
- if (skill === undefined) {
304
- continue;
305
- }
306
- part.text = (await template(skill.skill)).replaceAll(skill.argumentPlaceholder, `@${skill.argument}`).trim();
307
- if (!skill.retainsSelectedFileInPrompt) {
308
- FilePickerCommands.removeSelectedFile(output.parts, skill.filePath);
309
- }
310
- return;
311
- }
312
- for (const part of output.parts) {
313
- if (typeof part !== "object" || part === null || !("type" in part) || part.type !== "text" || !("text" in part) || typeof part.text !== "string" || "synthetic" in part && part.synthetic) {
314
- continue;
315
- }
316
- const skill = FilePickerCommands.expandedSkill(part.text, output.parts, commands);
317
- if (skill === undefined) {
318
- continue;
319
- }
320
- part.text = part.text.replaceAll(skill.argumentPlaceholder, `@${skill.argument}`);
321
- if (!skill.retainsSelectedFileInPrompt) {
322
- FilePickerCommands.removeSelectedFile(output.parts, skill.filePath);
323
- }
324
- return;
325
- }
326
- }
327
- static workspaceRoot(worktree, directory) {
328
- return worktree === "/" ? directory : worktree;
329
- }
330
- static setPrompt(prompt, root, command, option) {
331
- const mention = `@${option.argument}`;
332
- const input = `/${command.command} ${mention} `;
333
- const start = Bun.stringWidth(`/${command.command} `);
334
- prompt.set({
335
- input,
336
- mode: "normal",
337
- parts: [
338
- {
339
- type: "file",
340
- mime: "text/plain",
341
- filename: option.filePath,
342
- url: pathToFileURL(resolve(root, option.filePath)).href,
343
- source: {
344
- type: "file",
345
- path: option.filePath,
346
- text: {
347
- start,
348
- end: start + Bun.stringWidth(mention),
349
- value: mention
350
- }
351
- }
352
- }
353
- ]
354
- });
355
- prompt.focus();
356
- }
357
- static register(api, prompt, config) {
358
- api.keymap.registerLayer({
359
- commands: FilePickerCommands.getCommands(config).map((command) => ({
360
- name: `encoredigitalgroup-opencode.${command.command}`,
361
- title: command.title,
362
- category: "Plugin",
363
- namespace: "palette",
364
- slashName: command.command,
365
- run() {
366
- const root = FilePickerCommands.workspaceRoot(api.state.path.worktree, api.state.path.directory);
367
- const options = command.options(root, command.excludesPlanningDirectory);
368
- if (options.length === 0) {
369
- api.ui.toast({
370
- variant: "warning",
371
- message: command.emptyMessage
372
- });
373
- return;
374
- }
375
- const DialogSelect = api.ui.DialogSelect;
376
- api.ui.dialog.setSize("medium");
377
- api.ui.dialog.replace(() => createComponent(DialogSelect, {
378
- title: command.title,
379
- options: options.map((option) => ({ title: option.label, value: option })),
380
- onSelect: (option) => {
381
- api.ui.dialog.clear();
382
- const promptRef = prompt();
383
- if (promptRef === undefined) {
384
- api.ui.toast({
385
- variant: "error",
386
- message: "The prompt is not ready."
387
- });
388
- return;
389
- }
390
- FilePickerCommands.setPrompt(promptRef, root, command, option.value);
391
- }
392
- }));
393
- }
394
- }))
395
- });
396
- }
397
- }
398
-
399
119
  // src/lib/SkillCommands.ts
400
- var skillMarker = "<encoredigitalgroup-opencode-skill:";
401
-
402
120
  class SkillCommands {
403
121
  static getCommands(config) {
404
122
  return {
@@ -408,61 +126,37 @@ class SkillCommands {
408
126
  }
409
127
  };
410
128
  }
411
- static commandTemplate(skillName) {
412
- return `${skillMarker}${skillName}>`;
413
- }
414
- static async getTemplate(serverUrl, directory, skillName) {
415
- const url = new URL("/api/skill", serverUrl);
416
- url.searchParams.set("directory", directory);
417
- url.searchParams.set("location[directory]", directory);
418
- const response = await fetch(url);
419
- if (!response.ok) {
420
- throw new Error(`Unable to list OpenCode skills: ${response.status} ${response.statusText}`);
421
- }
422
- const responseBody = await response.json();
423
- const skills = typeof responseBody === "object" && responseBody !== null && "data" in responseBody && Array.isArray(responseBody.data) ? responseBody.data.filter((value) => typeof value === "object" && value !== null && ("content" in value) && typeof value.content === "string" && ("name" in value) && typeof value.name === "string") : [];
424
- const skill = skills.find((candidate) => candidate.name === skillName);
425
- if (skill === undefined) {
426
- throw new Error(`OpenCode skill "${skillName}" is not available.`);
427
- }
428
- return skill.content;
429
- }
430
- static async expandCommandTemplate(output, template) {
431
- for (const part of output.parts) {
432
- if (typeof part !== "object" || part === null || !("type" in part) || part.type !== "text" || !("text" in part) || typeof part.text !== "string" || !part.text.startsWith(skillMarker) || !part.text.endsWith(">")) {
433
- continue;
434
- }
435
- const skillName = part.text.slice(skillMarker.length, -1);
436
- part.text = await template(skillName);
437
- return;
438
- }
439
- }
440
129
  }
441
130
 
442
131
  // src/server/index.ts
443
- var plugin = {
444
- id: "encoredigitalgroup-opencode",
445
- server: async (input, options) => {
446
- const pluginConfig = ConfigLoader.loadConfig(options);
447
- const template = (skill) => SkillCommands.getTemplate(input.serverUrl, input.directory, skill);
448
- return {
449
- config: async (config) => {
450
- config.command ??= {};
451
- for (const [command, route] of Object.entries(SkillCommands.getCommands(pluginConfig))) {
452
- config.command[command] = {
453
- description: route.description,
454
- template: SkillCommands.commandTemplate(route.skill)
455
- };
132
+ var server_default = Plugin.define({
133
+ id: "@encoredigitalgroup/opencode",
134
+ async setup(ctx) {
135
+ const pluginConfig = ConfigLoader.loadConfig(ctx.options);
136
+ const command = "feature-planning";
137
+ const route = SkillCommands.getCommands(pluginConfig)[command];
138
+ const registration = await ctx.command.transform((commands) => {
139
+ commands.add({
140
+ name: command,
141
+ description: route.description,
142
+ execute: async (input) => {
143
+ const skills = await ctx.skill.list();
144
+ const skill = skills.data.find((candidate) => candidate.name === route.skill);
145
+ if (skill === undefined) {
146
+ throw new Error(`OpenCode skill "${route.skill}" is not available for /${command}.`);
147
+ }
148
+ await ctx.session.prompt({
149
+ sessionID: input.sessionID,
150
+ ...input.prompt,
151
+ skills: [...input.prompt.skills ?? [], { id: skill.id }],
152
+ delivery: input.delivery
153
+ });
456
154
  }
457
- },
458
- "chat.message": async (_input, output) => {
459
- await SkillCommands.expandCommandTemplate(output, template);
460
- await FilePickerCommands.expand(output, template, pluginConfig);
461
- }
462
- };
155
+ });
156
+ });
157
+ return registration.dispose;
463
158
  }
464
- };
465
- var server_default = plugin;
159
+ });
466
160
  export {
467
161
  server_default as default
468
162
  };
@@ -0,0 +1 @@
1
+ export {};
@@ -1,3 +1,3 @@
1
- import type { TuiPluginModule } from "@opencode-ai/plugin/tui";
2
- declare const plugin: TuiPluginModule;
3
- export default plugin;
1
+ import { Plugin } from "@opencode/plugin/tui";
2
+ declare const _default: Plugin.Definition;
3
+ export default _default;
package/dist/tui/index.js CHANGED
@@ -1,4 +1,7 @@
1
1
  // @bun
2
+ // src/tui/index.tsx
3
+ import { Plugin } from "@opencode/plugin/tui";
4
+
2
5
  // src/config/ConfigDefaults.ts
3
6
  class ConfigDefaults {
4
7
  static getDefaultSkillMappings() {
@@ -114,7 +117,6 @@ class ConfigLoader {
114
117
  }
115
118
  }
116
119
  // src/lib/FilePickerCommands.ts
117
- import { createComponent } from "@opentui/solid";
118
120
  import { readdirSync, statSync } from "fs";
119
121
  import { join, resolve } from "path";
120
122
  import { pathToFileURL } from "url";
@@ -183,252 +185,149 @@ class FilePickerCommands {
183
185
  const noSubPlansMessage = `No sub-plan files found in ${planDirectory}.`;
184
186
  return [
185
187
  {
186
- argumentPlaceholder: "$ARGUMENTS",
187
188
  command: "plan-review",
188
189
  description: "Select a plan to review",
189
190
  emptyMessage: noPlansMessage,
190
191
  matchesSelection: (argument, filePath) => argument === filePath && FilePickerCommands.isPlanPath(argument, planDirectory),
191
192
  options: (root, excludesPlanningDirectory) => FilePickerCommands.planOptions(root, planDirectory, excludesPlanningDirectory),
192
- retainsSelectedFileInPrompt: true,
193
193
  skill: skillMappings["plan-review"],
194
194
  title: "Select a plan to review"
195
195
  },
196
196
  {
197
- argumentPlaceholder: "$ARGUMENTS",
198
197
  command: "plan-resync",
199
198
  description: "Select a plan to resync",
200
199
  emptyMessage: noPlansMessage,
201
200
  matchesSelection: (argument, filePath) => argument === filePath && FilePickerCommands.isPlanPath(argument, planDirectory),
202
201
  options: (root, excludesPlanningDirectory) => FilePickerCommands.planOptions(root, planDirectory, excludesPlanningDirectory),
203
- retainsSelectedFileInPrompt: true,
204
202
  skill: skillMappings["plan-resync"],
205
203
  title: "Select a plan to resync"
206
204
  },
207
205
  {
208
- argumentPlaceholder: "$ARGUMENTS",
209
206
  command: "plan-split",
210
207
  description: "Select a plan to split",
211
208
  emptyMessage: noPlansMessage,
212
209
  excludesPlanningDirectory: FilePickerCommands.hasSubPlans,
213
210
  matchesSelection: (argument, filePath) => argument === filePath && FilePickerCommands.isPlanPath(argument, planDirectory),
214
211
  options: (root, excludesPlanningDirectory) => FilePickerCommands.planOptions(root, planDirectory, excludesPlanningDirectory),
215
- retainsSelectedFileInPrompt: true,
216
212
  skill: skillMappings["plan-split"],
217
213
  title: "Select a plan to split"
218
214
  },
219
215
  {
220
- argumentPlaceholder: "$PLAN_DIR",
221
216
  command: "plan-execute",
222
217
  description: "Select sub-plans to execute",
223
218
  emptyMessage: noSubPlansMessage,
224
219
  matchesSelection: (argument, filePath) => FilePickerCommands.isSubPlanSelection(argument, filePath, planDirectory),
225
220
  options: (root) => FilePickerCommands.subPlanOptions(root, planDirectory),
226
- retainsSelectedFileInPrompt: false,
227
221
  skill: skillMappings["plan-execute"],
228
222
  title: "Select sub-plans to execute"
229
223
  }
230
224
  ];
231
225
  }
232
- static findSkill(text, parts, config) {
233
- const filePaths = parts.flatMap((part) => {
234
- if (typeof part !== "object" || part === null || !("type" in part) || part.type !== "file" || !("source" in part)) {
235
- return [];
236
- }
237
- const { source } = part;
238
- return typeof source === "object" && source !== null && "type" in source && source.type === "file" && "path" in source && typeof source.path === "string" ? [source.path] : [];
239
- });
240
- for (const command of FilePickerCommands.getCommands(config)) {
241
- const prefix = `/${command.command} @`;
242
- if (!text.startsWith(prefix)) {
243
- continue;
244
- }
245
- const argument = text.slice(prefix.length).trim();
246
- const filePath = filePaths.find((path) => command.matchesSelection(argument, path));
247
- if (argument !== "" && filePath !== undefined) {
248
- return {
249
- argument,
250
- argumentPlaceholder: command.argumentPlaceholder,
251
- filePath,
252
- retainsSelectedFileInPrompt: command.retainsSelectedFileInPrompt,
253
- skill: command.skill
254
- };
255
- }
256
- }
226
+ static toast(context, message) {
227
+ context.ui.toast.show({ message, variant: "error" });
257
228
  }
258
- static removeSelectedFile(parts, filePath) {
259
- const selectedFileIndex = parts.findIndex((candidate) => {
260
- if (typeof candidate !== "object" || candidate === null || !("type" in candidate) || candidate.type !== "file" || !("source" in candidate)) {
261
- return false;
229
+ static async submit(context, location, command, option) {
230
+ let skills;
231
+ try {
232
+ skills = context.data.location.skill.list(location);
233
+ if (skills === undefined) {
234
+ await context.data.location.skill.sync(location);
235
+ skills = context.data.location.skill.list(location);
262
236
  }
263
- const { source } = candidate;
264
- return typeof source === "object" && source !== null && "type" in source && source.type === "file" && "path" in source && source.path === filePath;
265
- });
266
- if (selectedFileIndex !== -1) {
267
- parts.splice(selectedFileIndex, 1);
237
+ } catch {
238
+ skills = undefined;
268
239
  }
269
- }
270
- static expandedSkill(text, parts, commands) {
271
- for (const part of parts) {
272
- if (typeof part !== "object" || part === null || !("type" in part) || part.type !== "file" || !("source" in part)) {
273
- continue;
274
- }
275
- const { source } = part;
276
- if (typeof source !== "object" || source === null || !("type" in source) || source.type !== "file" || !("path" in source) || !("text" in source)) {
277
- continue;
278
- }
279
- const filePath = source.path;
280
- if (typeof filePath !== "string" || typeof source.text !== "object" || source.text === null || !("value" in source.text) || typeof source.text.value !== "string" || !source.text.value.startsWith("@")) {
281
- continue;
240
+ const skill = skills?.find((candidate) => candidate.name === command.skill);
241
+ if (skill === undefined) {
242
+ FilePickerCommands.toast(context, `The configured skill "${command.skill}" is not available.`);
243
+ return;
244
+ }
245
+ let sessionID;
246
+ const route = context.ui.router.current();
247
+ if (route.type === "session") {
248
+ sessionID = route.sessionID;
249
+ } else {
250
+ let session;
251
+ try {
252
+ session = await context.client.session.create({ location: { directory: location.directory } });
253
+ } catch {
254
+ FilePickerCommands.toast(context, "Unable to create a session for the plan request.");
255
+ return;
282
256
  }
283
- const argument = source.text.value.slice(1);
284
- const command = commands.find((candidate) => candidate.matchesSelection(argument, filePath) && text.includes(candidate.argumentPlaceholder));
285
- if (command !== undefined) {
286
- return {
287
- argument,
288
- argumentPlaceholder: command.argumentPlaceholder,
289
- filePath,
290
- retainsSelectedFileInPrompt: command.retainsSelectedFileInPrompt,
291
- skill: command.skill
292
- };
257
+ sessionID = session.id;
258
+ if (!context.ui.tabs.focus(sessionID)) {
259
+ context.ui.router.navigate({ type: "session", sessionID });
293
260
  }
294
261
  }
262
+ const prompt = {
263
+ sessionID,
264
+ text: option.argument,
265
+ ...command.command === "plan-execute" ? {} : {
266
+ files: [{ uri: pathToFileURL(resolve(location.directory, option.filePath)).href, name: option.filePath }]
267
+ },
268
+ skills: [{ id: skill.id }]
269
+ };
270
+ try {
271
+ await context.client.session.prompt(prompt);
272
+ } catch {
273
+ FilePickerCommands.toast(context, "Unable to submit the plan request.");
274
+ }
295
275
  }
296
- static async expand(output, template, config) {
297
- const commands = FilePickerCommands.getCommands(config);
298
- for (const part of output.parts) {
299
- if (typeof part !== "object" || part === null || !("type" in part) || part.type !== "text" || !("text" in part) || typeof part.text !== "string" || "synthetic" in part && part.synthetic) {
300
- continue;
301
- }
302
- const skill = FilePickerCommands.findSkill(part.text, output.parts, config);
303
- if (skill === undefined) {
304
- continue;
305
- }
306
- part.text = (await template(skill.skill)).replaceAll(skill.argumentPlaceholder, `@${skill.argument}`).trim();
307
- if (!skill.retainsSelectedFileInPrompt) {
308
- FilePickerCommands.removeSelectedFile(output.parts, skill.filePath);
309
- }
276
+ static async run(context, command) {
277
+ const location = context.location ?? context.data.location.default();
278
+ const options = command.options(location.directory, command.excludesPlanningDirectory);
279
+ if (options.length === 0) {
280
+ context.ui.toast.show({ variant: "warning", message: command.emptyMessage });
310
281
  return;
311
282
  }
312
- for (const part of output.parts) {
313
- if (typeof part !== "object" || part === null || !("type" in part) || part.type !== "text" || !("text" in part) || typeof part.text !== "string" || "synthetic" in part && part.synthetic) {
314
- continue;
315
- }
316
- const skill = FilePickerCommands.expandedSkill(part.text, output.parts, commands);
317
- if (skill === undefined) {
318
- continue;
319
- }
320
- part.text = part.text.replaceAll(skill.argumentPlaceholder, `@${skill.argument}`);
321
- if (!skill.retainsSelectedFileInPrompt) {
322
- FilePickerCommands.removeSelectedFile(output.parts, skill.filePath);
323
- }
283
+ const option = await context.ui.dialog.select({
284
+ title: command.title,
285
+ options: options.map((value) => ({ title: value.label, value }))
286
+ });
287
+ if (option === undefined) {
324
288
  return;
325
289
  }
290
+ await FilePickerCommands.submit(context, location, command, option);
326
291
  }
327
- static workspaceRoot(worktree, directory) {
328
- return worktree === "/" ? directory : worktree;
329
- }
330
- static setPrompt(prompt, root, command, option) {
331
- const mention = `@${option.argument}`;
332
- const input = `/${command.command} ${mention} `;
333
- const start = Bun.stringWidth(`/${command.command} `);
334
- prompt.set({
335
- input,
336
- mode: "normal",
337
- parts: [
338
- {
339
- type: "file",
340
- mime: "text/plain",
341
- filename: option.filePath,
342
- url: pathToFileURL(resolve(root, option.filePath)).href,
343
- source: {
344
- type: "file",
345
- path: option.filePath,
346
- text: {
347
- start,
348
- end: start + Bun.stringWidth(mention),
349
- value: mention
350
- }
351
- }
352
- }
353
- ]
354
- });
355
- prompt.focus();
356
- }
357
- static register(api, prompt, config) {
358
- api.keymap.registerLayer({
359
- commands: FilePickerCommands.getCommands(config).map((command) => ({
360
- name: `encoredigitalgroup-opencode.${command.command}`,
361
- title: command.title,
362
- category: "Plugin",
363
- namespace: "palette",
364
- slashName: command.command,
365
- run() {
366
- const root = FilePickerCommands.workspaceRoot(api.state.path.worktree, api.state.path.directory);
367
- const options = command.options(root, command.excludesPlanningDirectory);
368
- if (options.length === 0) {
369
- api.ui.toast({
370
- variant: "warning",
371
- message: command.emptyMessage
372
- });
373
- return;
374
- }
375
- const DialogSelect = api.ui.DialogSelect;
376
- api.ui.dialog.setSize("medium");
377
- api.ui.dialog.replace(() => createComponent(DialogSelect, {
378
- title: command.title,
379
- options: options.map((option) => ({ title: option.label, value: option })),
380
- onSelect: (option) => {
381
- api.ui.dialog.clear();
382
- const promptRef = prompt();
383
- if (promptRef === undefined) {
384
- api.ui.toast({
385
- variant: "error",
386
- message: "The prompt is not ready."
387
- });
388
- return;
389
- }
390
- FilePickerCommands.setPrompt(promptRef, root, command, option.value);
391
- }
392
- }));
393
- }
394
- }))
395
- });
292
+ static register(context, config) {
293
+ for (const command of FilePickerCommands.getCommands(config)) {
294
+ context.keymap.layer(() => ({
295
+ mode: "global",
296
+ commands: [{
297
+ id: `@encoredigitalgroup/opencode.${command.command}`,
298
+ title: command.title,
299
+ description: command.description,
300
+ group: "Plugin",
301
+ palette: true,
302
+ slash: {
303
+ name: command.command,
304
+ arguments: true
305
+ },
306
+ run: () => FilePickerCommands.run(context, command)
307
+ }]
308
+ }));
309
+ }
396
310
  }
397
311
  }
398
312
 
399
313
  // src/tui/index.tsx
400
- import { jsx } from "@opentui/solid/jsx-runtime";
401
- var plugin = {
402
- id: "encoredigitalgroup-opencode",
403
- tui: async (api, options) => {
404
- let prompt;
405
- const Prompt = api.ui.Prompt;
406
- const capturePrompt = (value, ref) => {
407
- prompt = value;
408
- ref?.(value);
409
- };
410
- api.slots.register({
411
- slots: {
412
- home_prompt(_context, props) {
413
- return /* @__PURE__ */ jsx(Prompt, {
414
- ref: (value) => capturePrompt(value, props.ref)
415
- });
416
- },
417
- session_prompt(_context, props) {
418
- return /* @__PURE__ */ jsx(Prompt, {
419
- sessionID: props.session_id,
420
- visible: props.visible,
421
- disabled: props.disabled,
422
- onSubmit: props.on_submit,
423
- ref: (value) => capturePrompt(value, props.ref)
424
- });
425
- }
426
- }
314
+ import { jsx, Fragment } from "@opentui/solid/jsx-runtime";
315
+ function PickerCommandLayer(props) {
316
+ FilePickerCommands.register(props.context, props.config);
317
+ return /* @__PURE__ */ jsx(Fragment, {});
318
+ }
319
+ var tui_default = Plugin.define({
320
+ id: "@encoredigitalgroup/opencode",
321
+ setup(ctx) {
322
+ return ctx.ui.slot({
323
+ append: "app",
324
+ render: () => /* @__PURE__ */ jsx(PickerCommandLayer, {
325
+ context: ctx,
326
+ config: ConfigLoader.loadConfig(ctx.options)
327
+ })
427
328
  });
428
- FilePickerCommands.register(api, () => prompt, ConfigLoader.loadConfig(options));
429
329
  }
430
- };
431
- var tui_default = plugin;
330
+ });
432
331
  export {
433
332
  tui_default as default
434
333
  };
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@encoredigitalgroup/opencode",
3
3
  "type": "module",
4
- "version": "0.1.3",
4
+ "version": "0.2.1",
5
5
  "description": "OpenCode file-picker commands for planning skills.",
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -16,21 +16,23 @@
16
16
  "bun": ">=1.4.0"
17
17
  },
18
18
  "dependencies": {
19
- "@opentui/solid": "0.4.5",
19
+ "@opentui/solid": "0.5.14",
20
20
  "solid-js": "1.9.12"
21
21
  },
22
22
  "devDependencies": {
23
- "@opencode-ai/plugin": "1.18.29",
24
- "@opentui/core": "0.4.5",
25
- "@opentui/keymap": "0.4.5",
23
+ "@opencode/cli": "2.0.22",
24
+ "@opencode/client": "2.0.22",
25
+ "@opencode/plugin": "2.0.22",
26
+ "@opentui/core": "0.5.14",
26
27
  "@types/bun": "1.4.0",
27
28
  "tsfmt": "^0.3.0-beta9",
28
29
  "typescript": "5.8.2"
29
30
  },
30
31
  "scripts": {
31
- "build": "rm -rf dist && bun build src/server/index.ts src/tui/index.tsx --outdir dist --target bun --external '@opencode-ai/plugin' --external '@opencode-ai/plugin/*' --external '@opentui/solid' --external '@opentui/solid/*' && tsc --emitDeclarationOnly",
32
+ "build": "rm -rf dist && bun build src/server/index.ts src/tui/index.tsx --outdir dist --target bun --external '@opencode/plugin' --external '@opencode/plugin/*' --external '@opentui/solid' --external '@opentui/solid/*' && tsc --emitDeclarationOnly",
32
33
  "check": "bun run typecheck && bun run build",
33
34
  "pack:check": "bun pm pack --dry-run",
35
+ "test": "bun test",
34
36
  "typecheck": "tsc --noEmit"
35
37
  },
36
38
  "types": "./dist/server/index.d.ts",
@@ -51,15 +53,20 @@
51
53
  },
52
54
  "files": [
53
55
  "dist",
56
+ "server.js",
57
+ "tui.js",
54
58
  "README.md",
55
59
  "LICENSE.md"
56
60
  ],
57
61
  "license": "proprietary",
58
62
  "peerDependencies": {
59
- "@opencode-ai/plugin": "^1.18.29"
63
+ "@opencode/plugin": "^2.0.22"
60
64
  },
61
65
  "oc-plugin": [
62
66
  "server",
63
67
  "tui"
68
+ ],
69
+ "trustedDependencies": [
70
+ "@opencode/cli"
64
71
  ]
65
72
  }
package/server.js ADDED
@@ -0,0 +1 @@
1
+ export {default} from "./dist/server/index.js";
package/tui.js ADDED
@@ -0,0 +1 @@
1
+ export {default} from "./dist/tui/index.js";