opencode-teammate 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/.bunli/commands.gen.ts +87 -0
- package/.github/workflows/ci.yml +31 -0
- package/.github/workflows/release.yml +140 -0
- package/.oxfmtrc.json +3 -0
- package/.oxlintrc.json +4 -0
- package/.zed/settings.json +76 -0
- package/README.md +15 -0
- package/bunli.config.ts +11 -0
- package/bunup.config.ts +31 -0
- package/package.json +36 -0
- package/src/adapters/assets/index.ts +1 -0
- package/src/adapters/assets/specifications.ts +70 -0
- package/src/adapters/beads/agents.ts +105 -0
- package/src/adapters/beads/config.ts +17 -0
- package/src/adapters/beads/index.ts +4 -0
- package/src/adapters/beads/issues.ts +156 -0
- package/src/adapters/beads/specifications.ts +55 -0
- package/src/adapters/environments/index.ts +43 -0
- package/src/adapters/environments/worktrees.ts +78 -0
- package/src/adapters/teammates/index.ts +15 -0
- package/src/assets/agent/planner.md +196 -0
- package/src/assets/command/brainstorm.md +60 -0
- package/src/assets/command/specify.md +135 -0
- package/src/assets/command/work.md +247 -0
- package/src/assets/index.ts +37 -0
- package/src/cli/commands/manifest.ts +6 -0
- package/src/cli/commands/spec/sync.ts +47 -0
- package/src/cli/commands/work.ts +110 -0
- package/src/cli/index.ts +11 -0
- package/src/plugin.ts +45 -0
- package/src/tools/i-am-done.ts +44 -0
- package/src/tools/i-am-stuck.ts +49 -0
- package/src/tools/index.ts +2 -0
- package/src/use-cases/index.ts +5 -0
- package/src/use-cases/inject-beads-issue.ts +97 -0
- package/src/use-cases/sync-specifications.ts +48 -0
- package/src/use-cases/sync-teammates.ts +35 -0
- package/src/use-cases/track-specs.ts +91 -0
- package/src/use-cases/work-on-issue.ts +110 -0
- package/src/utils/chain.ts +60 -0
- package/src/utils/frontmatter.spec.ts +491 -0
- package/src/utils/frontmatter.ts +317 -0
- package/src/utils/opencode.ts +102 -0
- package/src/utils/polling.ts +41 -0
- package/src/utils/projects.ts +35 -0
- package/src/utils/shell/client.spec.ts +106 -0
- package/src/utils/shell/client.ts +117 -0
- package/src/utils/shell/error.ts +29 -0
- package/src/utils/shell/index.ts +2 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { isString, isObject, isArray, isNullish } from "radashi";
|
|
2
|
+
|
|
3
|
+
export type Metadata = Record<string, unknown>;
|
|
4
|
+
|
|
5
|
+
export interface Options {
|
|
6
|
+
/** @default '---' */
|
|
7
|
+
delimiter?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parses frontmatter from a content string and returns the data object.
|
|
12
|
+
*
|
|
13
|
+
* Extracts YAML-formatted frontmatter from the beginning of a string, parsing it
|
|
14
|
+
* into a JavaScript object. The frontmatter must be enclosed by delimiter lines
|
|
15
|
+
* (default: `---`).
|
|
16
|
+
*
|
|
17
|
+
* @param content - The string containing frontmatter and optional content
|
|
18
|
+
* @param options - Configuration options
|
|
19
|
+
* @param options.delimiter - The delimiter string to use (default: '---')
|
|
20
|
+
*
|
|
21
|
+
* @returns Parsed frontmatter as a data object
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```ts
|
|
25
|
+
* const content = `---
|
|
26
|
+
* title: My Title
|
|
27
|
+
* description: My Description
|
|
28
|
+
* nested:
|
|
29
|
+
* key: value
|
|
30
|
+
* ---
|
|
31
|
+
*
|
|
32
|
+
* any other content`;
|
|
33
|
+
*
|
|
34
|
+
* const metadata = parse(content);
|
|
35
|
+
* // { title: "My Title", description: "My Description", nested: { key: "value" } }
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export function parse(content: string, options?: Options): Metadata {
|
|
39
|
+
const { metadata } = extract(content, options);
|
|
40
|
+
return metadata;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Extracts both frontmatter data and remaining content from a string.
|
|
45
|
+
*
|
|
46
|
+
* Similar to `parse()`, but also returns the content that appears after the
|
|
47
|
+
* closing frontmatter delimiter. Useful when you need to process both metadata
|
|
48
|
+
* and document body.
|
|
49
|
+
*
|
|
50
|
+
* @param content - The string containing frontmatter and content
|
|
51
|
+
* @param options - Configuration options
|
|
52
|
+
* @param options.delimiter - The delimiter string to use (default: '---')
|
|
53
|
+
*
|
|
54
|
+
* @returns An object containing both `metadata` (parsed frontmatter) and `content` (remaining text)
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```ts
|
|
58
|
+
* const content = `---
|
|
59
|
+
* title: My Title
|
|
60
|
+
* ---
|
|
61
|
+
*
|
|
62
|
+
* # Main Content
|
|
63
|
+
* This is the body.`;
|
|
64
|
+
*
|
|
65
|
+
* const result = extract(content);
|
|
66
|
+
* // {
|
|
67
|
+
* // metadata: { title: "My Title" },
|
|
68
|
+
* // content: "\n# Main Content\nThis is the body."
|
|
69
|
+
* // }
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
export function extract(
|
|
73
|
+
content: string,
|
|
74
|
+
options?: Options,
|
|
75
|
+
): { metadata: Metadata; content: string } {
|
|
76
|
+
const delimiter = options?.delimiter ?? "---";
|
|
77
|
+
|
|
78
|
+
// Check if content starts with delimiter (allowing leading whitespace)
|
|
79
|
+
if (!content.trimStart().startsWith(delimiter)) {
|
|
80
|
+
return { metadata: {}, content };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const lines = content.split("\n");
|
|
84
|
+
let startIndex = -1;
|
|
85
|
+
let endIndex = -1;
|
|
86
|
+
|
|
87
|
+
// Find the opening delimiter
|
|
88
|
+
for (let i = 0; i < lines.length; i++) {
|
|
89
|
+
if (lines[i]?.trim() === delimiter) {
|
|
90
|
+
startIndex = i;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// No valid opening delimiter found
|
|
96
|
+
if (startIndex === -1) {
|
|
97
|
+
return { metadata: {}, content };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Find the closing delimiter
|
|
101
|
+
for (let i = startIndex + 1; i < lines.length; i++) {
|
|
102
|
+
if (lines[i]?.trim() === delimiter) {
|
|
103
|
+
endIndex = i;
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// No valid closing delimiter found
|
|
109
|
+
if (endIndex === -1) {
|
|
110
|
+
return { metadata: {}, content };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Extract and parse the frontmatter content
|
|
114
|
+
const frontmatterLines = lines.slice(startIndex + 1, endIndex);
|
|
115
|
+
const yamlContent = frontmatterLines.join("\n");
|
|
116
|
+
const metadata = parseYAML(yamlContent);
|
|
117
|
+
|
|
118
|
+
// Extract remaining content after frontmatter
|
|
119
|
+
const remainingContent = lines
|
|
120
|
+
.slice(endIndex + 1)
|
|
121
|
+
.join("\n")
|
|
122
|
+
.replace(/^\n+/, "");
|
|
123
|
+
|
|
124
|
+
return { metadata, content: remainingContent };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Converts a data object into YAML-formatted frontmatter string.
|
|
129
|
+
*
|
|
130
|
+
* Serializes a JavaScript object into YAML format and wraps it with delimiter
|
|
131
|
+
* lines. The output can be prepended to content to create a complete document
|
|
132
|
+
* with frontmatter.
|
|
133
|
+
*
|
|
134
|
+
* @param data - The data object to convert to frontmatter
|
|
135
|
+
* @param options - Configuration options
|
|
136
|
+
* @param options.delimiter - The delimiter string to use (default: '---')
|
|
137
|
+
*
|
|
138
|
+
* @returns YAML frontmatter string with delimiters
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* ```ts
|
|
142
|
+
* const data = {
|
|
143
|
+
* title: "My Title",
|
|
144
|
+
* nested: { key: "value" }
|
|
145
|
+
* };
|
|
146
|
+
*
|
|
147
|
+
* const frontmatter = stringify(data);
|
|
148
|
+
* // ---
|
|
149
|
+
* // title: My Title
|
|
150
|
+
* // nested:
|
|
151
|
+
* // key: value
|
|
152
|
+
* // ---
|
|
153
|
+
* ```
|
|
154
|
+
*/
|
|
155
|
+
export function stringify(data: Metadata, options?: Options): string {
|
|
156
|
+
const delimiter = options?.delimiter ?? "---";
|
|
157
|
+
const yamlContent = stringifyYAML(data);
|
|
158
|
+
return `${delimiter}\n${yamlContent}${delimiter}\n`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function apply(content: string, data: Metadata, options?: Options): string {
|
|
162
|
+
const frontmatter = stringify(data, options);
|
|
163
|
+
return `${frontmatter}\n${content}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Parses a YAML string into a JavaScript object.
|
|
168
|
+
*
|
|
169
|
+
* This is a lightweight YAML parser that supports basic key-value pairs,
|
|
170
|
+
* nested objects, and common data types. It's designed specifically for
|
|
171
|
+
* frontmatter use cases.
|
|
172
|
+
*
|
|
173
|
+
* Supported features:
|
|
174
|
+
* - String, number, boolean, and null values
|
|
175
|
+
* - Nested objects (via indentation)
|
|
176
|
+
* - Single and double quoted strings
|
|
177
|
+
* - Comments (lines starting with #)
|
|
178
|
+
*
|
|
179
|
+
* @param yaml - The YAML string to parse
|
|
180
|
+
* @returns Parsed data object
|
|
181
|
+
*/
|
|
182
|
+
function parseYAML(yaml: string): Metadata {
|
|
183
|
+
const result: Metadata = {};
|
|
184
|
+
const lines = yaml.split("\n");
|
|
185
|
+
const stack: Array<{ obj: Metadata; indent: number }> = [{ obj: result, indent: -1 }];
|
|
186
|
+
|
|
187
|
+
for (const line of lines) {
|
|
188
|
+
// Skip empty lines and comments
|
|
189
|
+
if (!line.trim() || line.trim().startsWith("#")) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const indent = line.length - (line.trimStart()?.length ?? 0);
|
|
194
|
+
const trimmed = line.trim();
|
|
195
|
+
|
|
196
|
+
// Pop stack to find the correct parent based on indentation
|
|
197
|
+
while (stack.length > 1) {
|
|
198
|
+
const top = stack[stack.length - 1];
|
|
199
|
+
if (!top || indent > top.indent) {
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
stack.pop();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Parse key-value pair
|
|
206
|
+
const colonIndex = trimmed.indexOf(":");
|
|
207
|
+
if (colonIndex === -1) {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const key = trimmed.substring(0, colonIndex).trim();
|
|
212
|
+
const valueStr = trimmed.substring(colonIndex + 1).trim();
|
|
213
|
+
|
|
214
|
+
const currentObj = stack[stack.length - 1]?.obj;
|
|
215
|
+
if (!currentObj) continue;
|
|
216
|
+
|
|
217
|
+
if (valueStr === "") {
|
|
218
|
+
// Empty value indicates a nested object
|
|
219
|
+
const nestedObj: Metadata = {};
|
|
220
|
+
currentObj[key] = nestedObj;
|
|
221
|
+
stack.push({ obj: nestedObj, indent });
|
|
222
|
+
} else {
|
|
223
|
+
// Parse the value and assign it
|
|
224
|
+
if (currentObj) {
|
|
225
|
+
currentObj[key] = parseYAMLValue(valueStr);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return result;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Parses a YAML value string into its JavaScript type.
|
|
235
|
+
*
|
|
236
|
+
* Handles type coercion for strings, numbers, booleans, and null values.
|
|
237
|
+
* Quoted strings are unquoted, and special YAML values are converted.
|
|
238
|
+
*
|
|
239
|
+
* @param value - The YAML value string to parse
|
|
240
|
+
* @returns The parsed value with appropriate type
|
|
241
|
+
*/
|
|
242
|
+
function parseYAMLValue(value: string): unknown {
|
|
243
|
+
// Remove quotes from quoted strings
|
|
244
|
+
if (
|
|
245
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
246
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
247
|
+
) {
|
|
248
|
+
return value.slice(1, -1);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Parse boolean values
|
|
252
|
+
if (value === "true") return true;
|
|
253
|
+
if (value === "false") return false;
|
|
254
|
+
|
|
255
|
+
// Parse null/undefined
|
|
256
|
+
if (value === "null" || value === "~") return null;
|
|
257
|
+
|
|
258
|
+
// Try to parse as number
|
|
259
|
+
const num = Number(value);
|
|
260
|
+
if (!Number.isNaN(num) && value !== "") {
|
|
261
|
+
return num;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Return as string by default
|
|
265
|
+
return value;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Converts a JavaScript object to YAML format.
|
|
270
|
+
*
|
|
271
|
+
* Recursively serializes an object into YAML syntax with proper indentation.
|
|
272
|
+
* Handles nested objects, arrays, and various data types. Strings with special
|
|
273
|
+
* characters are automatically quoted.
|
|
274
|
+
*
|
|
275
|
+
* @param data - The data object to stringify
|
|
276
|
+
* @param indent - Current indentation level (internal use)
|
|
277
|
+
* @returns YAML formatted string
|
|
278
|
+
*/
|
|
279
|
+
function stringifyYAML(data: Metadata, indent = 0): string {
|
|
280
|
+
const indentStr = " ".repeat(indent);
|
|
281
|
+
let result = "";
|
|
282
|
+
|
|
283
|
+
for (const [key, value] of Object.entries(data)) {
|
|
284
|
+
// Handle null and undefined
|
|
285
|
+
if (isNullish(value)) {
|
|
286
|
+
result += `${indentStr}${key}: null\n`;
|
|
287
|
+
}
|
|
288
|
+
// Handle nested objects (but not arrays)
|
|
289
|
+
else if (isObject(value) && !isArray(value)) {
|
|
290
|
+
result += `${indentStr}${key}:\n`;
|
|
291
|
+
result += stringifyYAML(value as Metadata, indent + 1);
|
|
292
|
+
}
|
|
293
|
+
// Handle strings
|
|
294
|
+
else if (isString(value)) {
|
|
295
|
+
// Quote strings that contain special YAML characters
|
|
296
|
+
const needsQuotes = value.includes(":") || value.includes("#") || value.includes("\n");
|
|
297
|
+
result += `${indentStr}${key}: ${needsQuotes ? `"${value}"` : value}\n`;
|
|
298
|
+
}
|
|
299
|
+
// Handle arrays
|
|
300
|
+
else if (isArray(value)) {
|
|
301
|
+
result += `${indentStr}${key}:\n`;
|
|
302
|
+
for (const item of value) {
|
|
303
|
+
if (isObject(item) && !isArray(item)) {
|
|
304
|
+
result += `${indentStr}- \n${stringifyYAML(item as Metadata, indent + 2)}`;
|
|
305
|
+
} else {
|
|
306
|
+
result += `${indentStr} - ${item}\n`;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// Handle primitives (numbers, booleans, etc.)
|
|
311
|
+
else {
|
|
312
|
+
result += `${indentStr}${key}: ${value}\n`;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return result;
|
|
317
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { createOpencodeClient, type OpencodeClientConfig } from "@opencode-ai/sdk/v2/client";
|
|
2
|
+
import { assign } from "radashi";
|
|
3
|
+
|
|
4
|
+
const STORE = Bun.file(
|
|
5
|
+
`${Bun.env.HOME}/Library/Application Support/ai.opencode.desktop/store.json`,
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
interface ServerConfig {
|
|
9
|
+
username?: string;
|
|
10
|
+
password?: string;
|
|
11
|
+
hostname?: string;
|
|
12
|
+
port?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const context = {
|
|
16
|
+
client: Bun.env.OPENCODE_CLIENT ?? "unknown",
|
|
17
|
+
password: Bun.env.OPENCODE_SERVER_PASSWORD,
|
|
18
|
+
username: Bun.env.OPENCODE_SERVER_USERNAME,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export async function register(options: {
|
|
22
|
+
project?: { id?: string; worktree?: string };
|
|
23
|
+
directory?: string;
|
|
24
|
+
}) {
|
|
25
|
+
if (context.client === "desktop") {
|
|
26
|
+
const server = await getServer();
|
|
27
|
+
const config = {
|
|
28
|
+
username: context.username,
|
|
29
|
+
password: context.password,
|
|
30
|
+
seenAt: new Date(),
|
|
31
|
+
directory: options.directory,
|
|
32
|
+
project: options.project,
|
|
33
|
+
...server,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
await Bun.write(STORE, JSON.stringify(config));
|
|
37
|
+
|
|
38
|
+
return createOpencodeClient(createOpencodeClientConfigFromServerConfig(config));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return createOpencodeClient({
|
|
42
|
+
directory: options.directory,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ClientClientOptions extends OpencodeClientConfig {
|
|
47
|
+
directory?: string;
|
|
48
|
+
loopUpForDesktopServer?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function createClient(options: ClientClientOptions) {
|
|
52
|
+
if (options.loopUpForDesktopServer) {
|
|
53
|
+
const registered = await createRegisteredServerConfig();
|
|
54
|
+
|
|
55
|
+
if (registered) {
|
|
56
|
+
const client = createOpencodeClient(assign(options, registered ?? {}));
|
|
57
|
+
|
|
58
|
+
const health = await client.global.health();
|
|
59
|
+
if (health.data?.healthy) return client;
|
|
60
|
+
else console.warn("Desktop server is not healthy, re-launch Opencode app");
|
|
61
|
+
} else console.warn("Desktop server is not registered, launch Opencode app");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return createOpencodeClient(options);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function createRegisteredServerConfig() {
|
|
68
|
+
const exists = await STORE.exists();
|
|
69
|
+
if (!exists) return undefined;
|
|
70
|
+
|
|
71
|
+
const config: ServerConfig = await STORE.json();
|
|
72
|
+
|
|
73
|
+
return createOpencodeClientConfigFromServerConfig(config);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function createOpencodeClientConfigFromServerConfig(
|
|
77
|
+
server: ServerConfig,
|
|
78
|
+
): OpencodeClientConfig | undefined {
|
|
79
|
+
return {
|
|
80
|
+
baseUrl: `http://${server.hostname}:${server.port}`,
|
|
81
|
+
headers: {
|
|
82
|
+
Authorization: `Basic ${btoa(`${server.username}:${server.password}`)}`,
|
|
83
|
+
},
|
|
84
|
+
} satisfies OpencodeClientConfig;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function getServer() {
|
|
88
|
+
const process =
|
|
89
|
+
await Bun.$`ps ax -o pid=,command= | grep "opencode-cli " | grep "serve" | grep -v grep`
|
|
90
|
+
.nothrow()
|
|
91
|
+
.text();
|
|
92
|
+
|
|
93
|
+
const pid = process.match(/^(\d+)\s+/)?.[1];
|
|
94
|
+
const hostname = process.match(/--hostname\s+([\S]+)/)?.[1];
|
|
95
|
+
const port = process.match(/--port\s+(\d+)/)?.[1];
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
pid: pid ? parseInt(pid, 10) : undefined,
|
|
99
|
+
hostname,
|
|
100
|
+
port: port ? parseInt(port, 10) : undefined,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type DurationString, isEmpty, parseDuration, sleep } from "radashi";
|
|
2
|
+
|
|
3
|
+
export interface PollOptions {
|
|
4
|
+
interval?: DurationString;
|
|
5
|
+
/** Stop polling when the result is empty or undefined */
|
|
6
|
+
exitOnEmpty?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function poll<T extends Array<unknown>>(
|
|
10
|
+
fn: () => Promise<T | undefined>,
|
|
11
|
+
options?: PollOptions,
|
|
12
|
+
): AsyncGenerator<T[number]>;
|
|
13
|
+
|
|
14
|
+
export function poll<T>(fn: () => Promise<T | undefined>, options?: PollOptions): AsyncGenerator<T>;
|
|
15
|
+
|
|
16
|
+
export async function* poll(fn: () => Promise<unknown>, options?: PollOptions) {
|
|
17
|
+
const interval = parseDuration(options?.interval ?? "1s");
|
|
18
|
+
|
|
19
|
+
while (true) {
|
|
20
|
+
const result = await fn();
|
|
21
|
+
|
|
22
|
+
if (Array.isArray(result)) {
|
|
23
|
+
if (isEmpty(result) && options?.exitOnEmpty) break;
|
|
24
|
+
yield* result;
|
|
25
|
+
} else if (result === undefined && options?.exitOnEmpty) {
|
|
26
|
+
break;
|
|
27
|
+
} else yield result;
|
|
28
|
+
|
|
29
|
+
await sleep(interval);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type WaitForOptions = Omit<PollOptions, "exitOnEmpty">;
|
|
34
|
+
|
|
35
|
+
export async function waitFor<T>(fn: () => Promise<T | undefined>, options?: WaitForOptions) {
|
|
36
|
+
for await (const result of poll(fn, options)) {
|
|
37
|
+
if (result) return result;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
throw Error("Timeout: waiting for condition");
|
|
41
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { OpencodeClient, Project } from "@opencode-ai/sdk/v2/client";
|
|
2
|
+
import { prompt } from "@bunli/utils";
|
|
3
|
+
import { assert } from "radashi";
|
|
4
|
+
|
|
5
|
+
export async function use(client: OpencodeClient, directory?: string) {
|
|
6
|
+
const { data: project } = await client.project.current({
|
|
7
|
+
directory: directory ?? process.cwd(),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
if (project && project.id !== "global") return project.worktree;
|
|
11
|
+
|
|
12
|
+
const { data: projects, error } = await client.project.list();
|
|
13
|
+
assert(error === undefined, `Failed to list projects: ${error}`);
|
|
14
|
+
assert(projects, `No projects found`);
|
|
15
|
+
assert(projects.length > 0, `No projects found: ${projects.length}`);
|
|
16
|
+
|
|
17
|
+
return prompt.select("Select a project", {
|
|
18
|
+
options: projects.map((project) => ({
|
|
19
|
+
label: project.name ?? project.worktree,
|
|
20
|
+
value: project.worktree,
|
|
21
|
+
hint: project.name
|
|
22
|
+
? `${project.worktree} - ${project.id.slice(0, 5)}`
|
|
23
|
+
: project.id.slice(0, 5),
|
|
24
|
+
})),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getProjectLabel(project: Project) {
|
|
29
|
+
return project.name ?? project.worktree;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getProjectHint(project: Project) {
|
|
33
|
+
const shortId = project.id.slice(0, 5);
|
|
34
|
+
return project.name ? `${project.worktree} - ${shortId}` : shortId;
|
|
35
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, expect, expectTypeOf, it } from "bun:test";
|
|
2
|
+
import { $ } from "bun";
|
|
3
|
+
import { toResult } from "radashi";
|
|
4
|
+
|
|
5
|
+
import * as client from "./client";
|
|
6
|
+
import { InvalidExitCodeError, ShellError } from "./error";
|
|
7
|
+
|
|
8
|
+
const PRINT_FLAGS_SH = `
|
|
9
|
+
printf "%d:" "$#"
|
|
10
|
+
for arg in "$@"; do
|
|
11
|
+
printf " <%s>" "$arg"
|
|
12
|
+
done
|
|
13
|
+
echo ""
|
|
14
|
+
`;
|
|
15
|
+
|
|
16
|
+
describe("shell client wrapper", () => {
|
|
17
|
+
it("returns the same client when already a client", () => {
|
|
18
|
+
const shell = client.create($);
|
|
19
|
+
expect(client.use(shell)).toBe(shell);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("formats CLI options and positional arguments", async () => {
|
|
23
|
+
const shell = client.create($);
|
|
24
|
+
|
|
25
|
+
const [, output] = await shell`bash -c ${PRINT_FLAGS_SH} -- ${"pos"} ${{
|
|
26
|
+
flag: true,
|
|
27
|
+
count: 3,
|
|
28
|
+
list: ["a", "b"],
|
|
29
|
+
name: "hello world",
|
|
30
|
+
skip: undefined,
|
|
31
|
+
}}`.text();
|
|
32
|
+
|
|
33
|
+
expect(output).toBe("5: <pos> <--flag> <--count='3'> <--list=a,b> <--name='hello world'>");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("ignores empty options objects", async () => {
|
|
37
|
+
const shell = client.create($);
|
|
38
|
+
const [, output] = await shell`bash -c ${PRINT_FLAGS_SH} -- pos ${{}}`.text();
|
|
39
|
+
|
|
40
|
+
expect(output).toBe("1: <pos>");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("parses JSON output", async () => {
|
|
44
|
+
const shell = client.create($);
|
|
45
|
+
type Output = { ok: true };
|
|
46
|
+
|
|
47
|
+
const output = await shell`echo '{\"ok\":true}'`.json<Output>();
|
|
48
|
+
expect(output).toEqual({ ok: true });
|
|
49
|
+
expectTypeOf(output).toEqualTypeOf<Output>();
|
|
50
|
+
|
|
51
|
+
const second = await shell<Output>`echo '{\"ok\":true}'`.json();
|
|
52
|
+
expectTypeOf(second).toEqualTypeOf(output);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns an error when stdout JSON parsing fails", async () => {
|
|
56
|
+
const shell = client.create($);
|
|
57
|
+
|
|
58
|
+
const [error] = await toResult(shell`echo 'not json'`.json());
|
|
59
|
+
|
|
60
|
+
expect(error).toBeInstanceOf(ShellError);
|
|
61
|
+
expect(error?.message).toBe("not json");
|
|
62
|
+
expect(error?.cause).toBeInstanceOf(SyntaxError);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("falls back to raw stderr when JSON parsing is failing", async () => {
|
|
66
|
+
const shell = client.create($);
|
|
67
|
+
|
|
68
|
+
const [error] = await toResult(shell`echo "This is an error message" 2> /dev/stderr`.json());
|
|
69
|
+
|
|
70
|
+
expect(error).toBeInstanceOf(ShellError);
|
|
71
|
+
expect(error?.message).toBe("This is an error message");
|
|
72
|
+
expect(error?.cause).toBeInstanceOf(SyntaxError);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("uses chained configuration methods with real commands", async () => {
|
|
76
|
+
const shell = client.create($);
|
|
77
|
+
|
|
78
|
+
const [, pwd] = await shell.cwd(import.meta.dir)`pwd`.text();
|
|
79
|
+
expect(pwd).toBe(import.meta.dir);
|
|
80
|
+
|
|
81
|
+
const [, value] = await shell.env({
|
|
82
|
+
TEST_ENV: "works",
|
|
83
|
+
})`printenv TEST_ENV`.text();
|
|
84
|
+
expect(value).toBe("works");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("keeps the bun shell context", async () => {
|
|
88
|
+
const shell = client.create($.cwd(import.meta.dir).env({ TEST_ENV: "works" }));
|
|
89
|
+
|
|
90
|
+
const [, pwd] = await shell`pwd`.text();
|
|
91
|
+
expect(pwd).toBe(import.meta.dir);
|
|
92
|
+
|
|
93
|
+
const [, value] = await shell`printenv TEST_ENV`.text();
|
|
94
|
+
expect(value).toBe("works");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("text() output returns error on exit 1", async () => {
|
|
98
|
+
const shell = client.create($);
|
|
99
|
+
|
|
100
|
+
const [error, output] = await shell`exit 1`.text();
|
|
101
|
+
expect(output).toBeUndefined();
|
|
102
|
+
expect(error).toBeInstanceOf(ShellError);
|
|
103
|
+
expect(error?.message).toBe("Unknown shell error");
|
|
104
|
+
expect(error?.cause).toBeInstanceOf(InvalidExitCodeError);
|
|
105
|
+
});
|
|
106
|
+
});
|