devvami 1.0.0 → 1.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.
@@ -0,0 +1,123 @@
1
+ import { createOctokit } from './github.js'
2
+ import { DvmiError } from '../utils/errors.js'
3
+
4
+ /** @import { AwesomeEntry } from '../types.js' */
5
+
6
+ /**
7
+ * Supported categories in github/awesome-copilot.
8
+ * Each maps to `docs/README.<category>.md` in the repository.
9
+ * @type {string[]}
10
+ */
11
+ export const AWESOME_CATEGORIES = ['agents', 'instructions', 'skills', 'plugins', 'hooks', 'workflows']
12
+
13
+ const AWESOME_REPO = { owner: 'github', repo: 'awesome-copilot' }
14
+
15
+ /**
16
+ * Parse a GitHub-flavoured markdown table into AwesomeEntry objects.
17
+ *
18
+ * Expects at least two columns: `| Name/Link | Description |`
19
+ * The first column may contain `[text](url)` markdown links; badge images
20
+ * (e.g. `[![foo](img)](url)`) are stripped so only the text survives.
21
+ *
22
+ * @param {string} md - Raw markdown content of the file
23
+ * @param {string} category - Category label attached to every returned entry
24
+ * @returns {AwesomeEntry[]}
25
+ */
26
+ export function parseMarkdownTable(md, category) {
27
+ /** @type {AwesomeEntry[]} */
28
+ const entries = []
29
+
30
+ for (const line of md.split('\n')) {
31
+ // Only process table data rows (start + end with |, not separator rows)
32
+ const trimmed = line.trim()
33
+ if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) continue
34
+ if (/^\|[\s\-:|]+\|/.test(trimmed)) continue // header separator
35
+
36
+ const cells = trimmed
37
+ .slice(1, -1) // remove leading and trailing |
38
+ .split('|')
39
+ .map((c) => c.trim())
40
+
41
+ if (cells.length < 2) continue
42
+
43
+ const rawName = cells[0]
44
+ const description = cells[1] ?? ''
45
+
46
+ // Skip header row (first column is literally "Name" or similar)
47
+ if (/^[\*_]?name[\*_]?$/i.test(rawName)) continue
48
+
49
+ // Strip badge images: [![alt](img)](url) → keep nothing; [![alt](img)] → keep nothing
50
+ const noBadge = rawName.replace(/\[!\[.*?\]\(.*?\)\]\(.*?\)/g, '').replace(/!\[.*?\]\(.*?\)/g, '').trim()
51
+
52
+ // Extract [text](url) link
53
+ const linkMatch = noBadge.match(/\[([^\]]+)\]\(([^)]+)\)/)
54
+ const name = linkMatch ? linkMatch[1].trim() : noBadge.replace(/\[|\]/g, '').trim()
55
+ const url = linkMatch ? linkMatch[2].trim() : ''
56
+
57
+ if (!name) continue
58
+
59
+ entries.push(
60
+ /** @type {AwesomeEntry} */ ({
61
+ name,
62
+ description: description.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').trim(),
63
+ url,
64
+ category,
65
+ source: 'awesome-copilot',
66
+ }),
67
+ )
68
+ }
69
+
70
+ return entries
71
+ }
72
+
73
+ /**
74
+ * Fetch awesome-copilot entries for a given category from GitHub.
75
+ *
76
+ * Retrieves `docs/README.<category>.md` from the `github/awesome-copilot`
77
+ * repository and parses the markdown table into AwesomeEntry objects.
78
+ *
79
+ * @param {string} category - One of {@link AWESOME_CATEGORIES}
80
+ * @returns {Promise<AwesomeEntry[]>}
81
+ * @throws {DvmiError} when category is invalid, file not found, or auth is missing
82
+ */
83
+ export async function fetchAwesomeEntries(category) {
84
+ if (!AWESOME_CATEGORIES.includes(category)) {
85
+ throw new DvmiError(
86
+ `Unknown awesome-copilot category: "${category}"`,
87
+ `Valid categories: ${AWESOME_CATEGORIES.join(', ')}`,
88
+ )
89
+ }
90
+
91
+ const octokit = await createOctokit()
92
+ const path = `docs/README.${category}.md`
93
+
94
+ let data
95
+ try {
96
+ const res = await octokit.rest.repos.getContent({
97
+ owner: AWESOME_REPO.owner,
98
+ repo: AWESOME_REPO.repo,
99
+ path,
100
+ })
101
+ data = res.data
102
+ } catch (err) {
103
+ const status = /** @type {{ status?: number }} */ (err).status
104
+ if (status === 404) {
105
+ throw new DvmiError(
106
+ `Category file not found: ${path}`,
107
+ `Check available categories: ${AWESOME_CATEGORIES.join(', ')}`,
108
+ )
109
+ }
110
+ throw err
111
+ }
112
+
113
+ const file = /** @type {{ content?: string, encoding?: string }} */ (data)
114
+ if (!file.content || file.encoding !== 'base64') {
115
+ throw new DvmiError(
116
+ `Unable to read awesome-copilot category: ${category}`,
117
+ 'The file may be a directory or have an unsupported encoding',
118
+ )
119
+ }
120
+
121
+ const md = Buffer.from(file.content.replace(/\n/g, ''), 'base64').toString('utf8')
122
+ return parseMarkdownTable(md, category)
123
+ }
@@ -20,7 +20,8 @@ async function getToken() {
20
20
  */
21
21
  export async function createOctokit() {
22
22
  const token = await getToken()
23
- return new Octokit({ auth: token })
23
+ const baseUrl = process.env.GITHUB_API_URL ?? 'https://api.github.com'
24
+ return new Octokit({ auth: token, baseUrl })
24
25
  }
25
26
 
26
27
  /**
@@ -0,0 +1,307 @@
1
+ import { mkdir, writeFile, readFile, access } from 'node:fs/promises'
2
+ import { join, dirname } from 'node:path'
3
+ import { execa } from 'execa'
4
+ import { createOctokit } from './github.js'
5
+ import { which } from './shell.js'
6
+ import { parseFrontmatter, serializeFrontmatter } from '../utils/frontmatter.js'
7
+ import { DvmiError } from '../utils/errors.js'
8
+
9
+ /** @import { Prompt, AITool } from '../types.js' */
10
+
11
+ /**
12
+ * Supported AI tools and their invocation configuration.
13
+ * @type {Record<AITool, { bin: string[], promptFlag: string }>}
14
+ */
15
+ export const SUPPORTED_TOOLS = {
16
+ opencode: { bin: ['opencode'], promptFlag: '--prompt' },
17
+ copilot: { bin: ['gh', 'copilot'], promptFlag: '-p' },
18
+ }
19
+
20
+ /**
21
+ * GitHub repository containing the personal prompt collection.
22
+ * @type {{ owner: string, repo: string }}
23
+ */
24
+ export const PROMPT_REPO = { owner: 'savez', repo: 'prompt-for-ai' }
25
+
26
+ /**
27
+ * Default branch used when fetching the repository tree.
28
+ * @type {string}
29
+ */
30
+ const DEFAULT_BRANCH = 'HEAD'
31
+
32
+ /**
33
+ * Known GitHub repository meta-files that should never appear as prompts.
34
+ * Matched case-insensitively against the final path component.
35
+ * @type {Set<string>}
36
+ */
37
+ const EXCLUDED_FILENAMES = new Set([
38
+ 'readme.md',
39
+ 'contributing.md',
40
+ 'pull_request_template.md',
41
+ 'changelog.md',
42
+ 'license.md',
43
+ 'code_of_conduct.md',
44
+ 'security.md',
45
+ ])
46
+
47
+ /**
48
+ * Derive a human-readable title from a file path when no frontmatter title exists.
49
+ * @param {string} filePath - Relative path, e.g. "coding/refactor-prompt.md"
50
+ * @returns {string}
51
+ */
52
+ function titleFromPath(filePath) {
53
+ const filename = filePath.split('/').pop() ?? filePath
54
+ return filename
55
+ .replace(/\.md$/i, '')
56
+ .replace(/[-_]/g, ' ')
57
+ .replace(/\b\w/g, (c) => c.toUpperCase())
58
+ }
59
+
60
+ /**
61
+ * Derive the category (top-level directory) from a file path.
62
+ * @param {string} filePath
63
+ * @returns {string|undefined}
64
+ */
65
+ function categoryFromPath(filePath) {
66
+ const parts = filePath.split('/')
67
+ return parts.length > 1 ? parts[0] : undefined
68
+ }
69
+
70
+ /**
71
+ * Map raw GitHub file content (base64-encoded) to a Prompt object.
72
+ * @param {string} path - Relative path in the repo
73
+ * @param {string} base64Content - Base64-encoded file content from GitHub API
74
+ * @returns {Prompt}
75
+ */
76
+ function contentToPrompt(path, base64Content) {
77
+ const raw = Buffer.from(base64Content, 'base64').toString('utf8')
78
+ const { frontmatter, body } = parseFrontmatter(raw)
79
+ return {
80
+ path,
81
+ title: typeof frontmatter.title === 'string' ? frontmatter.title : titleFromPath(path),
82
+ category: typeof frontmatter.category === 'string' ? frontmatter.category : categoryFromPath(path),
83
+ description: typeof frontmatter.description === 'string' ? frontmatter.description : undefined,
84
+ tags: Array.isArray(frontmatter.tags) ? /** @type {string[]} */ (frontmatter.tags) : [],
85
+ body,
86
+ author: typeof frontmatter.author === 'string' ? frontmatter.author : undefined,
87
+ version: typeof frontmatter.version === 'string' ? String(frontmatter.version) : undefined,
88
+ }
89
+ }
90
+
91
+ /**
92
+ * List all prompts from the personal prompt repository.
93
+ *
94
+ * Fetches the full git tree (recursive) to find all `.md` files,
95
+ * then fetches each file's content to parse frontmatter.
96
+ *
97
+ * @returns {Promise<Prompt[]>}
98
+ * @throws {DvmiError} when GitHub authentication is missing or the repo is not found
99
+ */
100
+ export async function listPrompts() {
101
+ const octokit = await createOctokit()
102
+ let tree
103
+ try {
104
+ const { data } = await octokit.rest.git.getTree({
105
+ owner: PROMPT_REPO.owner,
106
+ repo: PROMPT_REPO.repo,
107
+ tree_sha: DEFAULT_BRANCH,
108
+ recursive: '1',
109
+ })
110
+ tree = data.tree
111
+ } catch (err) {
112
+ const status = /** @type {{ status?: number }} */ (err).status
113
+ if (status === 404) {
114
+ throw new DvmiError(
115
+ `Repository ${PROMPT_REPO.owner}/${PROMPT_REPO.repo} not found`,
116
+ 'Ensure the repository exists and you have access to it',
117
+ )
118
+ }
119
+ throw err
120
+ }
121
+
122
+ const mdFiles = tree.filter(
123
+ (item) =>
124
+ item.type === 'blob' &&
125
+ item.path?.endsWith('.md') &&
126
+ !EXCLUDED_FILENAMES.has(item.path.split('/').pop()?.toLowerCase() ?? ''),
127
+ )
128
+
129
+ if (mdFiles.length === 0) {
130
+ return []
131
+ }
132
+
133
+ const prompts = await Promise.all(
134
+ mdFiles.map(async (item) => {
135
+ const { data } = await octokit.rest.repos.getContent({
136
+ owner: PROMPT_REPO.owner,
137
+ repo: PROMPT_REPO.repo,
138
+ path: item.path ?? '',
139
+ })
140
+ // getContent returns a single file object when path is a file
141
+ const file = /** @type {{ content?: string, encoding?: string }} */ (data)
142
+ if (!file.content || file.encoding !== 'base64') {
143
+ return null
144
+ }
145
+ return contentToPrompt(item.path ?? '', file.content.replace(/\n/g, ''))
146
+ }),
147
+ )
148
+
149
+ return /** @type {Prompt[]} */ (prompts.filter(Boolean))
150
+ }
151
+
152
+ /**
153
+ * Fetch a single prompt by its relative path in the repository.
154
+ *
155
+ * @param {string} relativePath - Path relative to repo root (e.g. "coding/refactor-prompt.md")
156
+ * @returns {Promise<Prompt>}
157
+ * @throws {DvmiError} when the file is not found or authentication is missing
158
+ */
159
+ export async function fetchPromptByPath(relativePath) {
160
+ const octokit = await createOctokit()
161
+ let data
162
+ try {
163
+ const res = await octokit.rest.repos.getContent({
164
+ owner: PROMPT_REPO.owner,
165
+ repo: PROMPT_REPO.repo,
166
+ path: relativePath,
167
+ })
168
+ data = res.data
169
+ } catch (err) {
170
+ const status = /** @type {{ status?: number }} */ (err).status
171
+ if (status === 404) {
172
+ throw new DvmiError(
173
+ `Prompt not found: ${relativePath}`,
174
+ `Run \`dvmi prompts list\` to see available prompts`,
175
+ )
176
+ }
177
+ throw err
178
+ }
179
+
180
+ const file = /** @type {{ content?: string, encoding?: string }} */ (data)
181
+ if (!file.content || file.encoding !== 'base64') {
182
+ throw new DvmiError(
183
+ `Unable to read prompt: ${relativePath}`,
184
+ 'The file may be a directory or have an unsupported encoding',
185
+ )
186
+ }
187
+
188
+ return contentToPrompt(relativePath, file.content.replace(/\n/g, ''))
189
+ }
190
+
191
+ /**
192
+ * Download a prompt from the repository to a local directory.
193
+ *
194
+ * If the destination file already exists and `opts.overwrite` is not `true`,
195
+ * the function returns immediately with `{ skipped: true }` without making
196
+ * any network request.
197
+ *
198
+ * @param {string} relativePath - Path relative to repo root (e.g. "coding/refactor-prompt.md")
199
+ * @param {string} localDir - Local base directory (e.g. "/project/.prompts")
200
+ * @param {{ overwrite?: boolean }} [opts]
201
+ * @returns {Promise<{ path: string, skipped: boolean }>}
202
+ * @throws {DvmiError} when the prompt is not found or the write fails
203
+ */
204
+ export async function downloadPrompt(relativePath, localDir, opts = {}) {
205
+ const destPath = join(localDir, relativePath)
206
+
207
+ // Fast-path: skip without a network round-trip if file exists and no overwrite
208
+ if (!opts.overwrite) {
209
+ try {
210
+ await access(destPath)
211
+ return { path: destPath, skipped: true }
212
+ } catch {
213
+ // File does not exist — fall through to download
214
+ }
215
+ }
216
+
217
+ const prompt = await fetchPromptByPath(relativePath)
218
+
219
+ // Re-build frontmatter from the known Prompt fields
220
+ /** @type {Record<string, unknown>} */
221
+ const fm = {}
222
+ if (prompt.title) fm.title = prompt.title
223
+ if (prompt.category) fm.category = prompt.category
224
+ if (prompt.description) fm.description = prompt.description
225
+ if (prompt.tags?.length) fm.tags = prompt.tags
226
+ if (prompt.author) fm.author = prompt.author
227
+ if (prompt.version) fm.version = prompt.version
228
+
229
+ const content = serializeFrontmatter(fm, prompt.body)
230
+
231
+ await mkdir(dirname(destPath), { recursive: true })
232
+ await writeFile(destPath, content, 'utf8')
233
+
234
+ return { path: destPath, skipped: false }
235
+ }
236
+
237
+ /**
238
+ * Read and parse a prompt from the local prompt store.
239
+ *
240
+ * @param {string} relativePath - Prompt path relative to the local store root
241
+ * (e.g. "coding/refactor-prompt.md")
242
+ * @param {string} localDir - Absolute path to the local prompts directory
243
+ * @returns {Promise<import('../types.js').Prompt>}
244
+ * @throws {DvmiError} when the file does not exist or cannot be parsed
245
+ */
246
+ export async function resolveLocalPrompt(relativePath, localDir) {
247
+ const fullPath = join(localDir, relativePath)
248
+ let raw
249
+ try {
250
+ raw = await readFile(fullPath, 'utf8')
251
+ } catch {
252
+ throw new DvmiError(
253
+ `Local prompt not found: ${relativePath}`,
254
+ `Run \`dvmi prompts download ${relativePath}\` to download it first`,
255
+ )
256
+ }
257
+
258
+ const { frontmatter, body } = parseFrontmatter(raw)
259
+ return {
260
+ path: relativePath,
261
+ title: typeof frontmatter.title === 'string' ? frontmatter.title : titleFromPath(relativePath),
262
+ category: typeof frontmatter.category === 'string' ? frontmatter.category : categoryFromPath(relativePath),
263
+ description: typeof frontmatter.description === 'string' ? frontmatter.description : undefined,
264
+ tags: Array.isArray(frontmatter.tags) ? /** @type {string[]} */ (frontmatter.tags) : [],
265
+ body,
266
+ author: typeof frontmatter.author === 'string' ? frontmatter.author : undefined,
267
+ version: typeof frontmatter.version === 'string' ? String(frontmatter.version) : undefined,
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Invoke a supported AI tool with the given prompt content.
273
+ *
274
+ * Verifies the tool binary is available in PATH before spawning.
275
+ * Spawns with `stdio: 'inherit'` so the tool's UI is displayed directly.
276
+ *
277
+ * @param {AITool} toolName - Tool key from {@link SUPPORTED_TOOLS}
278
+ * @param {string} promptContent - The full prompt text to pass to the tool
279
+ * @returns {Promise<void>}
280
+ * @throws {DvmiError} when the tool is unknown or the binary is not in PATH
281
+ */
282
+ export async function invokeTool(toolName, promptContent) {
283
+ const tool = SUPPORTED_TOOLS[toolName]
284
+ if (!tool) {
285
+ throw new DvmiError(
286
+ `Unknown AI tool: "${toolName}"`,
287
+ `Supported tools: ${Object.keys(SUPPORTED_TOOLS).join(', ')}`,
288
+ )
289
+ }
290
+
291
+ // Verify binary availability
292
+ const [bin, ...subArgs] = tool.bin
293
+ const binPath = await which(bin)
294
+ if (!binPath) {
295
+ const installHints = {
296
+ opencode: 'Install opencode: npm install -g opencode',
297
+ copilot: 'Install GitHub CLI: https://cli.github.com',
298
+ }
299
+ throw new DvmiError(
300
+ `${bin} is not installed or not in PATH`,
301
+ installHints[toolName] ?? `Install ${bin} and ensure it is in your PATH`,
302
+ )
303
+ }
304
+
305
+ // Spawn tool with prompt content — inherits stdio so TUI/interactive tools work
306
+ await execa(bin, [...subArgs, tool.promptFlag, promptContent], { stdio: 'inherit' })
307
+ }
@@ -0,0 +1,81 @@
1
+ import { DvmiError } from '../utils/errors.js'
2
+
3
+ /** @import { Skill } from '../types.js' */
4
+
5
+ const SKILLS_SH_DEFAULT = 'https://skills.sh'
6
+
7
+ /**
8
+ * Base URL for the skills.sh API.
9
+ * Overridable via SKILLS_SH_BASE_URL env var (used in tests).
10
+ * @returns {string}
11
+ */
12
+ function skillsBaseUrl() {
13
+ return process.env.SKILLS_SH_BASE_URL ?? SKILLS_SH_DEFAULT
14
+ }
15
+
16
+ /**
17
+ * Search for skills on skills.sh.
18
+ *
19
+ * @param {string} query - Search query string (must be at least 2 characters)
20
+ * @param {number} [limit=50] - Maximum number of results
21
+ * @returns {Promise<Skill[]>}
22
+ * @throws {DvmiError} when query is too short, the API is unreachable or returns an unexpected response
23
+ */
24
+ export async function searchSkills(query, limit = 50) {
25
+ if (!query || query.length < 2) {
26
+ throw new DvmiError(
27
+ 'skills.sh requires a search query of at least 2 characters',
28
+ 'Use --query to search, e.g. dvmi prompts browse skills --query refactor',
29
+ )
30
+ }
31
+
32
+ const url = new URL('/api/search', skillsBaseUrl())
33
+ url.searchParams.set('q', query)
34
+ url.searchParams.set('limit', String(limit))
35
+
36
+ let res
37
+ try {
38
+ res = await fetch(url.toString())
39
+ } catch {
40
+ throw new DvmiError(
41
+ 'Unable to reach skills.sh API',
42
+ 'Check your internet connection and try again',
43
+ )
44
+ }
45
+
46
+ if (!res.ok) {
47
+ throw new DvmiError(
48
+ `skills.sh API returned ${res.status}`,
49
+ 'Try again later or visit https://skills.sh',
50
+ )
51
+ }
52
+
53
+ /** @type {unknown} */
54
+ let json
55
+ try {
56
+ json = await res.json()
57
+ } catch {
58
+ throw new DvmiError('Unexpected response from skills.sh', 'Try again later')
59
+ }
60
+
61
+ // skills.sh returns { skills: [...], count, ... } — also handle legacy { results: [...] } or plain array
62
+ const items = Array.isArray(json)
63
+ ? json
64
+ : Array.isArray(/** @type {Record<string,unknown>} */ (json)?.skills)
65
+ ? /** @type {Record<string,unknown>[]} */ (/** @type {Record<string,unknown>} */ (json).skills)
66
+ : Array.isArray(/** @type {Record<string,unknown>} */ (json)?.results)
67
+ ? /** @type {Record<string,unknown>[]} */ (/** @type {Record<string,unknown>} */ (json).results)
68
+ : []
69
+
70
+ return items.map((item) => {
71
+ const s = /** @type {Record<string, unknown>} */ (item)
72
+ return /** @type {Skill} */ ({
73
+ id: String(s.id ?? s.slug ?? ''),
74
+ name: String(s.name ?? s.title ?? ''),
75
+ description: typeof s.description === 'string' ? s.description : undefined,
76
+ installs: typeof s.installs === 'number' ? s.installs : undefined,
77
+ url: typeof s.url === 'string' ? s.url : `https://skills.sh/skills/${s.id ?? s.slug ?? ''}`,
78
+ source: 'skills.sh',
79
+ })
80
+ })
81
+ }
@@ -0,0 +1,73 @@
1
+ import { execa } from 'execa'
2
+ import { which, exec } from './shell.js'
3
+ import { DvmiError } from '../utils/errors.js'
4
+
5
+ /** GitHub spec-kit package source for uv */
6
+ const SPECKIT_FROM = 'git+https://github.com/github/spec-kit.git'
7
+
8
+ /**
9
+ * Check whether the `uv` Python package manager is available in PATH.
10
+ *
11
+ * @returns {Promise<boolean>}
12
+ */
13
+ export async function isUvInstalled() {
14
+ return (await which('uv')) !== null
15
+ }
16
+
17
+ /**
18
+ * Check whether the `specify` CLI (spec-kit) is available in PATH.
19
+ *
20
+ * @returns {Promise<boolean>}
21
+ */
22
+ export async function isSpecifyInstalled() {
23
+ return (await which('specify')) !== null
24
+ }
25
+
26
+ /**
27
+ * Install `specify-cli` from the GitHub spec-kit repository via `uv tool install`.
28
+ *
29
+ * @param {{ force?: boolean }} [opts] - Pass `force: true` to reinstall even if already present
30
+ * @returns {Promise<{ stdout: string, stderr: string, exitCode: number }>}
31
+ * @throws {DvmiError} when the installation command fails
32
+ */
33
+ export async function installSpecifyCli(opts = {}) {
34
+ const args = ['tool', 'install', 'specify-cli', '--from', SPECKIT_FROM]
35
+ if (opts.force) args.push('--force')
36
+
37
+ const result = await exec('uv', args)
38
+ if (result.exitCode !== 0) {
39
+ throw new DvmiError(
40
+ 'Failed to install specify-cli',
41
+ result.stderr || 'Check your network connection and uv installation, then try again',
42
+ )
43
+ }
44
+ return result
45
+ }
46
+
47
+ /**
48
+ * Run `specify init --here` in the given directory, inheriting the parent
49
+ * stdio so the user can interact with the speckit wizard directly.
50
+ *
51
+ * @param {string} cwd - Working directory to run `specify init` in
52
+ * @param {{ ai?: string, force?: boolean }} [opts]
53
+ * @returns {Promise<void>}
54
+ * @throws {DvmiError} when `specify init` exits with a non-zero code
55
+ */
56
+ export async function runSpecifyInit(cwd, opts = {}) {
57
+ const args = ['init', '--here']
58
+ if (opts.ai) args.push('--ai', opts.ai)
59
+ if (opts.force) args.push('--force')
60
+
61
+ const result = await execa('specify', args, {
62
+ cwd,
63
+ stdio: 'inherit',
64
+ reject: false,
65
+ })
66
+
67
+ if (result.exitCode !== 0) {
68
+ throw new DvmiError(
69
+ '`specify init` exited with a non-zero code',
70
+ 'Check the output above for details',
71
+ )
72
+ }
73
+ }
package/src/types.js CHANGED
@@ -12,6 +12,40 @@
12
12
  * @property {{ teamId?: string, teamName?: string, authMethod?: 'oauth' | 'personal_token' }} [clickup] - ClickUp workspace config
13
13
  * @property {string} [lastVersionCheck] - ISO8601 timestamp of last version check
14
14
  * @property {string} [latestVersion] - Latest known CLI version
15
+ * @property {'opencode'|'copilot'} [aiTool] - Preferred AI tool for running prompts
16
+ * @property {string} [promptsDir] - Local directory for downloaded prompts (default: .prompts)
17
+ */
18
+
19
+ /**
20
+ * @typedef {Object} Prompt
21
+ * @property {string} path - Relative path in the repo (e.g. "coding/refactor-prompt.md")
22
+ * @property {string} title - Human-readable title (from frontmatter or filename)
23
+ * @property {string} [category] - Category derived from parent directory (e.g. "coding")
24
+ * @property {string} [description] - Short description from frontmatter
25
+ * @property {string[]} [tags] - Tags from frontmatter
26
+ * @property {string} body - Full prompt content (without frontmatter)
27
+ * @property {string} [author] - Author from frontmatter
28
+ * @property {string} [version] - Version string from frontmatter
29
+ */
30
+
31
+ /**
32
+ * @typedef {Object} Skill
33
+ * @property {string} id - Unique skill identifier on skills.sh
34
+ * @property {string} name - Display name
35
+ * @property {number} installs - Install count
36
+ * @property {string} source - Source URL or identifier
37
+ */
38
+
39
+ /**
40
+ * @typedef {Object} AwesomeEntry
41
+ * @property {string} name - Entry name
42
+ * @property {string} description - Short description
43
+ * @property {string} url - Link to the resource
44
+ * @property {string} category - Category (agents, instructions, skills, plugins, hooks, workflows)
45
+ */
46
+
47
+ /**
48
+ * @typedef {'opencode'|'copilot'} AITool
15
49
  */
16
50
 
17
51
  /**
@@ -0,0 +1,52 @@
1
+ import yaml from 'js-yaml'
2
+
3
+ /**
4
+ * @typedef {Object} ParsedFrontmatter
5
+ * @property {Record<string, unknown>} frontmatter - Parsed YAML frontmatter object (empty if none)
6
+ * @property {string} body - Body content without frontmatter
7
+ */
8
+
9
+ /**
10
+ * Parse YAML frontmatter from a markdown string.
11
+ *
12
+ * Expects optional leading `---\n...\n---\n` block.
13
+ * Returns an empty frontmatter object if none is found.
14
+ *
15
+ * @param {string} content - Raw file content
16
+ * @returns {ParsedFrontmatter}
17
+ */
18
+ export function parseFrontmatter(content) {
19
+ const match = content.match(/^---\r?\n([\s\S]*?)---\r?\n?([\s\S]*)$/)
20
+ if (!match) {
21
+ return { frontmatter: {}, body: content }
22
+ }
23
+ const rawYaml = match[1]
24
+ const body = match[2] ?? ''
25
+ try {
26
+ const parsed = yaml.load(rawYaml)
27
+ const frontmatter =
28
+ parsed && typeof parsed === 'object' && !Array.isArray(parsed)
29
+ ? /** @type {Record<string, unknown>} */ (parsed)
30
+ : {}
31
+ return { frontmatter, body }
32
+ } catch {
33
+ return { frontmatter: {}, body: content }
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Serialize a frontmatter object and body back into a markdown string.
39
+ *
40
+ * If `frontmatter` is empty (`{}`), returns `body` without a frontmatter block.
41
+ *
42
+ * @param {Record<string, unknown>} frontmatter - Frontmatter data to serialize
43
+ * @param {string} body - Body content
44
+ * @returns {string}
45
+ */
46
+ export function serializeFrontmatter(frontmatter, body) {
47
+ if (!frontmatter || Object.keys(frontmatter).length === 0) {
48
+ return body
49
+ }
50
+ const yamlStr = yaml.dump(frontmatter, { lineWidth: -1 }).trimEnd()
51
+ return `---\n${yamlStr}\n---\n${body}`
52
+ }