@miphamai/cli 0.69.0 → 0.71.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,233 @@
1
+ /**
2
+ * Marketplace sources — discover and install skills from any public GitHub
3
+ * repository that follows the standard `SKILL.md` (frontmatter name/description)
4
+ * convention. This lifts skill installation out of the hard-coded community
5
+ * registry so users can add any public repo as a source.
6
+ */
7
+ import { parse as parseYaml } from 'yaml'
8
+ import { homedir } from 'node:os'
9
+ import { join } from 'node:path'
10
+ import { spawn } from 'node:child_process'
11
+ import { URL } from 'node:url'
12
+
13
+ export const MARKETPLACES_PATH = join(homedir(), '.mipham', 'marketplaces.json')
14
+
15
+ export interface SkillFrontmatter {
16
+ name: string
17
+ description: string
18
+ }
19
+
20
+ /**
21
+ * Parse a `SKILL.md` document's frontmatter for `name`/`description`. Returns
22
+ * null when there is no frontmatter, the YAML is invalid, or `name` is missing.
23
+ */
24
+ export function parseSkillFrontmatter(content: string): SkillFrontmatter | null {
25
+ const src = content.replace(/^/, '')
26
+ const match = src.match(/^---\n([\s\S]*?)\n---/)
27
+ if (!match) return null
28
+ let data: Record<string, unknown>
29
+ try {
30
+ data = parseYaml(match[1] || '') as Record<string, unknown>
31
+ } catch {
32
+ return null
33
+ }
34
+ const name = typeof data.name === 'string' ? data.name.trim() : ''
35
+ if (!name) return null
36
+ const description = typeof data.description === 'string' ? data.description : ''
37
+ return { name, description }
38
+ }
39
+
40
+ export interface MarketplaceSource {
41
+ owner: string
42
+ repo: string
43
+ }
44
+
45
+ export const DEFAULT_MARKETPLACES: MarketplaceSource[] = [
46
+ { owner: 'anthropics', repo: 'claude-plugins-community' },
47
+ { owner: 'anthropics', repo: 'skills' },
48
+ ]
49
+
50
+ /** owner/repo must be plain GitHub names (letters, digits, dot, dash, underscore). */
51
+ export function isValidMarketplaceRef(owner: string, repo: string): boolean {
52
+ const valid = (s: string) => /^[a-zA-Z0-9._-]+$/.test(s) && s.length > 0
53
+ return valid(owner) && valid(repo)
54
+ }
55
+
56
+ /**
57
+ * Read the marketplace sources from disk. A missing or corrupt file falls back
58
+ * to the default Anthropic sources.
59
+ */
60
+ export function readMarketplaces(readFile: (path: string) => string | null): MarketplaceSource[] {
61
+ const raw = readFile(MARKETPLACES_PATH)
62
+ if (raw === null) return [...DEFAULT_MARKETPLACES]
63
+ try {
64
+ const parsed = JSON.parse(raw)
65
+ if (!Array.isArray(parsed)) return [...DEFAULT_MARKETPLACES]
66
+ return parsed.filter(
67
+ (s): s is MarketplaceSource =>
68
+ !!s && typeof s === 'object' && typeof s.owner === 'string' && typeof s.repo === 'string',
69
+ )
70
+ } catch {
71
+ return [...DEFAULT_MARKETPLACES]
72
+ }
73
+ }
74
+
75
+ export function addMarketplace(
76
+ current: MarketplaceSource[],
77
+ owner: string,
78
+ repo: string,
79
+ ): { sources: MarketplaceSource[]; added: boolean } {
80
+ if (current.some((s) => s.owner === owner && s.repo === repo)) {
81
+ return { sources: current, added: false }
82
+ }
83
+ return { sources: [...current, { owner, repo }], added: true }
84
+ }
85
+
86
+ export function removeMarketplace(
87
+ current: MarketplaceSource[],
88
+ owner: string,
89
+ repo: string,
90
+ ): { sources: MarketplaceSource[]; removed: boolean } {
91
+ const sources = current.filter((s) => !(s.owner === owner && s.repo === repo))
92
+ return { sources, removed: sources.length !== current.length }
93
+ }
94
+
95
+ export interface DiscoveredSkill {
96
+ name: string
97
+ description: string
98
+ path: string
99
+ }
100
+
101
+ /** Minimal fetch shape (ok/json/text) so discovery is testable without real network. */
102
+ export interface FetchLike {
103
+ (url: string): Promise<{ ok: boolean; json: () => Promise<unknown>; text: () => Promise<string> }>
104
+ }
105
+
106
+ /**
107
+ * List every `SKILL.md` blob path in a repository via the GitHub trees API.
108
+ * Returns [] on any failure (network, rate-limit, non-2xx).
109
+ */
110
+ export async function listSkillPaths(
111
+ source: MarketplaceSource,
112
+ fetchFn: FetchLike,
113
+ ): Promise<string[]> {
114
+ const url = `https://api.github.com/repos/${source.owner}/${source.repo}/git/trees/HEAD?recursive=1`
115
+ try {
116
+ const res = await fetchFn(url)
117
+ if (!res.ok) return []
118
+ const data = (await res.json()) as { tree?: Array<{ path?: string; type?: string }> }
119
+ return (data.tree ?? [])
120
+ .filter((e) => e.type === 'blob' && e.path?.endsWith('SKILL.md'))
121
+ .map((e) => e.path!)
122
+ } catch {
123
+ return []
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Discover every skill in a repository: list `SKILL.md` paths, fetch each from
129
+ * raw.githubusercontent.com, and parse its frontmatter for name/description.
130
+ */
131
+ export async function discoverSkills(
132
+ source: MarketplaceSource,
133
+ fetchFn: FetchLike,
134
+ download: (url: string) => Promise<string>,
135
+ ): Promise<DiscoveredSkill[]> {
136
+ const paths = await listSkillPaths(source, fetchFn)
137
+ const skills = await Promise.all(
138
+ paths.map(async (path) => {
139
+ const rawUrl = `https://raw.githubusercontent.com/${source.owner}/${source.repo}/HEAD/${path}`
140
+ try {
141
+ const fm = parseSkillFrontmatter(await download(rawUrl))
142
+ return fm ? { name: fm.name, description: fm.description, path } : null
143
+ } catch {
144
+ return null
145
+ }
146
+ }),
147
+ )
148
+ return skills.filter((s): s is DiscoveredSkill => s !== null)
149
+ }
150
+
151
+ export interface FoundSkill {
152
+ name: string
153
+ description: string
154
+ source: MarketplaceSource
155
+ path: string
156
+ rawUrl: string
157
+ }
158
+
159
+ /**
160
+ * Search every marketplace source for a skill by name (first match wins, in
161
+ * source order). Returns null when no source exposes it.
162
+ */
163
+ export async function findSkillInMarketplaces(
164
+ skillName: string,
165
+ sources: MarketplaceSource[],
166
+ fetchFn: FetchLike,
167
+ download: (url: string) => Promise<string>,
168
+ ): Promise<FoundSkill | null> {
169
+ for (const source of sources) {
170
+ const skills = await discoverSkills(source, fetchFn, download)
171
+ const found = skills.find((s) => s.name === skillName)
172
+ if (found) {
173
+ return {
174
+ name: found.name,
175
+ description: found.description,
176
+ source,
177
+ path: found.path,
178
+ rawUrl: `https://raw.githubusercontent.com/${source.owner}/${source.repo}/HEAD/${found.path}`,
179
+ }
180
+ }
181
+ }
182
+ return null
183
+ }
184
+
185
+ /** Allowed domains for remote skill installation. */
186
+ const ALLOWED_DOMAINS = [
187
+ 'raw.githubusercontent.com',
188
+ 'github.com',
189
+ 'gist.githubusercontent.com',
190
+ 'gitlab.com',
191
+ ]
192
+
193
+ /**
194
+ * Download a file over HTTPS via `curl` (spawn, no shell injection), restricted
195
+ * to known-safe domains. Async so multiple downloads can run in parallel.
196
+ */
197
+ export async function downloadFile(rawUrl: string): Promise<string> {
198
+ let parsed: URL
199
+ try {
200
+ parsed = new URL(rawUrl)
201
+ } catch {
202
+ throw new Error(`Invalid URL: ${rawUrl}`)
203
+ }
204
+ if (parsed.protocol !== 'https:') {
205
+ throw new Error(`Only HTTPS URLs are allowed (got: ${parsed.protocol})`)
206
+ }
207
+ if (!ALLOWED_DOMAINS.some((d) => parsed.hostname === d || parsed.hostname.endsWith('.' + d))) {
208
+ throw new Error(
209
+ `Domain not allowed: ${parsed.hostname}. Allowed: ${ALLOWED_DOMAINS.join(', ')}`,
210
+ )
211
+ }
212
+
213
+ return new Promise<string>((resolve, reject) => {
214
+ const proc = spawn('curl', ['-fsSL', rawUrl], { stdio: ['pipe', 'pipe', 'pipe'] })
215
+ let stdout = ''
216
+ let stderr = ''
217
+ const timer = setTimeout(() => {
218
+ proc.kill()
219
+ reject(new Error(`Download timed out: ${rawUrl}`))
220
+ }, 15_000)
221
+ proc.stdout.on('data', (c) => (stdout += c))
222
+ proc.stderr.on('data', (c) => (stderr += c))
223
+ proc.on('error', (err) => {
224
+ clearTimeout(timer)
225
+ reject(new Error(`Failed to download: ${err.message}`))
226
+ })
227
+ proc.on('close', (code) => {
228
+ clearTimeout(timer)
229
+ if (code === 0) resolve(stdout)
230
+ else reject(new Error(`Download failed with status ${code}: ${stderr}`))
231
+ })
232
+ })
233
+ }
@@ -5,13 +5,19 @@
5
5
  * from remote sources (GitHub repos, direct URLs).
6
6
  */
7
7
 
8
- import { existsSync, mkdirSync, writeFileSync, readdirSync, unlinkSync } from 'node:fs'
8
+ import {
9
+ existsSync,
10
+ mkdirSync,
11
+ writeFileSync,
12
+ readdirSync,
13
+ unlinkSync,
14
+ readFileSync,
15
+ } from 'node:fs'
9
16
  import { join } from 'node:path'
10
17
  import { homedir } from 'node:os'
11
- import { spawnSync } from 'node:child_process'
12
- import { URL } from 'node:url'
13
18
  import type { MiphamConfig } from '../shared/types.js'
14
19
  import communitySkills from './community-registry.json'
20
+ import { readMarketplaces, findSkillInMarketplaces, downloadFile } from './marketplace'
15
21
 
16
22
  const SKILLS_DIR = join(homedir(), '.mipham', 'skills')
17
23
 
@@ -117,17 +123,13 @@ function isMarketplaceAllowed(url: string, config?: MiphamConfig['marketplace'])
117
123
  * Clones the repo to a temp directory, copies the skill file(s),
118
124
  * and cleans up.
119
125
  */
120
- export function installSkill(
126
+ export async function installSkill(
121
127
  skillName: string,
122
128
  marketplaceConfig?: MiphamConfig['marketplace'],
123
- ): InstallResult {
129
+ ): Promise<InstallResult> {
124
130
  const entry = COMMUNITY_SKILLS.find((s) => s.name === skillName)
125
131
  if (!entry) {
126
- return {
127
- success: false,
128
- name: skillName,
129
- message: `Skill "${skillName}" not found in the registry. Use /browse-skills to see available skills.`,
130
- }
132
+ return installFromMarketplace(skillName, marketplaceConfig)
131
133
  }
132
134
 
133
135
  // Check marketplace restrictions
@@ -165,7 +167,7 @@ export function installSkill(
165
167
  try {
166
168
  // Download the skill file from GitHub raw content
167
169
  const rawUrl = githubRawUrl(entry.url, entry.file || `${skillName}.SKILL.md`)
168
- const content = downloadFile(rawUrl)
170
+ const content = await downloadFile(rawUrl)
169
171
 
170
172
  // Validate it's a proper skill file (has frontmatter)
171
173
  if (!content.includes('---')) {
@@ -193,13 +195,74 @@ export function installSkill(
193
195
  }
194
196
  }
195
197
 
198
+ /**
199
+ * Install a skill by discovering it across the user's marketplace sources.
200
+ * Called when the name is not in the built-in community registry.
201
+ */
202
+ async function installFromMarketplace(
203
+ skillName: string,
204
+ marketplaceConfig?: MiphamConfig['marketplace'],
205
+ ): Promise<InstallResult> {
206
+ const sources = readMarketplaces((p) => (existsSync(p) ? readFileSync(p, 'utf-8') : null))
207
+ const found = await findSkillInMarketplaces(skillName, sources, globalThis.fetch, downloadFile)
208
+
209
+ if (!found) {
210
+ return {
211
+ success: false,
212
+ name: skillName,
213
+ message: `Skill "${skillName}" not found in the registry or any marketplace. Use /browse-skills or /browse-marketplace.`,
214
+ }
215
+ }
216
+ if (!isMarketplaceAllowed(found.rawUrl, marketplaceConfig)) {
217
+ return {
218
+ success: false,
219
+ name: skillName,
220
+ message: `Skill "${skillName}" is from a blocked or unapproved marketplace: ${found.rawUrl}`,
221
+ }
222
+ }
223
+
224
+ const destPath = join(SKILLS_DIR, `${found.name}.SKILL.md`)
225
+ if (existsSync(destPath)) {
226
+ return {
227
+ success: false,
228
+ name: found.name,
229
+ message: `Skill "${found.name}" is already installed. Remove it first to reinstall.`,
230
+ }
231
+ }
232
+
233
+ mkdirSync(SKILLS_DIR, { recursive: true })
234
+ try {
235
+ const content = await downloadFile(found.rawUrl)
236
+ if (!content.includes('---')) {
237
+ return {
238
+ success: false,
239
+ name: found.name,
240
+ message: `Downloaded file does not appear to be a valid skill (missing frontmatter).`,
241
+ }
242
+ }
243
+ writeFileSync(destPath, content, 'utf-8')
244
+ return {
245
+ success: true,
246
+ name: found.name,
247
+ message: `Skill "${found.name}" installed from ${found.source.owner}/${found.source.repo} to ${destPath}\nRun /reload-skills to activate it.`,
248
+ }
249
+ } catch (err: unknown) {
250
+ const msg = err instanceof Error ? err.message : String(err)
251
+ return {
252
+ success: false,
253
+ name: found.name,
254
+ message: `Failed to install "${found.name}": ${msg}`,
255
+ }
256
+ }
257
+ }
258
+
196
259
  /**
197
260
  * Install a skill from a direct URL (GitHub raw, gist, or any HTTP URL).
198
261
  */
199
- export function installSkillFromUrl(
262
+ export async function installSkillFromUrl(
200
263
  url: string,
201
264
  marketplaceConfig?: MiphamConfig['marketplace'],
202
- ): InstallResult {
265
+ ): Promise<InstallResult> {
203
266
  // Check marketplace restrictions
204
267
  if (!isMarketplaceAllowed(url, marketplaceConfig)) {
205
268
  return {
@@ -222,7 +285,7 @@ export function installSkillFromUrl(
222
285
  mkdirSync(destDir, { recursive: true })
223
286
 
224
287
  try {
225
- const content = downloadFile(url)
288
+ const content = await downloadFile(url)
226
289
 
227
290
  if (!content.includes('---')) {
228
291
  return {
@@ -288,52 +351,3 @@ function githubRawUrl(repoUrl: string, file: string): string {
288
351
  const base = repoUrl.replace('https://github.com/', 'https://raw.githubusercontent.com/')
289
352
  return `${base}/main/${file}`
290
353
  }
291
-
292
- /** Allowed domains for remote skill installation */
293
- const ALLOWED_DOMAINS = [
294
- 'raw.githubusercontent.com',
295
- 'github.com',
296
- 'gist.githubusercontent.com',
297
- 'gitlab.com',
298
- ]
299
-
300
- /**
301
- * Download a file from a URL using curl with spawn (no shell injection).
302
- * Only allows HTTPS URLs from known safe domains.
303
- */
304
- function downloadFile(rawUrl: string): string {
305
- let parsed: URL
306
- try {
307
- parsed = new URL(rawUrl)
308
- } catch {
309
- throw new Error(`Invalid URL: ${rawUrl}`)
310
- }
311
-
312
- // Protocol must be HTTPS
313
- if (parsed.protocol !== 'https:') {
314
- throw new Error(`Only HTTPS URLs are allowed (got: ${parsed.protocol})`)
315
- }
316
-
317
- // Domain must be in allowlist
318
- if (!ALLOWED_DOMAINS.some((d) => parsed.hostname === d || parsed.hostname.endsWith('.' + d))) {
319
- throw new Error(
320
- `Domain not allowed: ${parsed.hostname}. Allowed: ${ALLOWED_DOMAINS.join(', ')}`,
321
- )
322
- }
323
-
324
- // Use spawnSync with args array — no shell, no injection
325
- const result = spawnSync('curl', ['-fsSL', rawUrl], {
326
- encoding: 'utf-8',
327
- timeout: 15_000,
328
- stdio: ['pipe', 'pipe', 'pipe'],
329
- })
330
-
331
- if (result.error) {
332
- throw new Error(`Failed to download: ${result.error.message}`)
333
- }
334
- if (result.status !== 0) {
335
- throw new Error(`Download failed with status ${result.status}`)
336
- }
337
-
338
- return result.stdout
339
- }
@@ -130,7 +130,10 @@ export function isBlocked(command: string): string | null {
130
130
 
131
131
  // Detect dangerous git operations invoked via Bash — anywhere in the command,
132
132
  // so `echo ok && git push --force` is still caught (not just `^git`).
133
- if (/(?:^|[\s;&|])(?:sudo\s+)?(?:git|gh)\s+/.test(normalized)) {
133
+ // `gh` is deliberately NOT scanned: DANGEROUS_GIT_PATTERNS are all git
134
+ // subcommands (push/reset/clean/…), and scanning `gh` only produced false
135
+ // positives (e.g. `gh issue create --body "...git config user.name..."`).
136
+ if (/(?:^|[\s;&|])(?:sudo\s+)?git\s+/.test(normalized)) {
134
137
  for (const { pattern, description } of DANGEROUS_GIT_PATTERNS) {
135
138
  if (pattern.test(normalized)) {
136
139
  return `Dangerous git command blocked: "${description}". Run manually if intended.`