@miphamai/cli 0.68.0 → 0.70.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
- }
@@ -3,6 +3,8 @@ import { join } from 'node:path'
3
3
  import { homedir } from 'node:os'
4
4
  import type { ToolDefinition } from '../../shared/index.ts'
5
5
  import { MemoryManager } from '../../core/memory/memory-manager'
6
+ import { renderWorkingState } from '../../core/working-memory'
7
+ import { getTasks } from '../exec/task'
6
8
 
7
9
  const MEMORY_DIR = join(homedir(), '.mipham', 'memory')
8
10
 
@@ -58,7 +60,9 @@ export const memoryTool: ToolDefinition = {
58
60
  if (!query) return { success: false, content: '', error: 'query is required' }
59
61
  const mm = new MemoryManager(MEMORY_DIR)
60
62
  mm.loadAll()
61
- const results = mm.recall(query, 10)
63
+ // 状态接地(治「前存后忘」):除了精确匹配 query,还带上「当前未完成任务」相关的记忆。
64
+ const grounding = renderWorkingState(getTasks())
65
+ const results = mm.recall(query, 10, grounding)
62
66
  if (results.length === 0) return { success: true, content: '(no matching memories)' }
63
67
  const lines = results.map((entry) => {
64
68
  const snippet = entry.content.replace(/\n+/g, ' ').trim().slice(0, 140)
@@ -1,4 +1,5 @@
1
1
  import type { ToolDefinition } from '../../shared/index.ts'
2
+ import { hasSupportedEvidenceSince } from '../../core/working-memory'
2
3
 
3
4
  export interface Task {
4
5
  id: string
@@ -11,6 +12,8 @@ export interface Task {
11
12
  blockedBy: string[]
12
13
  owner?: string
13
14
  createdAt: string
15
+ /** 完成证据:completed 时是否有 supported 决策接地(软门,unverified 只标记不阻断)。 */
16
+ completionEvidence?: 'supported' | 'unverified'
14
17
  /** Background task output content (set when a background agent completes). */
15
18
  output?: string
16
19
  /** Background task error message (set when a background agent fails). */
@@ -218,6 +221,9 @@ export const taskTool: ToolDefinition = {
218
221
  `Status: ${task.status}`,
219
222
  `Description: ${task.description || '(none)'}`,
220
223
  ]
224
+ if (task.completionEvidence) {
225
+ lines.push(`Completion evidence: ${task.completionEvidence}`)
226
+ }
221
227
  if (task.activeForm) lines.push(`Active form: ${task.activeForm}`)
222
228
  if (task.owner) lines.push(`Owner: ${task.owner}`)
223
229
  if (task.blocks.length) lines.push(`Blocks: ${task.blocks.join(', ')}`)
@@ -255,7 +261,16 @@ export const taskTool: ToolDefinition = {
255
261
  if (params.subject !== undefined) task.subject = params.subject as string
256
262
  if (params.description !== undefined) task.description = params.description as string
257
263
  if (params.activeForm !== undefined) task.activeForm = params.activeForm as string
258
- if (params.status !== undefined) task.status = params.status as Task['status']
264
+ if (params.status !== undefined) {
265
+ task.status = params.status as Task['status']
266
+ // 完成证据门(软门):completed 时按「自任务创建以来是否有 supported 决策」标记。
267
+ // unverified 只标记不阻断(对齐 CRSI「最小干预」)。
268
+ if (params.status === 'completed') {
269
+ task.completionEvidence = hasSupportedEvidenceSince(new Date(task.createdAt).getTime())
270
+ ? 'supported'
271
+ : 'unverified'
272
+ }
273
+ }
259
274
  if (params.owner !== undefined) task.owner = params.owner as string
260
275
 
261
276
  // Merge dependency arrays
@@ -281,10 +296,14 @@ export const taskTool: ToolDefinition = {
281
296
  const blockedNote = isBlocked(task)
282
297
  ? ` (blocked — waiting on: ${getBlockingIds(task).join(', ')})`
283
298
  : ''
299
+ const unverifiedNote =
300
+ task.status === 'completed' && task.completionEvidence === 'unverified'
301
+ ? '\n⚠ completed without verified evidence (no supported tool decision since task start)'
302
+ : ''
284
303
 
285
304
  return {
286
305
  success: true,
287
- content: `Task #${taskId} updated.\n${formatTask(task)}${blockedNote}`,
306
+ content: `Task #${taskId} updated.\n${formatTask(task)}${blockedNote}${unverifiedNote}`,
288
307
  }
289
308
  }
290
309