devvami 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/oclif.manifest.json +331 -67
- package/package.json +5 -1
- package/src/commands/init.js +30 -1
- package/src/commands/open.js +1 -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/commands/upgrade.js +5 -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/clickup.js +15 -1
- package/src/services/config.js +2 -1
- package/src/services/github.js +2 -1
- package/src/services/prompts.js +326 -0
- package/src/services/skills-sh.js +81 -0
- package/src/services/speckit.js +76 -0
- package/src/types.js +34 -0
- package/src/utils/frontmatter.js +52 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { Command, Args, Flags } from '@oclif/core'
|
|
2
|
+
import ora from 'ora'
|
|
3
|
+
import chalk from 'chalk'
|
|
4
|
+
import { select } from '@inquirer/prompts'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { readdir } from 'node:fs/promises'
|
|
7
|
+
import { resolveLocalPrompt, invokeTool, SUPPORTED_TOOLS } from '../../services/prompts.js'
|
|
8
|
+
import { loadConfig } from '../../services/config.js'
|
|
9
|
+
import { DvmiError } from '../../utils/errors.js'
|
|
10
|
+
|
|
11
|
+
/** @import { AITool } from '../../types.js' */
|
|
12
|
+
|
|
13
|
+
const DEFAULT_PROMPTS_DIR = '.prompts'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Walk a directory recursively and collect `.md` file paths relative to `base`.
|
|
17
|
+
* @param {string} dir
|
|
18
|
+
* @param {string} base
|
|
19
|
+
* @returns {Promise<string[]>}
|
|
20
|
+
*/
|
|
21
|
+
async function walkPrompts(dir, base) {
|
|
22
|
+
/** @type {string[]} */
|
|
23
|
+
const results = []
|
|
24
|
+
let entries
|
|
25
|
+
try {
|
|
26
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
27
|
+
} catch {
|
|
28
|
+
return results
|
|
29
|
+
}
|
|
30
|
+
for (const entry of entries) {
|
|
31
|
+
const full = join(dir, entry.name)
|
|
32
|
+
if (entry.isDirectory()) {
|
|
33
|
+
const sub = await walkPrompts(full, base)
|
|
34
|
+
results.push(...sub)
|
|
35
|
+
} else if (entry.name.endsWith('.md')) {
|
|
36
|
+
results.push(full.replace(base + '/', ''))
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return results
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default class PromptsRun extends Command {
|
|
43
|
+
static description = 'Execute a local prompt with a configured AI tool'
|
|
44
|
+
|
|
45
|
+
static examples = [
|
|
46
|
+
'<%= config.bin %> prompts run',
|
|
47
|
+
'<%= config.bin %> prompts run coding/refactor-prompt.md',
|
|
48
|
+
'<%= config.bin %> prompts run coding/refactor-prompt.md --tool opencode',
|
|
49
|
+
'<%= config.bin %> prompts run coding/refactor-prompt.md --json',
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
static enableJsonFlag = true
|
|
53
|
+
|
|
54
|
+
static args = {
|
|
55
|
+
path: Args.string({
|
|
56
|
+
description: 'Relative path of the local prompt (e.g. coding/refactor-prompt.md)',
|
|
57
|
+
required: false,
|
|
58
|
+
}),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
static flags = {
|
|
62
|
+
tool: Flags.string({
|
|
63
|
+
char: 't',
|
|
64
|
+
description: `AI tool to use (${Object.keys(SUPPORTED_TOOLS).join(', ')})`,
|
|
65
|
+
options: Object.keys(SUPPORTED_TOOLS),
|
|
66
|
+
}),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async run() {
|
|
70
|
+
const { args, flags } = await this.parse(PromptsRun)
|
|
71
|
+
const isJson = flags.json
|
|
72
|
+
|
|
73
|
+
// Load config
|
|
74
|
+
let config = {}
|
|
75
|
+
try {
|
|
76
|
+
config = await loadConfig()
|
|
77
|
+
} catch {
|
|
78
|
+
/* use defaults */
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const localDir =
|
|
82
|
+
process.env.DVMI_PROMPTS_DIR ?? config.promptsDir ?? join(process.cwd(), DEFAULT_PROMPTS_DIR)
|
|
83
|
+
|
|
84
|
+
// Resolve tool: --tool flag > config.aiTool
|
|
85
|
+
const toolName = /** @type {AITool | undefined} */ (flags.tool ?? config.aiTool)
|
|
86
|
+
|
|
87
|
+
// In --json mode, output the invocation plan without spawning
|
|
88
|
+
if (isJson) {
|
|
89
|
+
if (!args.path) {
|
|
90
|
+
this.error('Prompt path is required in --json mode', {
|
|
91
|
+
exit: 1,
|
|
92
|
+
suggestions: ['Run `dvmi prompts run <path> --json`'],
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!toolName) {
|
|
97
|
+
this.error('No AI tool configured', {
|
|
98
|
+
exit: 1,
|
|
99
|
+
suggestions: ['Run `dvmi init` to configure your preferred AI tool, or pass --tool <name>'],
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!SUPPORTED_TOOLS[toolName]) {
|
|
104
|
+
this.error(`Unknown tool: "${toolName}". Supported: ${Object.keys(SUPPORTED_TOOLS).join(', ')}`, {
|
|
105
|
+
exit: 1,
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let prompt
|
|
110
|
+
try {
|
|
111
|
+
prompt = await resolveLocalPrompt(args.path, localDir)
|
|
112
|
+
} catch (err) {
|
|
113
|
+
if (err instanceof DvmiError) {
|
|
114
|
+
this.error(err.message, { exit: err.exitCode, suggestions: [err.hint] })
|
|
115
|
+
}
|
|
116
|
+
throw err
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const tool = SUPPORTED_TOOLS[toolName]
|
|
120
|
+
const invocation = [tool.bin.join(' '), tool.promptFlag, '<prompt content>'].join(' ')
|
|
121
|
+
return {
|
|
122
|
+
tool: toolName,
|
|
123
|
+
promptPath: args.path,
|
|
124
|
+
invocation,
|
|
125
|
+
preview: prompt.body.slice(0, 200),
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// --- Interactive mode ---
|
|
130
|
+
|
|
131
|
+
// Resolve path interactively if not provided
|
|
132
|
+
let relativePath = args.path
|
|
133
|
+
if (!relativePath) {
|
|
134
|
+
const localPaths = await walkPrompts(localDir, localDir)
|
|
135
|
+
|
|
136
|
+
if (localPaths.length === 0) {
|
|
137
|
+
this.error('No local prompts found', {
|
|
138
|
+
exit: 1,
|
|
139
|
+
suggestions: [`Run \`dvmi prompts download\` to download prompts to ${localDir}`],
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
relativePath = await select({
|
|
144
|
+
message: 'Select a local prompt to run:',
|
|
145
|
+
choices: localPaths.map((p) => ({ name: p, value: p })),
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Verify tool is configured
|
|
150
|
+
if (!toolName) {
|
|
151
|
+
this.error('No AI tool configured', {
|
|
152
|
+
exit: 1,
|
|
153
|
+
suggestions: ['Run `dvmi init` to configure your preferred AI tool, or pass --tool <name>'],
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Load prompt
|
|
158
|
+
const spinner = ora({
|
|
159
|
+
spinner: 'arc',
|
|
160
|
+
color: false,
|
|
161
|
+
text: chalk.hex('#FF6B2B')('Loading prompt...'),
|
|
162
|
+
}).start()
|
|
163
|
+
|
|
164
|
+
let prompt
|
|
165
|
+
try {
|
|
166
|
+
prompt = await resolveLocalPrompt(relativePath, localDir)
|
|
167
|
+
spinner.stop()
|
|
168
|
+
} catch (err) {
|
|
169
|
+
spinner.fail()
|
|
170
|
+
if (err instanceof DvmiError) {
|
|
171
|
+
this.error(err.message, { exit: err.exitCode, suggestions: [err.hint] })
|
|
172
|
+
}
|
|
173
|
+
throw err
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
this.log(chalk.bold(`\nRunning: ${chalk.hex('#FF9A5C')(prompt.title)}`))
|
|
177
|
+
this.log(chalk.dim(` Tool: ${toolName}`) + '\n')
|
|
178
|
+
|
|
179
|
+
// Invoke tool
|
|
180
|
+
try {
|
|
181
|
+
await invokeTool(toolName, prompt.body)
|
|
182
|
+
} catch (err) {
|
|
183
|
+
if (err instanceof DvmiError) {
|
|
184
|
+
this.error(err.message, { exit: err.exitCode, suggestions: [err.hint] })
|
|
185
|
+
}
|
|
186
|
+
throw err
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
package/src/commands/upgrade.js
CHANGED
|
@@ -22,6 +22,11 @@ export default class Upgrade extends Command {
|
|
|
22
22
|
const { hasUpdate, current, latest } = await checkForUpdate({ force: true })
|
|
23
23
|
spinner?.stop()
|
|
24
24
|
|
|
25
|
+
// Guard against malformed version strings from the GitHub Releases API
|
|
26
|
+
if (latest && !/^v?\d+\.\d+\.\d+(-[\w.]+)?(\+[\w.]+)?$/.test(latest)) {
|
|
27
|
+
this.error(`Invalid version received from releases API: "${latest}" — update aborted`)
|
|
28
|
+
}
|
|
29
|
+
|
|
25
30
|
if (!hasUpdate) {
|
|
26
31
|
const msg = `You're already on the latest version (${current})`
|
|
27
32
|
if (isJson) return { currentVersion: current, latestVersion: latest, updated: false }
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
import { marked } from 'marked'
|
|
3
|
+
import { renderTable } from './table.js'
|
|
4
|
+
|
|
5
|
+
/** @import { Prompt, Skill, AwesomeEntry } from '../types.js' */
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Format a list of prompts as a terminal table.
|
|
9
|
+
* Columns: title, category, description.
|
|
10
|
+
*
|
|
11
|
+
* @param {Prompt[]} prompts
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
export function formatPromptTable(prompts) {
|
|
15
|
+
if (prompts.length === 0) {
|
|
16
|
+
return chalk.dim('No prompts found.')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return renderTable(
|
|
20
|
+
/** @type {Record<string, unknown>[]} */ (prompts),
|
|
21
|
+
[
|
|
22
|
+
{
|
|
23
|
+
header: 'Title',
|
|
24
|
+
key: 'title',
|
|
25
|
+
width: 36,
|
|
26
|
+
colorize: (v) => chalk.hex('#FF9A5C')(v),
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
header: 'Category',
|
|
30
|
+
key: 'category',
|
|
31
|
+
width: 16,
|
|
32
|
+
format: (v) => v ?? '—',
|
|
33
|
+
colorize: (v) => chalk.hex('#4A9EFF')(v),
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
header: 'Description',
|
|
37
|
+
key: 'description',
|
|
38
|
+
width: 60,
|
|
39
|
+
format: (v) => v ?? '—',
|
|
40
|
+
colorize: (v) => chalk.white(v),
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Format a single prompt's full content for display in the terminal.
|
|
48
|
+
* Renders the title as a header and the body as plain text (markdown stripped).
|
|
49
|
+
*
|
|
50
|
+
* @param {Prompt} prompt
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
export function formatPromptBody(prompt) {
|
|
54
|
+
const titleLine = chalk.bold.hex('#FF6B2B')(prompt.title)
|
|
55
|
+
const divider = chalk.dim('─'.repeat(60))
|
|
56
|
+
|
|
57
|
+
const meta = [
|
|
58
|
+
prompt.category ? chalk.dim(`Category: `) + chalk.hex('#4A9EFF')(prompt.category) : null,
|
|
59
|
+
prompt.description ? chalk.dim(`Description: `) + chalk.white(prompt.description) : null,
|
|
60
|
+
prompt.tags?.length ? chalk.dim(`Tags: `) + chalk.hex('#4A9EFF')(prompt.tags.join(', ')) : null,
|
|
61
|
+
]
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
.join('\n')
|
|
64
|
+
|
|
65
|
+
// Render markdown to plain terminal text by stripping HTML tags from marked output
|
|
66
|
+
const rendered = marked(prompt.body, { async: false })
|
|
67
|
+
const plain = String(rendered)
|
|
68
|
+
.replace(/<[^>]+>/g, '')
|
|
69
|
+
.replace(/&/g, '&')
|
|
70
|
+
.replace(/</g, '<')
|
|
71
|
+
.replace(/>/g, '>')
|
|
72
|
+
.replace(/"/g, '"')
|
|
73
|
+
.trim()
|
|
74
|
+
|
|
75
|
+
const parts = [titleLine, divider]
|
|
76
|
+
if (meta) parts.push(meta, divider)
|
|
77
|
+
parts.push(plain)
|
|
78
|
+
|
|
79
|
+
return parts.join('\n')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Format a list of skills.sh skills as a terminal table.
|
|
84
|
+
* Columns: name, installs, description.
|
|
85
|
+
*
|
|
86
|
+
* @param {Skill[]} skills
|
|
87
|
+
* @returns {string}
|
|
88
|
+
*/
|
|
89
|
+
export function formatSkillTable(skills) {
|
|
90
|
+
if (skills.length === 0) {
|
|
91
|
+
return chalk.dim('No skills found.')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return renderTable(
|
|
95
|
+
/** @type {Record<string, unknown>[]} */ (skills),
|
|
96
|
+
[
|
|
97
|
+
{
|
|
98
|
+
header: 'Name',
|
|
99
|
+
key: 'name',
|
|
100
|
+
width: 36,
|
|
101
|
+
colorize: (v) => chalk.hex('#FF9A5C')(v),
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
header: 'Installs',
|
|
105
|
+
key: 'installs',
|
|
106
|
+
width: 10,
|
|
107
|
+
format: (v) => (v != null ? String(v) : '—'),
|
|
108
|
+
colorize: (v) => chalk.hex('#4A9EFF')(v),
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
header: 'Description',
|
|
112
|
+
key: 'description',
|
|
113
|
+
width: 60,
|
|
114
|
+
format: (v) => v ?? '—',
|
|
115
|
+
colorize: (v) => chalk.white(v),
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Format a list of awesome-copilot entries as a terminal table.
|
|
123
|
+
* Columns: name, category, description.
|
|
124
|
+
*
|
|
125
|
+
* @param {AwesomeEntry[]} entries
|
|
126
|
+
* @param {string} [category] - Active category label shown in the header
|
|
127
|
+
* @returns {string}
|
|
128
|
+
*/
|
|
129
|
+
export function formatAwesomeTable(entries, category) {
|
|
130
|
+
if (entries.length === 0) {
|
|
131
|
+
return chalk.dim(category ? `No entries found for category "${category}".` : 'No entries found.')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return renderTable(
|
|
135
|
+
/** @type {Record<string, unknown>[]} */ (entries),
|
|
136
|
+
[
|
|
137
|
+
{
|
|
138
|
+
header: 'Name',
|
|
139
|
+
key: 'name',
|
|
140
|
+
width: 36,
|
|
141
|
+
colorize: (v) => chalk.hex('#FF9A5C')(v),
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
header: 'Category',
|
|
145
|
+
key: 'category',
|
|
146
|
+
width: 14,
|
|
147
|
+
format: (v) => v ?? '—',
|
|
148
|
+
colorize: (v) => chalk.hex('#4A9EFF')(v),
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
header: 'Description',
|
|
152
|
+
key: 'description',
|
|
153
|
+
width: 58,
|
|
154
|
+
format: (v) => v ?? '—',
|
|
155
|
+
colorize: (v) => chalk.white(v),
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
)
|
|
159
|
+
}
|
package/src/help.js
CHANGED
|
@@ -68,6 +68,16 @@ const CATEGORIES = [
|
|
|
68
68
|
{ id: 'costs:get', hint: '[--period] [--profile]' },
|
|
69
69
|
],
|
|
70
70
|
},
|
|
71
|
+
{
|
|
72
|
+
title: 'AI Prompts',
|
|
73
|
+
cmds: [
|
|
74
|
+
{ id: 'prompts:list', hint: '[--filter]' },
|
|
75
|
+
{ id: 'prompts:download', hint: '<PATH> [--overwrite]' },
|
|
76
|
+
{ id: 'prompts:browse', hint: '[--source] [--query] [--category]' },
|
|
77
|
+
{ id: 'prompts:install-speckit', hint: '[--force]' },
|
|
78
|
+
{ id: 'prompts:run', hint: '[PATH] [--tool]' },
|
|
79
|
+
],
|
|
80
|
+
},
|
|
71
81
|
{
|
|
72
82
|
title: 'Setup & Ambiente',
|
|
73
83
|
cmds: [
|
|
@@ -82,14 +92,19 @@ const CATEGORIES = [
|
|
|
82
92
|
|
|
83
93
|
// ─── Example commands shown at bottom of root help ──────────────────────────
|
|
84
94
|
const EXAMPLES = [
|
|
85
|
-
{ cmd: 'dvmi
|
|
86
|
-
{ cmd: 'dvmi
|
|
87
|
-
{ cmd: 'dvmi
|
|
88
|
-
{ cmd: 'dvmi
|
|
89
|
-
{ cmd: 'dvmi
|
|
90
|
-
{ cmd: 'dvmi
|
|
91
|
-
{ cmd: 'dvmi
|
|
92
|
-
{ cmd: 'dvmi
|
|
95
|
+
{ cmd: 'dvmi prompts list', note: 'Sfoglia prompt AI dal tuo repository' },
|
|
96
|
+
{ cmd: 'dvmi prompts list --filter refactor', note: 'Filtra prompt per parola chiave' },
|
|
97
|
+
{ cmd: 'dvmi prompts download coding/refactor-prompt.md', note: 'Scarica un prompt localmente' },
|
|
98
|
+
{ cmd: 'dvmi prompts browse skills --query refactor', note: 'Cerca skill su skills.sh' },
|
|
99
|
+
{ cmd: 'dvmi prompts browse awesome --category agents', note: 'Sfoglia awesome-copilot agents' },
|
|
100
|
+
{ cmd: 'dvmi prompts run coding/refactor-prompt.md --tool opencode', note: 'Esegui un prompt con opencode' },
|
|
101
|
+
{ cmd: 'dvmi docs read', note: 'Leggi il README del repo corrente' },
|
|
102
|
+
{ cmd: 'dvmi docs search "authentication"', note: 'Cerca nei docs del repo corrente' },
|
|
103
|
+
{ cmd: 'dvmi repo list --search "api"', note: 'Filtra repository per nome' },
|
|
104
|
+
{ cmd: 'dvmi pr status', note: 'PR aperte e review in attesa' },
|
|
105
|
+
{ cmd: 'dvmi pipeline status', note: 'Ultimi workflow CI/CD' },
|
|
106
|
+
{ cmd: 'dvmi tasks list --search "bug"', note: 'Cerca task ClickUp' },
|
|
107
|
+
{ cmd: 'dvmi costs get --json', note: 'Costi AWS in formato JSON' },
|
|
93
108
|
]
|
|
94
109
|
|
|
95
110
|
// ─── Help class ─────────────────────────────────────────────────────────────
|
|
@@ -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/clickup.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import http from 'node:http'
|
|
2
|
+
import { randomBytes } from 'node:crypto'
|
|
2
3
|
import { openBrowser } from '../utils/open-browser.js'
|
|
3
4
|
import { loadConfig } from './config.js'
|
|
4
5
|
|
|
@@ -45,6 +46,10 @@ export async function storeToken(token) {
|
|
|
45
46
|
await keytar.setPassword('devvami', TOKEN_KEY, token)
|
|
46
47
|
} catch {
|
|
47
48
|
// Fallback: store in config (less secure)
|
|
49
|
+
process.stderr.write(
|
|
50
|
+
'Warning: keytar unavailable. ClickUp token will be stored in plaintext.\n' +
|
|
51
|
+
'Run `dvmi auth logout` after this session on shared machines.\n',
|
|
52
|
+
)
|
|
48
53
|
const config = await loadConfig()
|
|
49
54
|
await saveConfig({ ...config, clickup: { ...config.clickup, token } })
|
|
50
55
|
}
|
|
@@ -57,11 +62,20 @@ export async function storeToken(token) {
|
|
|
57
62
|
* @returns {Promise<string>} Access token
|
|
58
63
|
*/
|
|
59
64
|
export async function oauthFlow(clientId, clientSecret) {
|
|
65
|
+
const csrfState = randomBytes(16).toString('hex')
|
|
60
66
|
return new Promise((resolve, reject) => {
|
|
61
67
|
const server = http.createServer(async (req, res) => {
|
|
62
68
|
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
63
69
|
const code = url.searchParams.get('code')
|
|
70
|
+
const returnedState = url.searchParams.get('state')
|
|
64
71
|
if (!code) return
|
|
72
|
+
if (!returnedState || returnedState !== csrfState) {
|
|
73
|
+
res.writeHead(400)
|
|
74
|
+
res.end('State mismatch — possible CSRF attack.')
|
|
75
|
+
server.close()
|
|
76
|
+
reject(new Error('OAuth state mismatch — possible CSRF attack'))
|
|
77
|
+
return
|
|
78
|
+
}
|
|
65
79
|
res.end('Authorization successful! You can close this tab.')
|
|
66
80
|
server.close()
|
|
67
81
|
try {
|
|
@@ -80,7 +94,7 @@ export async function oauthFlow(clientId, clientSecret) {
|
|
|
80
94
|
server.listen(0, async () => {
|
|
81
95
|
const addr = /** @type {import('node:net').AddressInfo} */ (server.address())
|
|
82
96
|
const callbackUrl = `http://localhost:${addr.port}/callback`
|
|
83
|
-
const authUrl = `https://app.clickup.com/api?client_id=${clientId}&redirect_uri=${encodeURIComponent(callbackUrl)}`
|
|
97
|
+
const authUrl = `https://app.clickup.com/api?client_id=${clientId}&redirect_uri=${encodeURIComponent(callbackUrl)}&state=${csrfState}`
|
|
84
98
|
await openBrowser(authUrl)
|
|
85
99
|
})
|
|
86
100
|
server.on('error', reject)
|
package/src/services/config.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
1
|
+
import { readFile, writeFile, mkdir, chmod } from 'node:fs/promises'
|
|
2
2
|
import { existsSync } from 'node:fs'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { homedir } from 'node:os'
|
|
@@ -47,6 +47,7 @@ export async function saveConfig(config, configPath = CONFIG_PATH) {
|
|
|
47
47
|
await mkdir(dir, { recursive: true })
|
|
48
48
|
}
|
|
49
49
|
await writeFile(configPath, JSON.stringify(config, null, 2), 'utf8')
|
|
50
|
+
await chmod(configPath, 0o600)
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
/**
|
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
|
/**
|