@miphamai/cli 0.69.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.
- package/package.json +1 -1
- package/src/commands/loop-scaffold.ts +15 -44
- package/src/config/loader.ts +56 -1
- package/src/core/claude-md-fix.ts +28 -0
- package/src/core/fix-code.ts +167 -0
- package/src/core/fix.ts +176 -0
- package/src/core/hooks-config.ts +10 -5
- package/src/core/hooks-executor.ts +84 -5
- package/src/core/instructions.ts +2 -2
- package/src/i18n-core/locales/en-US.json +21 -1
- package/src/i18n-core/locales/zh-CN.json +21 -1
- package/src/index.tsx +12 -0
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +2 -0
- package/src/skills/community-registry.json +16 -0
- package/src/skills/marketplace.ts +233 -0
- package/src/skills/registry.ts +77 -63
- package/src/ui/commands.ts +311 -33
package/src/core/instructions.ts
CHANGED
|
@@ -11,12 +11,12 @@ import {
|
|
|
11
11
|
type CrsiLessonSummary,
|
|
12
12
|
} from './crsi-producer'
|
|
13
13
|
|
|
14
|
-
interface FrontmatterResult {
|
|
14
|
+
export interface FrontmatterResult {
|
|
15
15
|
data: Record<string, unknown>
|
|
16
16
|
content: string
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
function parseFrontmatter(raw: string): FrontmatterResult {
|
|
19
|
+
export function parseFrontmatter(raw: string): FrontmatterResult {
|
|
20
20
|
// Strip a leading UTF-8 BOM — otherwise `^---` never matches and a
|
|
21
21
|
// BOM-prefixed file is silently treated as body text (effectively ignored).
|
|
22
22
|
const src = raw.replace(/^\uFEFF/, '')
|
|
@@ -579,7 +579,7 @@
|
|
|
579
579
|
},
|
|
580
580
|
"hooks": {
|
|
581
581
|
"title": "── Hooks ──",
|
|
582
|
-
"no_hooks": "── Hooks ──\n\nNo hooks configured.\n\
|
|
582
|
+
"no_hooks": "── Hooks ──\n\nNo hooks configured.\n\nConfigure hooks in .mipham/settings.json under the hooks section:\n PreToolUse — before each tool\n PostToolUse — after each tool\n Stop — on session end\n\nCommand hooks read JSON from stdin; exit 2 blocks (stderr as reason).\nUse /loop init to scaffold settings.json.",
|
|
583
583
|
"location": "Location: {path}",
|
|
584
584
|
"found": "{count} hook(s) found.",
|
|
585
585
|
"error": "── Hooks ──\n\nCould not read hooks directory."
|
|
@@ -609,6 +609,26 @@
|
|
|
609
609
|
"audit_clean": " ✓ no code-derivable sections found",
|
|
610
610
|
"audit_found": "Add the sections above to the `prompt-exclude` frontmatter to stop sending them to the model."
|
|
611
611
|
},
|
|
612
|
+
"fix": {
|
|
613
|
+
"title": "── Fix ──",
|
|
614
|
+
"dryrun_banner": "[dry-run] preview — no changes written",
|
|
615
|
+
"doctor_clean": "✓ no code-derivable CLAUDE.md sections to fix",
|
|
616
|
+
"doctor_fixed": "✓ {path}: added prompt-exclude [{added}]",
|
|
617
|
+
"config_clean": "✓ config healthy, no disabled hooks",
|
|
618
|
+
"config_corrupt": "⚠ {path}: config corrupt",
|
|
619
|
+
"config_restored": "✓ restored config from backup: {path}",
|
|
620
|
+
"hook_disabled": "⚠ hook disabled: {key}",
|
|
621
|
+
"hook_reenabled": "✓ re-enabled hook: {key}",
|
|
622
|
+
"cache_clean": "✓ no corrupt cache lines",
|
|
623
|
+
"cache_found": "⚠ {path}: {count} corrupt line(s)",
|
|
624
|
+
"cache_cleaned": "✓ cleaned {path}: removed {count} line(s)",
|
|
625
|
+
"cache_hint": "Run /fix cache --apply to remove corrupt lines",
|
|
626
|
+
"test_usage": "Usage: /fix test <test-file> [--apply]\n\nLLM-driven repair of a failing test (test frozen, source only). Dry-run by default; --apply writes the fix.",
|
|
627
|
+
"test_no_llm": "No LLM available in this session — cannot generate a fix.",
|
|
628
|
+
"test_fixed": "✓ fixed {test} (source {source}, {attempts} attempt(s))",
|
|
629
|
+
"test_failed": "✗ could not fix {test}: {detail}",
|
|
630
|
+
"test_hint": "Run /fix test <file> --apply to write the fix."
|
|
631
|
+
},
|
|
612
632
|
"plugins": {
|
|
613
633
|
"unavailable": "PluginManager not available in this session.",
|
|
614
634
|
"title": "── Installed Plugins ──",
|
|
@@ -579,7 +579,7 @@
|
|
|
579
579
|
},
|
|
580
580
|
"hooks": {
|
|
581
581
|
"title": "── 钩子 ──",
|
|
582
|
-
"no_hooks": "── 钩子 ──\n\n未配置钩子。\n\n在 .mipham/hooks
|
|
582
|
+
"no_hooks": "── 钩子 ──\n\n未配置钩子。\n\n在 .mipham/settings.json 的 hooks 段配置钩子:\n PreToolUse — 工具执行前\n PostToolUse — 工具执行后\n Stop — 会话结束\n\n命令钩子从 stdin 读 JSON,exit 2 拦截(stderr 作理由)。\n使用 /loop init 初始化 settings.json。",
|
|
583
583
|
"location": "位置: {path}",
|
|
584
584
|
"found": "找到 {count} 个钩子。",
|
|
585
585
|
"error": "── 钩子 ──\n\n无法读取钩子目录。"
|
|
@@ -609,6 +609,26 @@
|
|
|
609
609
|
"audit_clean": " ✓ 未发现可从代码推断的冗余章节",
|
|
610
610
|
"audit_found": "将上述章节加入 `prompt-exclude` frontmatter,可停止发送给模型、节省 token。"
|
|
611
611
|
},
|
|
612
|
+
"fix": {
|
|
613
|
+
"title": "── 修复 ──",
|
|
614
|
+
"dryrun_banner": "[dry-run] 预览模式 — 不落盘",
|
|
615
|
+
"doctor_clean": "✓ CLAUDE.md 无需要修复的冗余章节",
|
|
616
|
+
"doctor_fixed": "✓ {path}: 已加 prompt-exclude [{added}]",
|
|
617
|
+
"config_clean": "✓ 配置无损坏、hooks 无禁用",
|
|
618
|
+
"config_corrupt": "⚠ {path}: 配置损坏",
|
|
619
|
+
"config_restored": "✓ 已从备份恢复 config: {path}",
|
|
620
|
+
"hook_disabled": "⚠ hook 已禁用: {key}",
|
|
621
|
+
"hook_reenabled": "✓ 已重新启用 hook: {key}",
|
|
622
|
+
"cache_clean": "✓ 缓存无损坏行",
|
|
623
|
+
"cache_found": "⚠ {path}: {count} 行损坏",
|
|
624
|
+
"cache_cleaned": "✓ 已清理 {path}: 移除 {count} 行",
|
|
625
|
+
"cache_hint": "使用 /fix cache --apply 清理损坏行",
|
|
626
|
+
"test_usage": "用法: /fix test <测试文件> [--apply]\n\nLLM 自动修复失败测试(测试冻结,只改源码)。默认 dry-run,--apply 落盘。",
|
|
627
|
+
"test_no_llm": "当前会话无可用 LLM,无法生成修复。",
|
|
628
|
+
"test_fixed": "✓ 已修复 {test}(源码 {source},{attempts} 次尝试)",
|
|
629
|
+
"test_failed": "✗ 未能修复 {test}: {detail}",
|
|
630
|
+
"test_hint": "使用 /fix test <文件> --apply 落盘修复。"
|
|
631
|
+
},
|
|
612
632
|
"plugins": {
|
|
613
633
|
"unavailable": "当前会话中 PluginManager 不可用。",
|
|
614
634
|
"title": "── 已安装插件 ──",
|
package/src/index.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
loadInferenceHookConfig,
|
|
11
11
|
loadCredentialMaskingConfig,
|
|
12
12
|
loadCrossSessionConfig,
|
|
13
|
+
loadSettingsJson,
|
|
13
14
|
} from './config/loader'
|
|
14
15
|
import {
|
|
15
16
|
registerActiveSession,
|
|
@@ -44,6 +45,7 @@ import { registerMcpServerTools, syncMcpToolsOnChange } from './mcp/registry'
|
|
|
44
45
|
import { formatMcpConnectFailures, type McpConnectFailure } from './mcp/connect-failures'
|
|
45
46
|
import { AgentRegistry } from './agent/agent-registry'
|
|
46
47
|
import { HookEngine } from './core/hooks'
|
|
48
|
+
import { loadHookConfigs } from './core/hooks-config'
|
|
47
49
|
import { ArtifactServer } from './artifacts/server'
|
|
48
50
|
import { getMetrics } from './core/metrics'
|
|
49
51
|
import { getWorkspaceTrust } from './core/workspace-trust'
|
|
@@ -514,6 +516,12 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
514
516
|
}
|
|
515
517
|
}
|
|
516
518
|
|
|
519
|
+
// Register settings.json hooks (Claude Code convention — additive across levels)
|
|
520
|
+
const settingsJson = loadSettingsJson(process.cwd())
|
|
521
|
+
for (const def of loadHookConfigs(settingsJson.hooks)) {
|
|
522
|
+
hookEngine.register(def)
|
|
523
|
+
}
|
|
524
|
+
|
|
517
525
|
// Start artifact server (lazy — first artifact creation triggers listening)
|
|
518
526
|
const artifactsDir = join(process.cwd(), MIPHAM_DIR, ARTIFACTS_DIR)
|
|
519
527
|
const artifactServer = new ArtifactServer(artifactsDir, ARTIFACT_PORT)
|
|
@@ -547,6 +555,10 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
547
555
|
for (const rule of config.permissionRules.deny ?? []) engine.getPermission().deny(rule)
|
|
548
556
|
}
|
|
549
557
|
|
|
558
|
+
// Apply settings.json permissions (allow/deny) — Claude Code convention
|
|
559
|
+
for (const rule of settingsJson.permissions.allow) engine.getPermission().allow(rule)
|
|
560
|
+
for (const rule of settingsJson.permissions.deny) engine.getPermission().deny(rule)
|
|
561
|
+
|
|
550
562
|
// Initialize agent registry and load plugin agents/skills/MCP/hooks
|
|
551
563
|
const agentRegistry = new AgentRegistry()
|
|
552
564
|
agentRegistry.loadUserAgents()
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
export const PACKAGE_NAME = '@miphamai/cli' as const
|
|
10
10
|
|
|
11
11
|
/** 当前发布版本 */
|
|
12
|
-
export const PACKAGE_VERSION = '0.
|
|
12
|
+
export const PACKAGE_VERSION = '0.70.0' as const
|
|
13
13
|
|
|
14
14
|
/** npm install 全局安装命令 */
|
|
15
15
|
export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
|
package/src/shared/types.ts
CHANGED
|
@@ -77,5 +77,21 @@
|
|
|
77
77
|
"file": "frontend-design.SKILL.md",
|
|
78
78
|
"category": "Design",
|
|
79
79
|
"author": "Anthropic"
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"name": "grill-me",
|
|
83
|
+
"description": "A relentless one-question-at-a-time interview that stress-tests your plan or design",
|
|
84
|
+
"url": "https://github.com/mattpocock/skills",
|
|
85
|
+
"file": "skills/productivity/grill-me/SKILL.md",
|
|
86
|
+
"category": "Development",
|
|
87
|
+
"author": "Matt Pocock"
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"name": "eli5",
|
|
91
|
+
"description": "Explain any topic like the user knows nothing about it, using an HTML artifact with big pictures and few words",
|
|
92
|
+
"url": "https://github.com/anthropics/claude-plugins-community",
|
|
93
|
+
"file": "eli5/skills/eli5/SKILL.md",
|
|
94
|
+
"category": "Communication",
|
|
95
|
+
"author": "Anthropic"
|
|
80
96
|
}
|
|
81
97
|
]
|
|
@@ -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
|
+
}
|
package/src/skills/registry.ts
CHANGED
|
@@ -5,13 +5,19 @@
|
|
|
5
5
|
* from remote sources (GitHub repos, direct URLs).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
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
|
-
}
|