@encoredigitalgroup/opencode 0.1.0
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/LICENSE.md +1 -0
- package/README.md +56 -0
- package/dist/config/ConfigDefaults.d.ts +8 -0
- package/dist/config/ConfigLoader.d.ts +13 -0
- package/dist/config/ConfigMerger.d.ts +6 -0
- package/dist/config/ConfigTypes.d.ts +16 -0
- package/dist/config/ConfigValidator.d.ts +13 -0
- package/dist/config/index.d.ts +5 -0
- package/dist/lib/FilePickerCommands.d.ts +36 -0
- package/dist/lib/SkillCommands.d.ts +15 -0
- package/dist/server/index.d.ts +3 -0
- package/dist/server/index.js +448 -0
- package/dist/tui/index.d.ts +3 -0
- package/dist/tui/index.js +423 -0
- package/package.json +65 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
See [documentation site](https://docs.encoredigitalgroup.com/license)
|
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# OpenCode planning commands
|
|
2
|
+
|
|
3
|
+
`@encoredigitalgroup/opencode` adds file pickers for OpenCode planning commands.
|
|
4
|
+
|
|
5
|
+
The TUI provides `/plan-review`, `/plan-split`, and `/plan-execute` commands. Each command opens a picker for plans in `docs/_planning` and inserts the selected plan
|
|
6
|
+
into the prompt. The server entry expands the selected plan into the matching installed skill prompt.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
Add the package to the `plugin` array in your OpenCode configuration:
|
|
11
|
+
|
|
12
|
+
- Use `~/.config/opencode/opencode.json` to enable it for every project.
|
|
13
|
+
- Use `opencode.json` in a project to enable it only there.
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"$schema": "https://opencode.ai/config.json",
|
|
18
|
+
"plugin": ["@encoredigitalgroup/opencode"]
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
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.
|
|
24
|
+
|
|
25
|
+
The plugin resolves the mapped skills through OpenCode, so those skills can live in any location that OpenCode discovers.
|
|
26
|
+
|
|
27
|
+
## Configuration
|
|
28
|
+
|
|
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.
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"$schema": "https://opencode.ai/config.json",
|
|
35
|
+
"plugin": [
|
|
36
|
+
[
|
|
37
|
+
"@encoredigitalgroup/opencode",
|
|
38
|
+
{
|
|
39
|
+
"planDirectory": "plans",
|
|
40
|
+
"skillMappings": {
|
|
41
|
+
"plan-review": "custom-plan-review",
|
|
42
|
+
"plan-execute": "custom-plan-execute"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Development
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
bun install
|
|
54
|
+
bun run check
|
|
55
|
+
bun run pack:check
|
|
56
|
+
```
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { PluginConfig, SkillMappings } from "./ConfigTypes.js";
|
|
2
|
+
/** Provides default configuration values for the plugin. */
|
|
3
|
+
export declare class ConfigDefaults {
|
|
4
|
+
/** Get the default skill mappings. */
|
|
5
|
+
static getDefaultSkillMappings(): SkillMappings;
|
|
6
|
+
/** Get the complete default configuration. */
|
|
7
|
+
static getDefaultConfig(): PluginConfig;
|
|
8
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { PluginOptions } from "@opencode-ai/plugin";
|
|
2
|
+
import { type PluginConfig } from "./ConfigTypes.js";
|
|
3
|
+
/** Loads plugin settings from the OpenCode plugin options. */
|
|
4
|
+
export declare class ConfigLoader {
|
|
5
|
+
private static stringOption;
|
|
6
|
+
private static planDirectoryOption;
|
|
7
|
+
/** Get valid skill mappings from plugin options. */
|
|
8
|
+
private static skillMappingsOption;
|
|
9
|
+
/** Get the configured option values that can override the defaults. */
|
|
10
|
+
private static getUserConfig;
|
|
11
|
+
/** Merge configured plugin options with the defaults. */
|
|
12
|
+
static loadConfig(options: PluginOptions | undefined): PluginConfig;
|
|
13
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type PluginConfig, type UserPluginConfig } from "./ConfigTypes.js";
|
|
2
|
+
/** Merges user configuration with the default configuration. */
|
|
3
|
+
export declare class ConfigMerger {
|
|
4
|
+
/** Merge user configuration with the defaults. */
|
|
5
|
+
static merge(userConfig: UserPluginConfig): PluginConfig;
|
|
6
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare const skillCommands: readonly ["feature-planning", "plan-execute", "plan-resync", "plan-review", "plan-split"];
|
|
2
|
+
export type SkillCommand = (typeof skillCommands)[number];
|
|
3
|
+
export type SkillMappings = Record<SkillCommand, string>;
|
|
4
|
+
export type PluginConfig = {
|
|
5
|
+
planDirectory: string;
|
|
6
|
+
skillMappings: SkillMappings;
|
|
7
|
+
};
|
|
8
|
+
export type UserPluginConfig = {
|
|
9
|
+
planDirectory?: string;
|
|
10
|
+
skillMappings?: Partial<SkillMappings>;
|
|
11
|
+
};
|
|
12
|
+
/** Configuration type utilities. */
|
|
13
|
+
export declare class ConfigTypes {
|
|
14
|
+
/** Get all commands with configurable skill mappings. */
|
|
15
|
+
static getSkillCommands(): SkillCommand[];
|
|
16
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type PluginConfig } from "./ConfigTypes.js";
|
|
2
|
+
/** Validation result containing configuration errors. */
|
|
3
|
+
export type ValidationResult = {
|
|
4
|
+
errors: string[];
|
|
5
|
+
valid: boolean;
|
|
6
|
+
};
|
|
7
|
+
/** Validates plugin configuration objects. */
|
|
8
|
+
export declare class ConfigValidator {
|
|
9
|
+
/** Validate a configuration object. */
|
|
10
|
+
static validate(config: PluginConfig): ValidationResult;
|
|
11
|
+
/** Validate a configuration object and throw when it is invalid. */
|
|
12
|
+
static validateOrThrow(config: PluginConfig): void;
|
|
13
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { TuiPluginApi, TuiPromptRef } from "@opencode-ai/plugin/tui";
|
|
2
|
+
import type { PluginConfig } from "../config";
|
|
3
|
+
export type FilePickerOption = {
|
|
4
|
+
argument: string;
|
|
5
|
+
filePath: string;
|
|
6
|
+
label: string;
|
|
7
|
+
};
|
|
8
|
+
export type FilePickerCommand = {
|
|
9
|
+
argumentPlaceholder: string;
|
|
10
|
+
command: string;
|
|
11
|
+
description: string;
|
|
12
|
+
emptyMessage: string;
|
|
13
|
+
excludesPlanningDirectory?(directory: string): boolean;
|
|
14
|
+
matchesSelection(argument: string, filePath: string): boolean;
|
|
15
|
+
options(root: string, excludesPlanningDirectory?: (directory: string) => boolean): FilePickerOption[];
|
|
16
|
+
retainsSelectedFileInPrompt: boolean;
|
|
17
|
+
skill: string;
|
|
18
|
+
title: string;
|
|
19
|
+
};
|
|
20
|
+
export declare class FilePickerCommands {
|
|
21
|
+
private static isPlanPath;
|
|
22
|
+
private static planOptions;
|
|
23
|
+
private static hasSubPlans;
|
|
24
|
+
private static isSubPlanSelection;
|
|
25
|
+
private static subPlanOptions;
|
|
26
|
+
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;
|
|
36
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PluginConfig } from "../config";
|
|
2
|
+
export declare class SkillCommands {
|
|
3
|
+
static getCommands(config: PluginConfig): {
|
|
4
|
+
"feature-planning": {
|
|
5
|
+
description: string;
|
|
6
|
+
skill: string;
|
|
7
|
+
};
|
|
8
|
+
"plan-resync": {
|
|
9
|
+
description: string;
|
|
10
|
+
skill: string;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
private static isSkill;
|
|
14
|
+
static getTemplate(serverUrl: URL, directory: string, skillName: string): Promise<string>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/config/ConfigDefaults.ts
|
|
3
|
+
class ConfigDefaults {
|
|
4
|
+
static getDefaultSkillMappings() {
|
|
5
|
+
return {
|
|
6
|
+
"feature-planning": "feature-planning",
|
|
7
|
+
"plan-execute": "plan-execute",
|
|
8
|
+
"plan-resync": "plan-resync",
|
|
9
|
+
"plan-review": "plan-review",
|
|
10
|
+
"plan-split": "plan-split"
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
static getDefaultConfig() {
|
|
14
|
+
return {
|
|
15
|
+
planDirectory: "docs/_planning",
|
|
16
|
+
skillMappings: this.getDefaultSkillMappings()
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// src/config/ConfigTypes.ts
|
|
21
|
+
var skillCommands = [
|
|
22
|
+
"feature-planning",
|
|
23
|
+
"plan-execute",
|
|
24
|
+
"plan-resync",
|
|
25
|
+
"plan-review",
|
|
26
|
+
"plan-split"
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
class ConfigTypes {
|
|
30
|
+
static getSkillCommands() {
|
|
31
|
+
return [...skillCommands];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// src/config/ConfigMerger.ts
|
|
36
|
+
class ConfigMerger {
|
|
37
|
+
static merge(userConfig) {
|
|
38
|
+
const config = ConfigDefaults.getDefaultConfig();
|
|
39
|
+
if (userConfig.planDirectory !== undefined) {
|
|
40
|
+
config.planDirectory = userConfig.planDirectory;
|
|
41
|
+
}
|
|
42
|
+
if (userConfig.skillMappings !== undefined) {
|
|
43
|
+
for (const command of ConfigTypes.getSkillCommands()) {
|
|
44
|
+
config.skillMappings[command] = userConfig.skillMappings[command] ?? config.skillMappings[command];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return config;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// src/config/ConfigValidator.ts
|
|
52
|
+
class ConfigValidator {
|
|
53
|
+
static validate(config) {
|
|
54
|
+
const errors = [];
|
|
55
|
+
if (config.planDirectory.trim() === "") {
|
|
56
|
+
errors.push("Invalid planDirectory: must be a non-empty string.");
|
|
57
|
+
}
|
|
58
|
+
for (const command of ConfigTypes.getSkillCommands()) {
|
|
59
|
+
if (config.skillMappings[command].trim() === "") {
|
|
60
|
+
errors.push(`Invalid skillMappings.${command}: must be a non-empty string.`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { valid: errors.length === 0, errors };
|
|
64
|
+
}
|
|
65
|
+
static validateOrThrow(config) {
|
|
66
|
+
const result = this.validate(config);
|
|
67
|
+
if (!result.valid) {
|
|
68
|
+
throw new Error(`Invalid configuration:
|
|
69
|
+
${result.errors.join(`
|
|
70
|
+
`)}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// src/config/ConfigLoader.ts
|
|
76
|
+
class ConfigLoader {
|
|
77
|
+
static stringOption(value) {
|
|
78
|
+
return typeof value === "string" && value.trim() !== "" ? value : undefined;
|
|
79
|
+
}
|
|
80
|
+
static planDirectoryOption(value) {
|
|
81
|
+
const directory = this.stringOption(value)?.replace(/\/+$/, "");
|
|
82
|
+
return directory === "" ? undefined : directory;
|
|
83
|
+
}
|
|
84
|
+
static skillMappingsOption(value) {
|
|
85
|
+
if (typeof value !== "object" || value === null) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const mappingOptions = value;
|
|
89
|
+
const skillMappings = {};
|
|
90
|
+
for (const command of ConfigTypes.getSkillCommands()) {
|
|
91
|
+
const skill = this.stringOption(mappingOptions[command]);
|
|
92
|
+
if (skill !== undefined) {
|
|
93
|
+
skillMappings[command] = skill;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return skillMappings;
|
|
97
|
+
}
|
|
98
|
+
static getUserConfig(options) {
|
|
99
|
+
const planDirectory = this.planDirectoryOption(options?.planDirectory);
|
|
100
|
+
const skillMappings = this.skillMappingsOption(options?.skillMappings);
|
|
101
|
+
return {
|
|
102
|
+
...planDirectory === undefined ? {} : { planDirectory },
|
|
103
|
+
...skillMappings === undefined ? {} : { skillMappings }
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
static loadConfig(options) {
|
|
107
|
+
try {
|
|
108
|
+
const config = ConfigMerger.merge(this.getUserConfig(options));
|
|
109
|
+
ConfigValidator.validateOrThrow(config);
|
|
110
|
+
return config;
|
|
111
|
+
} catch {
|
|
112
|
+
return ConfigDefaults.getDefaultConfig();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
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-split",
|
|
199
|
+
description: "Select a plan to split",
|
|
200
|
+
emptyMessage: noPlansMessage,
|
|
201
|
+
excludesPlanningDirectory: FilePickerCommands.hasSubPlans,
|
|
202
|
+
matchesSelection: (argument, filePath) => argument === filePath && FilePickerCommands.isPlanPath(argument, planDirectory),
|
|
203
|
+
options: (root, excludesPlanningDirectory) => FilePickerCommands.planOptions(root, planDirectory, excludesPlanningDirectory),
|
|
204
|
+
retainsSelectedFileInPrompt: true,
|
|
205
|
+
skill: skillMappings["plan-split"],
|
|
206
|
+
title: "Select a plan to split"
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
argumentPlaceholder: "$PLAN_DIR",
|
|
210
|
+
command: "plan-execute",
|
|
211
|
+
description: "Select sub-plans to execute",
|
|
212
|
+
emptyMessage: noSubPlansMessage,
|
|
213
|
+
matchesSelection: (argument, filePath) => FilePickerCommands.isSubPlanSelection(argument, filePath, planDirectory),
|
|
214
|
+
options: (root) => FilePickerCommands.subPlanOptions(root, planDirectory),
|
|
215
|
+
retainsSelectedFileInPrompt: false,
|
|
216
|
+
skill: skillMappings["plan-execute"],
|
|
217
|
+
title: "Select sub-plans to execute"
|
|
218
|
+
}
|
|
219
|
+
];
|
|
220
|
+
}
|
|
221
|
+
static findSkill(text, parts, config) {
|
|
222
|
+
const filePaths = parts.flatMap((part) => {
|
|
223
|
+
if (typeof part !== "object" || part === null || !("type" in part) || part.type !== "file" || !("source" in part)) {
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
226
|
+
const { source } = part;
|
|
227
|
+
return typeof source === "object" && source !== null && "type" in source && source.type === "file" && "path" in source && typeof source.path === "string" ? [source.path] : [];
|
|
228
|
+
});
|
|
229
|
+
for (const command of FilePickerCommands.getCommands(config)) {
|
|
230
|
+
const prefix = `/${command.command} @`;
|
|
231
|
+
if (!text.startsWith(prefix)) {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const argument = text.slice(prefix.length).trim();
|
|
235
|
+
const filePath = filePaths.find((path) => command.matchesSelection(argument, path));
|
|
236
|
+
if (argument !== "" && filePath !== undefined) {
|
|
237
|
+
return {
|
|
238
|
+
argument,
|
|
239
|
+
argumentPlaceholder: command.argumentPlaceholder,
|
|
240
|
+
filePath,
|
|
241
|
+
retainsSelectedFileInPrompt: command.retainsSelectedFileInPrompt,
|
|
242
|
+
skill: command.skill
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
static removeSelectedFile(parts, filePath) {
|
|
248
|
+
const selectedFileIndex = parts.findIndex((candidate) => {
|
|
249
|
+
if (typeof candidate !== "object" || candidate === null || !("type" in candidate) || candidate.type !== "file" || !("source" in candidate)) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
const { source } = candidate;
|
|
253
|
+
return typeof source === "object" && source !== null && "type" in source && source.type === "file" && "path" in source && source.path === filePath;
|
|
254
|
+
});
|
|
255
|
+
if (selectedFileIndex !== -1) {
|
|
256
|
+
parts.splice(selectedFileIndex, 1);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
static expandedSkill(text, parts, commands) {
|
|
260
|
+
for (const part of parts) {
|
|
261
|
+
if (typeof part !== "object" || part === null || !("type" in part) || part.type !== "file" || !("source" in part)) {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const { source } = part;
|
|
265
|
+
if (typeof source !== "object" || source === null || !("type" in source) || source.type !== "file" || !("path" in source) || !("text" in source)) {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const filePath = source.path;
|
|
269
|
+
if (typeof filePath !== "string" || typeof source.text !== "object" || source.text === null || !("value" in source.text) || typeof source.text.value !== "string" || !source.text.value.startsWith("@")) {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const argument = source.text.value.slice(1);
|
|
273
|
+
const command = commands.find((candidate) => candidate.matchesSelection(argument, filePath) && text.includes(candidate.argumentPlaceholder));
|
|
274
|
+
if (command !== undefined) {
|
|
275
|
+
return {
|
|
276
|
+
argument,
|
|
277
|
+
argumentPlaceholder: command.argumentPlaceholder,
|
|
278
|
+
filePath,
|
|
279
|
+
retainsSelectedFileInPrompt: command.retainsSelectedFileInPrompt,
|
|
280
|
+
skill: command.skill
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
static async expand(output, template, config) {
|
|
286
|
+
const commands = FilePickerCommands.getCommands(config);
|
|
287
|
+
for (const part of output.parts) {
|
|
288
|
+
if (typeof part !== "object" || part === null || !("type" in part) || part.type !== "text" || !("text" in part) || typeof part.text !== "string" || "synthetic" in part && part.synthetic) {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
const skill = FilePickerCommands.findSkill(part.text, output.parts, config);
|
|
292
|
+
if (skill === undefined) {
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
part.text = (await template(skill.skill)).replaceAll(skill.argumentPlaceholder, `@${skill.argument}`).trim();
|
|
296
|
+
if (!skill.retainsSelectedFileInPrompt) {
|
|
297
|
+
FilePickerCommands.removeSelectedFile(output.parts, skill.filePath);
|
|
298
|
+
}
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
for (const part of output.parts) {
|
|
302
|
+
if (typeof part !== "object" || part === null || !("type" in part) || part.type !== "text" || !("text" in part) || typeof part.text !== "string" || "synthetic" in part && part.synthetic) {
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const skill = FilePickerCommands.expandedSkill(part.text, output.parts, commands);
|
|
306
|
+
if (skill === undefined) {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
part.text = part.text.replaceAll(skill.argumentPlaceholder, `@${skill.argument}`);
|
|
310
|
+
if (!skill.retainsSelectedFileInPrompt) {
|
|
311
|
+
FilePickerCommands.removeSelectedFile(output.parts, skill.filePath);
|
|
312
|
+
}
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
static workspaceRoot(worktree, directory) {
|
|
317
|
+
return worktree === "/" ? directory : worktree;
|
|
318
|
+
}
|
|
319
|
+
static setPrompt(prompt, root, command, option) {
|
|
320
|
+
const mention = `@${option.argument}`;
|
|
321
|
+
const input = `/${command.command} ${mention} `;
|
|
322
|
+
const start = Bun.stringWidth(`/${command.command} `);
|
|
323
|
+
prompt.set({
|
|
324
|
+
input,
|
|
325
|
+
mode: "normal",
|
|
326
|
+
parts: [
|
|
327
|
+
{
|
|
328
|
+
type: "file",
|
|
329
|
+
mime: "text/plain",
|
|
330
|
+
filename: option.filePath,
|
|
331
|
+
url: pathToFileURL(resolve(root, option.filePath)).href,
|
|
332
|
+
source: {
|
|
333
|
+
type: "file",
|
|
334
|
+
path: option.filePath,
|
|
335
|
+
text: {
|
|
336
|
+
start,
|
|
337
|
+
end: start + Bun.stringWidth(mention),
|
|
338
|
+
value: mention
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
]
|
|
343
|
+
});
|
|
344
|
+
prompt.focus();
|
|
345
|
+
}
|
|
346
|
+
static register(api, prompt, config) {
|
|
347
|
+
api.keymap.registerLayer({
|
|
348
|
+
commands: FilePickerCommands.getCommands(config).map((command) => ({
|
|
349
|
+
name: `encoredigitalgroup-opencode.${command.command}`,
|
|
350
|
+
title: command.title,
|
|
351
|
+
category: "Plugin",
|
|
352
|
+
namespace: "palette",
|
|
353
|
+
slashName: command.command,
|
|
354
|
+
run() {
|
|
355
|
+
const root = FilePickerCommands.workspaceRoot(api.state.path.worktree, api.state.path.directory);
|
|
356
|
+
const options = command.options(root, command.excludesPlanningDirectory);
|
|
357
|
+
if (options.length === 0) {
|
|
358
|
+
api.ui.toast({
|
|
359
|
+
variant: "warning",
|
|
360
|
+
message: command.emptyMessage
|
|
361
|
+
});
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const DialogSelect = api.ui.DialogSelect;
|
|
365
|
+
api.ui.dialog.setSize("medium");
|
|
366
|
+
api.ui.dialog.replace(() => createComponent(DialogSelect, {
|
|
367
|
+
title: command.title,
|
|
368
|
+
options: options.map((option) => ({ title: option.label, value: option })),
|
|
369
|
+
onSelect: (option) => {
|
|
370
|
+
api.ui.dialog.clear();
|
|
371
|
+
const promptRef = prompt();
|
|
372
|
+
if (promptRef === undefined) {
|
|
373
|
+
api.ui.toast({
|
|
374
|
+
variant: "error",
|
|
375
|
+
message: "The prompt is not ready."
|
|
376
|
+
});
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
FilePickerCommands.setPrompt(promptRef, root, command, option.value);
|
|
380
|
+
}
|
|
381
|
+
}));
|
|
382
|
+
}
|
|
383
|
+
}))
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// src/lib/SkillCommands.ts
|
|
389
|
+
import { readFile } from "fs/promises";
|
|
390
|
+
|
|
391
|
+
class SkillCommands {
|
|
392
|
+
static getCommands(config) {
|
|
393
|
+
return {
|
|
394
|
+
"feature-planning": {
|
|
395
|
+
description: "Create a feature implementation plan.",
|
|
396
|
+
skill: config.skillMappings["feature-planning"]
|
|
397
|
+
},
|
|
398
|
+
"plan-resync": {
|
|
399
|
+
description: "Reconcile an implementation plan with the current codebase.",
|
|
400
|
+
skill: config.skillMappings["plan-resync"]
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
static isSkill(value) {
|
|
405
|
+
return typeof value === "object" && value !== null && "location" in value && typeof value.location === "string" && "name" in value && typeof value.name === "string";
|
|
406
|
+
}
|
|
407
|
+
static async getTemplate(serverUrl, directory, skillName) {
|
|
408
|
+
const url = new URL("/api/skill", serverUrl);
|
|
409
|
+
url.searchParams.set("directory", directory);
|
|
410
|
+
url.searchParams.set("location[directory]", directory);
|
|
411
|
+
const response = await fetch(url);
|
|
412
|
+
if (!response.ok) {
|
|
413
|
+
throw new Error(`Unable to list OpenCode skills: ${response.status} ${response.statusText}`);
|
|
414
|
+
}
|
|
415
|
+
const responseBody = await response.json();
|
|
416
|
+
const skills = typeof responseBody === "object" && responseBody !== null && "data" in responseBody && Array.isArray(responseBody.data) ? responseBody.data.filter(SkillCommands.isSkill) : [];
|
|
417
|
+
const skill = skills.find((candidate) => candidate.name === skillName);
|
|
418
|
+
if (skill === undefined) {
|
|
419
|
+
throw new Error(`OpenCode skill "${skillName}" is not available.`);
|
|
420
|
+
}
|
|
421
|
+
return readFile(skill.location, "utf8");
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// src/server/index.ts
|
|
426
|
+
var plugin = {
|
|
427
|
+
id: "encoredigitalgroup-opencode",
|
|
428
|
+
server: async (input, options) => {
|
|
429
|
+
const pluginConfig = ConfigLoader.loadConfig(options);
|
|
430
|
+
const template = (skill) => SkillCommands.getTemplate(input.serverUrl, input.directory, skill);
|
|
431
|
+
return {
|
|
432
|
+
config: async (config) => {
|
|
433
|
+
config.command ??= {};
|
|
434
|
+
for (const [command, route] of Object.entries(SkillCommands.getCommands(pluginConfig))) {
|
|
435
|
+
config.command[command] = {
|
|
436
|
+
description: route.description,
|
|
437
|
+
template: await template(route.skill)
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
},
|
|
441
|
+
"chat.message": async (_input, output) => FilePickerCommands.expand(output, template, pluginConfig)
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
var server_default = plugin;
|
|
446
|
+
export {
|
|
447
|
+
server_default as default
|
|
448
|
+
};
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/config/ConfigDefaults.ts
|
|
3
|
+
class ConfigDefaults {
|
|
4
|
+
static getDefaultSkillMappings() {
|
|
5
|
+
return {
|
|
6
|
+
"feature-planning": "feature-planning",
|
|
7
|
+
"plan-execute": "plan-execute",
|
|
8
|
+
"plan-resync": "plan-resync",
|
|
9
|
+
"plan-review": "plan-review",
|
|
10
|
+
"plan-split": "plan-split"
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
static getDefaultConfig() {
|
|
14
|
+
return {
|
|
15
|
+
planDirectory: "docs/_planning",
|
|
16
|
+
skillMappings: this.getDefaultSkillMappings()
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// src/config/ConfigTypes.ts
|
|
21
|
+
var skillCommands = [
|
|
22
|
+
"feature-planning",
|
|
23
|
+
"plan-execute",
|
|
24
|
+
"plan-resync",
|
|
25
|
+
"plan-review",
|
|
26
|
+
"plan-split"
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
class ConfigTypes {
|
|
30
|
+
static getSkillCommands() {
|
|
31
|
+
return [...skillCommands];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// src/config/ConfigMerger.ts
|
|
36
|
+
class ConfigMerger {
|
|
37
|
+
static merge(userConfig) {
|
|
38
|
+
const config = ConfigDefaults.getDefaultConfig();
|
|
39
|
+
if (userConfig.planDirectory !== undefined) {
|
|
40
|
+
config.planDirectory = userConfig.planDirectory;
|
|
41
|
+
}
|
|
42
|
+
if (userConfig.skillMappings !== undefined) {
|
|
43
|
+
for (const command of ConfigTypes.getSkillCommands()) {
|
|
44
|
+
config.skillMappings[command] = userConfig.skillMappings[command] ?? config.skillMappings[command];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return config;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// src/config/ConfigValidator.ts
|
|
52
|
+
class ConfigValidator {
|
|
53
|
+
static validate(config) {
|
|
54
|
+
const errors = [];
|
|
55
|
+
if (config.planDirectory.trim() === "") {
|
|
56
|
+
errors.push("Invalid planDirectory: must be a non-empty string.");
|
|
57
|
+
}
|
|
58
|
+
for (const command of ConfigTypes.getSkillCommands()) {
|
|
59
|
+
if (config.skillMappings[command].trim() === "") {
|
|
60
|
+
errors.push(`Invalid skillMappings.${command}: must be a non-empty string.`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { valid: errors.length === 0, errors };
|
|
64
|
+
}
|
|
65
|
+
static validateOrThrow(config) {
|
|
66
|
+
const result = this.validate(config);
|
|
67
|
+
if (!result.valid) {
|
|
68
|
+
throw new Error(`Invalid configuration:
|
|
69
|
+
${result.errors.join(`
|
|
70
|
+
`)}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// src/config/ConfigLoader.ts
|
|
76
|
+
class ConfigLoader {
|
|
77
|
+
static stringOption(value) {
|
|
78
|
+
return typeof value === "string" && value.trim() !== "" ? value : undefined;
|
|
79
|
+
}
|
|
80
|
+
static planDirectoryOption(value) {
|
|
81
|
+
const directory = this.stringOption(value)?.replace(/\/+$/, "");
|
|
82
|
+
return directory === "" ? undefined : directory;
|
|
83
|
+
}
|
|
84
|
+
static skillMappingsOption(value) {
|
|
85
|
+
if (typeof value !== "object" || value === null) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const mappingOptions = value;
|
|
89
|
+
const skillMappings = {};
|
|
90
|
+
for (const command of ConfigTypes.getSkillCommands()) {
|
|
91
|
+
const skill = this.stringOption(mappingOptions[command]);
|
|
92
|
+
if (skill !== undefined) {
|
|
93
|
+
skillMappings[command] = skill;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return skillMappings;
|
|
97
|
+
}
|
|
98
|
+
static getUserConfig(options) {
|
|
99
|
+
const planDirectory = this.planDirectoryOption(options?.planDirectory);
|
|
100
|
+
const skillMappings = this.skillMappingsOption(options?.skillMappings);
|
|
101
|
+
return {
|
|
102
|
+
...planDirectory === undefined ? {} : { planDirectory },
|
|
103
|
+
...skillMappings === undefined ? {} : { skillMappings }
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
static loadConfig(options) {
|
|
107
|
+
try {
|
|
108
|
+
const config = ConfigMerger.merge(this.getUserConfig(options));
|
|
109
|
+
ConfigValidator.validateOrThrow(config);
|
|
110
|
+
return config;
|
|
111
|
+
} catch {
|
|
112
|
+
return ConfigDefaults.getDefaultConfig();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
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-split",
|
|
199
|
+
description: "Select a plan to split",
|
|
200
|
+
emptyMessage: noPlansMessage,
|
|
201
|
+
excludesPlanningDirectory: FilePickerCommands.hasSubPlans,
|
|
202
|
+
matchesSelection: (argument, filePath) => argument === filePath && FilePickerCommands.isPlanPath(argument, planDirectory),
|
|
203
|
+
options: (root, excludesPlanningDirectory) => FilePickerCommands.planOptions(root, planDirectory, excludesPlanningDirectory),
|
|
204
|
+
retainsSelectedFileInPrompt: true,
|
|
205
|
+
skill: skillMappings["plan-split"],
|
|
206
|
+
title: "Select a plan to split"
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
argumentPlaceholder: "$PLAN_DIR",
|
|
210
|
+
command: "plan-execute",
|
|
211
|
+
description: "Select sub-plans to execute",
|
|
212
|
+
emptyMessage: noSubPlansMessage,
|
|
213
|
+
matchesSelection: (argument, filePath) => FilePickerCommands.isSubPlanSelection(argument, filePath, planDirectory),
|
|
214
|
+
options: (root) => FilePickerCommands.subPlanOptions(root, planDirectory),
|
|
215
|
+
retainsSelectedFileInPrompt: false,
|
|
216
|
+
skill: skillMappings["plan-execute"],
|
|
217
|
+
title: "Select sub-plans to execute"
|
|
218
|
+
}
|
|
219
|
+
];
|
|
220
|
+
}
|
|
221
|
+
static findSkill(text, parts, config) {
|
|
222
|
+
const filePaths = parts.flatMap((part) => {
|
|
223
|
+
if (typeof part !== "object" || part === null || !("type" in part) || part.type !== "file" || !("source" in part)) {
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
226
|
+
const { source } = part;
|
|
227
|
+
return typeof source === "object" && source !== null && "type" in source && source.type === "file" && "path" in source && typeof source.path === "string" ? [source.path] : [];
|
|
228
|
+
});
|
|
229
|
+
for (const command of FilePickerCommands.getCommands(config)) {
|
|
230
|
+
const prefix = `/${command.command} @`;
|
|
231
|
+
if (!text.startsWith(prefix)) {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const argument = text.slice(prefix.length).trim();
|
|
235
|
+
const filePath = filePaths.find((path) => command.matchesSelection(argument, path));
|
|
236
|
+
if (argument !== "" && filePath !== undefined) {
|
|
237
|
+
return {
|
|
238
|
+
argument,
|
|
239
|
+
argumentPlaceholder: command.argumentPlaceholder,
|
|
240
|
+
filePath,
|
|
241
|
+
retainsSelectedFileInPrompt: command.retainsSelectedFileInPrompt,
|
|
242
|
+
skill: command.skill
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
static removeSelectedFile(parts, filePath) {
|
|
248
|
+
const selectedFileIndex = parts.findIndex((candidate) => {
|
|
249
|
+
if (typeof candidate !== "object" || candidate === null || !("type" in candidate) || candidate.type !== "file" || !("source" in candidate)) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
const { source } = candidate;
|
|
253
|
+
return typeof source === "object" && source !== null && "type" in source && source.type === "file" && "path" in source && source.path === filePath;
|
|
254
|
+
});
|
|
255
|
+
if (selectedFileIndex !== -1) {
|
|
256
|
+
parts.splice(selectedFileIndex, 1);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
static expandedSkill(text, parts, commands) {
|
|
260
|
+
for (const part of parts) {
|
|
261
|
+
if (typeof part !== "object" || part === null || !("type" in part) || part.type !== "file" || !("source" in part)) {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const { source } = part;
|
|
265
|
+
if (typeof source !== "object" || source === null || !("type" in source) || source.type !== "file" || !("path" in source) || !("text" in source)) {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const filePath = source.path;
|
|
269
|
+
if (typeof filePath !== "string" || typeof source.text !== "object" || source.text === null || !("value" in source.text) || typeof source.text.value !== "string" || !source.text.value.startsWith("@")) {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const argument = source.text.value.slice(1);
|
|
273
|
+
const command = commands.find((candidate) => candidate.matchesSelection(argument, filePath) && text.includes(candidate.argumentPlaceholder));
|
|
274
|
+
if (command !== undefined) {
|
|
275
|
+
return {
|
|
276
|
+
argument,
|
|
277
|
+
argumentPlaceholder: command.argumentPlaceholder,
|
|
278
|
+
filePath,
|
|
279
|
+
retainsSelectedFileInPrompt: command.retainsSelectedFileInPrompt,
|
|
280
|
+
skill: command.skill
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
static async expand(output, template, config) {
|
|
286
|
+
const commands = FilePickerCommands.getCommands(config);
|
|
287
|
+
for (const part of output.parts) {
|
|
288
|
+
if (typeof part !== "object" || part === null || !("type" in part) || part.type !== "text" || !("text" in part) || typeof part.text !== "string" || "synthetic" in part && part.synthetic) {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
const skill = FilePickerCommands.findSkill(part.text, output.parts, config);
|
|
292
|
+
if (skill === undefined) {
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
part.text = (await template(skill.skill)).replaceAll(skill.argumentPlaceholder, `@${skill.argument}`).trim();
|
|
296
|
+
if (!skill.retainsSelectedFileInPrompt) {
|
|
297
|
+
FilePickerCommands.removeSelectedFile(output.parts, skill.filePath);
|
|
298
|
+
}
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
for (const part of output.parts) {
|
|
302
|
+
if (typeof part !== "object" || part === null || !("type" in part) || part.type !== "text" || !("text" in part) || typeof part.text !== "string" || "synthetic" in part && part.synthetic) {
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const skill = FilePickerCommands.expandedSkill(part.text, output.parts, commands);
|
|
306
|
+
if (skill === undefined) {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
part.text = part.text.replaceAll(skill.argumentPlaceholder, `@${skill.argument}`);
|
|
310
|
+
if (!skill.retainsSelectedFileInPrompt) {
|
|
311
|
+
FilePickerCommands.removeSelectedFile(output.parts, skill.filePath);
|
|
312
|
+
}
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
static workspaceRoot(worktree, directory) {
|
|
317
|
+
return worktree === "/" ? directory : worktree;
|
|
318
|
+
}
|
|
319
|
+
static setPrompt(prompt, root, command, option) {
|
|
320
|
+
const mention = `@${option.argument}`;
|
|
321
|
+
const input = `/${command.command} ${mention} `;
|
|
322
|
+
const start = Bun.stringWidth(`/${command.command} `);
|
|
323
|
+
prompt.set({
|
|
324
|
+
input,
|
|
325
|
+
mode: "normal",
|
|
326
|
+
parts: [
|
|
327
|
+
{
|
|
328
|
+
type: "file",
|
|
329
|
+
mime: "text/plain",
|
|
330
|
+
filename: option.filePath,
|
|
331
|
+
url: pathToFileURL(resolve(root, option.filePath)).href,
|
|
332
|
+
source: {
|
|
333
|
+
type: "file",
|
|
334
|
+
path: option.filePath,
|
|
335
|
+
text: {
|
|
336
|
+
start,
|
|
337
|
+
end: start + Bun.stringWidth(mention),
|
|
338
|
+
value: mention
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
]
|
|
343
|
+
});
|
|
344
|
+
prompt.focus();
|
|
345
|
+
}
|
|
346
|
+
static register(api, prompt, config) {
|
|
347
|
+
api.keymap.registerLayer({
|
|
348
|
+
commands: FilePickerCommands.getCommands(config).map((command) => ({
|
|
349
|
+
name: `encoredigitalgroup-opencode.${command.command}`,
|
|
350
|
+
title: command.title,
|
|
351
|
+
category: "Plugin",
|
|
352
|
+
namespace: "palette",
|
|
353
|
+
slashName: command.command,
|
|
354
|
+
run() {
|
|
355
|
+
const root = FilePickerCommands.workspaceRoot(api.state.path.worktree, api.state.path.directory);
|
|
356
|
+
const options = command.options(root, command.excludesPlanningDirectory);
|
|
357
|
+
if (options.length === 0) {
|
|
358
|
+
api.ui.toast({
|
|
359
|
+
variant: "warning",
|
|
360
|
+
message: command.emptyMessage
|
|
361
|
+
});
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const DialogSelect = api.ui.DialogSelect;
|
|
365
|
+
api.ui.dialog.setSize("medium");
|
|
366
|
+
api.ui.dialog.replace(() => createComponent(DialogSelect, {
|
|
367
|
+
title: command.title,
|
|
368
|
+
options: options.map((option) => ({ title: option.label, value: option })),
|
|
369
|
+
onSelect: (option) => {
|
|
370
|
+
api.ui.dialog.clear();
|
|
371
|
+
const promptRef = prompt();
|
|
372
|
+
if (promptRef === undefined) {
|
|
373
|
+
api.ui.toast({
|
|
374
|
+
variant: "error",
|
|
375
|
+
message: "The prompt is not ready."
|
|
376
|
+
});
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
FilePickerCommands.setPrompt(promptRef, root, command, option.value);
|
|
380
|
+
}
|
|
381
|
+
}));
|
|
382
|
+
}
|
|
383
|
+
}))
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// src/tui/index.tsx
|
|
389
|
+
import { jsx } from "@opentui/solid/jsx-runtime";
|
|
390
|
+
var plugin = {
|
|
391
|
+
id: "encoredigitalgroup-opencode",
|
|
392
|
+
tui: async (api, options) => {
|
|
393
|
+
let prompt;
|
|
394
|
+
const Prompt = api.ui.Prompt;
|
|
395
|
+
const capturePrompt = (value, ref) => {
|
|
396
|
+
prompt = value;
|
|
397
|
+
ref?.(value);
|
|
398
|
+
};
|
|
399
|
+
api.slots.register({
|
|
400
|
+
slots: {
|
|
401
|
+
home_prompt(_context, props) {
|
|
402
|
+
return /* @__PURE__ */ jsx(Prompt, {
|
|
403
|
+
ref: (value) => capturePrompt(value, props.ref)
|
|
404
|
+
});
|
|
405
|
+
},
|
|
406
|
+
session_prompt(_context, props) {
|
|
407
|
+
return /* @__PURE__ */ jsx(Prompt, {
|
|
408
|
+
sessionID: props.session_id,
|
|
409
|
+
visible: props.visible,
|
|
410
|
+
disabled: props.disabled,
|
|
411
|
+
onSubmit: props.on_submit,
|
|
412
|
+
ref: (value) => capturePrompt(value, props.ref)
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
FilePickerCommands.register(api, () => prompt, ConfigLoader.loadConfig(options));
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
var tui_default = plugin;
|
|
421
|
+
export {
|
|
422
|
+
tui_default as default
|
|
423
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@encoredigitalgroup/opencode",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "OpenCode file-picker commands for planning skills.",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"opencode",
|
|
11
|
+
"opencode-plugin",
|
|
12
|
+
"planning",
|
|
13
|
+
"skills"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"bun": ">=1.4.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@opencode-ai/plugin": "1.18.29",
|
|
20
|
+
"@opentui/core": "0.4.5",
|
|
21
|
+
"@opentui/keymap": "0.4.5",
|
|
22
|
+
"@opentui/solid": "0.4.5",
|
|
23
|
+
"@types/bun": "1.4.0",
|
|
24
|
+
"tsfmt": "^0.3.0-beta9",
|
|
25
|
+
"typescript": "5.8.2"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"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",
|
|
29
|
+
"check": "bun run typecheck && bun run build",
|
|
30
|
+
"pack:check": "bun pm pack --dry-run",
|
|
31
|
+
"typecheck": "tsc --noEmit"
|
|
32
|
+
},
|
|
33
|
+
"types": "./dist/server/index.d.ts",
|
|
34
|
+
"main": "./dist/server/index.js",
|
|
35
|
+
"exports": {
|
|
36
|
+
".": {
|
|
37
|
+
"types": "./dist/server/index.d.ts",
|
|
38
|
+
"default": "./dist/server/index.js"
|
|
39
|
+
},
|
|
40
|
+
"./server": {
|
|
41
|
+
"types": "./dist/server/index.d.ts",
|
|
42
|
+
"default": "./dist/server/index.js"
|
|
43
|
+
},
|
|
44
|
+
"./tui": {
|
|
45
|
+
"types": "./dist/tui/index.d.ts",
|
|
46
|
+
"default": "./dist/tui/index.js"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"files": [
|
|
50
|
+
"dist",
|
|
51
|
+
"README.md",
|
|
52
|
+
"LICENSE.md"
|
|
53
|
+
],
|
|
54
|
+
"license": "proprietary",
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"@opencode-ai/plugin": "^1.18.29",
|
|
57
|
+
"@opentui/core": "^0.4.5",
|
|
58
|
+
"@opentui/keymap": "^0.4.5",
|
|
59
|
+
"@opentui/solid": "^0.4.5"
|
|
60
|
+
},
|
|
61
|
+
"oc-plugin": [
|
|
62
|
+
"server",
|
|
63
|
+
"tui"
|
|
64
|
+
]
|
|
65
|
+
}
|