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.
- package/oclif.manifest.json +265 -1
- package/package.json +4 -1
- package/src/commands/init.js +30 -1
- package/src/commands/prompts/browse.js +164 -0
- package/src/commands/prompts/download.js +154 -0
- package/src/commands/prompts/install-speckit.js +97 -0
- package/src/commands/prompts/list.js +107 -0
- package/src/commands/prompts/run.js +189 -0
- package/src/formatters/prompts.js +159 -0
- package/src/help.js +23 -8
- package/src/services/awesome-copilot.js +123 -0
- package/src/services/github.js +2 -1
- package/src/services/prompts.js +307 -0
- package/src/services/skills-sh.js +81 -0
- package/src/services/speckit.js +73 -0
- package/src/types.js +34 -0
- package/src/utils/frontmatter.js +52 -0
|
@@ -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. `[](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: [](url) → keep nothing; [] → 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
|
+
}
|
package/src/services/github.js
CHANGED
|
@@ -20,7 +20,8 @@ async function getToken() {
|
|
|
20
20
|
*/
|
|
21
21
|
export async function createOctokit() {
|
|
22
22
|
const token = await getToken()
|
|
23
|
-
|
|
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
|
+
}
|