@miphamai/cli 0.5.7 → 0.5.9
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/bin/mipham.ts +95 -0
- package/package.json +1 -1
- package/src/config/loader.ts +178 -14
- package/src/plugin/plugin-manager.ts +77 -0
- package/src/providers/anthropic.ts +12 -2
- package/src/providers/bootstrap.ts +11 -1
- package/src/providers/openai-compat.ts +17 -4
- package/src/shared/constants.ts +45 -0
- package/src/shared/update.ts +158 -0
- package/src/skills/loader.ts +13 -0
- package/src/skills/registry.ts +325 -0
- package/src/ui/app.tsx +1 -1
- package/src/ui/chat.tsx +11 -1
- package/src/ui/command-picker.tsx +145 -0
- package/src/ui/commands.ts +788 -164
- package/src/ui/input.tsx +95 -26
- package/dist/mipham +0 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mipham Code — Self-update utilities.
|
|
3
|
+
*
|
|
4
|
+
* Shared between the CLI entry point (bin/mipham.ts) and the TUI slash
|
|
5
|
+
* command handler (commands.ts) so that both `mipham update` and `/upgrade`
|
|
6
|
+
* use the same logic.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync, existsSync, copyFileSync, mkdirSync, chmodSync } from 'node:fs'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { execSync } from 'node:child_process'
|
|
12
|
+
import { homedir } from 'node:os'
|
|
13
|
+
|
|
14
|
+
const PACKAGE = '@miphamai/cli'
|
|
15
|
+
const HOME = homedir()
|
|
16
|
+
const MIPHAM_HOME = join(HOME, '.mipham')
|
|
17
|
+
const CONFIG_PATH = join(MIPHAM_HOME, 'config.yml')
|
|
18
|
+
|
|
19
|
+
export interface UpdateCheck {
|
|
20
|
+
/** Current installed version */
|
|
21
|
+
current: string
|
|
22
|
+
/** Latest version on npm */
|
|
23
|
+
latest: string
|
|
24
|
+
/** Whether an update is available */
|
|
25
|
+
available: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Read the currently installed version from package.json.
|
|
30
|
+
*/
|
|
31
|
+
export function getCurrentVersion(): string {
|
|
32
|
+
try {
|
|
33
|
+
// Try the CLI's own package.json first
|
|
34
|
+
const cliPkg = join(import.meta.dirname!, '..', '..', 'package.json')
|
|
35
|
+
if (existsSync(cliPkg)) {
|
|
36
|
+
const pkg = JSON.parse(readFileSync(cliPkg, 'utf-8'))
|
|
37
|
+
return pkg.version
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
// fall through
|
|
41
|
+
}
|
|
42
|
+
return 'unknown'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Fetch the latest version from the npm registry.
|
|
47
|
+
* Returns the version string, or throws on failure.
|
|
48
|
+
*/
|
|
49
|
+
function fetchLatestVersion(): string {
|
|
50
|
+
const result = execSync(`npm view ${PACKAGE} version --json`, {
|
|
51
|
+
encoding: 'utf-8',
|
|
52
|
+
timeout: 10_000,
|
|
53
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
54
|
+
}).trim()
|
|
55
|
+
return result.replace(/"/g, '')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Check if an update is available by comparing the local version
|
|
60
|
+
* against the npm registry.
|
|
61
|
+
*/
|
|
62
|
+
export function checkForUpdates(): UpdateCheck {
|
|
63
|
+
const current = getCurrentVersion()
|
|
64
|
+
let latest = current
|
|
65
|
+
let available = false
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
latest = fetchLatestVersion()
|
|
69
|
+
available = compareVersions(latest, current) > 0
|
|
70
|
+
} catch {
|
|
71
|
+
// If we can't reach npm, treat as up-to-date (don't alarm the user)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { current, latest, available }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Back up the user's config.yml before an update.
|
|
79
|
+
* Returns the backup path, or null if backup wasn't possible.
|
|
80
|
+
*/
|
|
81
|
+
export function backupConfig(label: string): string | null {
|
|
82
|
+
if (!existsSync(CONFIG_PATH)) return null
|
|
83
|
+
try {
|
|
84
|
+
mkdirSync(MIPHAM_HOME, { recursive: true, mode: 0o700 })
|
|
85
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-')
|
|
86
|
+
const backupPath = join(MIPHAM_HOME, `config.pre-${label}-${ts}.yml`)
|
|
87
|
+
copyFileSync(CONFIG_PATH, backupPath)
|
|
88
|
+
chmodSync(backupPath, 0o600) // owner read/write only — contains API keys
|
|
89
|
+
return backupPath
|
|
90
|
+
} catch {
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Validate a semver string before passing it to a shell command. */
|
|
96
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?(\+[a-zA-Z0-9.]+)?$/
|
|
97
|
+
|
|
98
|
+
function isValidSemver(v: string): boolean {
|
|
99
|
+
return SEMVER_RE.test(v)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Perform the actual update via npm install -g.
|
|
104
|
+
* Validates the version string before shell execution (prevents command injection).
|
|
105
|
+
* Returns true on success.
|
|
106
|
+
*/
|
|
107
|
+
export function performUpdate(version: string): boolean {
|
|
108
|
+
if (!isValidSemver(version)) {
|
|
109
|
+
process.stderr.write(`⚠ Refusing to install invalid version: "${version}"\n`)
|
|
110
|
+
return false
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
execSync(`npm install -g ${PACKAGE}@${version}`, {
|
|
115
|
+
encoding: 'utf-8',
|
|
116
|
+
stdio: 'inherit',
|
|
117
|
+
timeout: 120_000,
|
|
118
|
+
})
|
|
119
|
+
return true
|
|
120
|
+
} catch {
|
|
121
|
+
return false
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Restore config from a backup path.
|
|
127
|
+
*/
|
|
128
|
+
export function restoreConfig(backupPath: string): boolean {
|
|
129
|
+
if (!existsSync(backupPath)) return false
|
|
130
|
+
try {
|
|
131
|
+
copyFileSync(backupPath, CONFIG_PATH)
|
|
132
|
+
return true
|
|
133
|
+
} catch {
|
|
134
|
+
return false
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Get the config path so callers can verify it survived.
|
|
140
|
+
*/
|
|
141
|
+
export function getConfigPath(): string {
|
|
142
|
+
return CONFIG_PATH
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Compare two semver strings. Returns >0 if a > b, <0 if a < b, 0 if equal.
|
|
147
|
+
*/
|
|
148
|
+
function compareVersions(a: string, b: string): number {
|
|
149
|
+
const pa = a.split('.').map(Number)
|
|
150
|
+
const pb = b.split('.').map(Number)
|
|
151
|
+
for (let i = 0; i < 3; i++) {
|
|
152
|
+
const da = pa[i] ?? 0
|
|
153
|
+
const db = pb[i] ?? 0
|
|
154
|
+
if (da > db) return 1
|
|
155
|
+
if (da < db) return -1
|
|
156
|
+
}
|
|
157
|
+
return 0
|
|
158
|
+
}
|
package/src/skills/loader.ts
CHANGED
|
@@ -66,6 +66,19 @@ export class SkillsLoader {
|
|
|
66
66
|
return this.skills.has(name)
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
countByType(): { standard: number; mipham: number; total: number } {
|
|
70
|
+
const all = this.list()
|
|
71
|
+
return {
|
|
72
|
+
standard: all.filter((s) => s.type === 'standard').length,
|
|
73
|
+
mipham: all.filter((s) => s.type === 'mipham').length,
|
|
74
|
+
total: all.length,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
getNamesByType(type: 'standard' | 'mipham'): string[] {
|
|
79
|
+
return this.listByType(type).map((s) => s.name)
|
|
80
|
+
}
|
|
81
|
+
|
|
69
82
|
private loadDirectory(dir: string, type: 'standard' | 'mipham'): void {
|
|
70
83
|
try {
|
|
71
84
|
const entries = readdirSync(dir)
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mipham Code — Skill Registry & Installer
|
|
3
|
+
*
|
|
4
|
+
* Provides a community skill marketplace: browse, search, and install skills
|
|
5
|
+
* from remote sources (GitHub repos, direct URLs).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, writeFileSync, readdirSync, unlinkSync, chmodSync } from 'node:fs'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { homedir } from 'node:os'
|
|
11
|
+
import { spawnSync } from 'node:child_process'
|
|
12
|
+
import { URL } from 'node:url'
|
|
13
|
+
|
|
14
|
+
const SKILLS_DIR = join(homedir(), '.mipham', 'skills')
|
|
15
|
+
|
|
16
|
+
// ═══════════════════════════════════════════════════════════════
|
|
17
|
+
// Community Skill Registry
|
|
18
|
+
// ═══════════════════════════════════════════════════════════════
|
|
19
|
+
|
|
20
|
+
export interface SkillEntry {
|
|
21
|
+
/** Unique skill name (kebab-case) */
|
|
22
|
+
name: string
|
|
23
|
+
/** One-line description */
|
|
24
|
+
description: string
|
|
25
|
+
/** GitHub repo URL (https://github.com/owner/repo) */
|
|
26
|
+
url: string
|
|
27
|
+
/** Skill file path within the repo (e.g. "skill.SKILL.md") */
|
|
28
|
+
file?: string
|
|
29
|
+
/** Category for grouping */
|
|
30
|
+
category: string
|
|
31
|
+
/** Author */
|
|
32
|
+
author: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Embedded community skill registry.
|
|
37
|
+
* In the future, this can be fetched from a remote URL.
|
|
38
|
+
*/
|
|
39
|
+
const COMMUNITY_SKILLS: SkillEntry[] = [
|
|
40
|
+
{
|
|
41
|
+
name: 'code-review',
|
|
42
|
+
description: 'Automated code review with multiple dimensions',
|
|
43
|
+
url: 'https://github.com/One-Mipham/skill-code-review',
|
|
44
|
+
file: 'code-review.SKILL.md',
|
|
45
|
+
category: 'Development',
|
|
46
|
+
author: 'MiphamAI',
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: 'systematic-debugging',
|
|
50
|
+
description: 'Systematic debugging workflow — find root cause before fixing',
|
|
51
|
+
url: 'https://github.com/anthropics/skills',
|
|
52
|
+
file: 'systematic-debugging.SKILL.md',
|
|
53
|
+
category: 'Development',
|
|
54
|
+
author: 'Anthropic',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'test-driven-development',
|
|
58
|
+
description: 'TDD workflow — write failing test first, then implement',
|
|
59
|
+
url: 'https://github.com/anthropics/skills',
|
|
60
|
+
file: 'test-driven-development.SKILL.md',
|
|
61
|
+
category: 'Development',
|
|
62
|
+
author: 'Anthropic',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'web-access',
|
|
66
|
+
description: 'Web search, scraping, and browser automation',
|
|
67
|
+
url: 'https://github.com/One-Mipham/skill-web-access',
|
|
68
|
+
file: 'web-access.SKILL.md',
|
|
69
|
+
category: 'Network',
|
|
70
|
+
author: 'MiphamAI',
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'doc-generator',
|
|
74
|
+
description: 'Generate API docs, READMEs, and changelogs',
|
|
75
|
+
url: 'https://github.com/One-Mipham/skill-doc-generator',
|
|
76
|
+
file: 'doc-generator.SKILL.md',
|
|
77
|
+
category: 'Documentation',
|
|
78
|
+
author: 'MiphamAI',
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: 'github-ops',
|
|
82
|
+
description: 'GitHub PR/issue management and automation',
|
|
83
|
+
url: 'https://github.com/One-Mipham/skill-github-ops',
|
|
84
|
+
file: 'github-ops.SKILL.md',
|
|
85
|
+
category: 'DevOps',
|
|
86
|
+
author: 'MiphamAI',
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: 'security-review',
|
|
90
|
+
description: 'Security audit and vulnerability scanning for code changes',
|
|
91
|
+
url: 'https://github.com/One-Mipham/skill-security-review',
|
|
92
|
+
file: 'security-review.SKILL.md',
|
|
93
|
+
category: 'Security',
|
|
94
|
+
author: 'MiphamAI',
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'frontend-design',
|
|
98
|
+
description: 'Distinctive visual design guidance for UI components',
|
|
99
|
+
url: 'https://github.com/anthropics/skills',
|
|
100
|
+
file: 'frontend-design.SKILL.md',
|
|
101
|
+
category: 'Design',
|
|
102
|
+
author: 'Anthropic',
|
|
103
|
+
},
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
// ═══════════════════════════════════════════════════════════════
|
|
107
|
+
// Skill Installer
|
|
108
|
+
// ═══════════════════════════════════════════════════════════════
|
|
109
|
+
|
|
110
|
+
export interface InstallResult {
|
|
111
|
+
success: boolean
|
|
112
|
+
name: string
|
|
113
|
+
message: string
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get the list of available community skills.
|
|
118
|
+
*/
|
|
119
|
+
export function getAvailableSkills(): SkillEntry[] {
|
|
120
|
+
return [...COMMUNITY_SKILLS]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Search for skills by name or description.
|
|
125
|
+
*/
|
|
126
|
+
export function searchSkills(query: string): SkillEntry[] {
|
|
127
|
+
const q = query.toLowerCase()
|
|
128
|
+
return COMMUNITY_SKILLS.filter(
|
|
129
|
+
(s) => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q),
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Install a skill from a GitHub repository.
|
|
135
|
+
*
|
|
136
|
+
* Clones the repo to a temp directory, copies the skill file(s),
|
|
137
|
+
* and cleans up.
|
|
138
|
+
*/
|
|
139
|
+
export function installSkill(skillName: string): InstallResult {
|
|
140
|
+
const entry = COMMUNITY_SKILLS.find((s) => s.name === skillName)
|
|
141
|
+
if (!entry) {
|
|
142
|
+
return {
|
|
143
|
+
success: false,
|
|
144
|
+
name: skillName,
|
|
145
|
+
message: `Skill "${skillName}" not found in the registry. Use /browse-skills to see available skills.`,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const destDir = join(SKILLS_DIR)
|
|
150
|
+
const destPath = join(destDir, `${skillName}.SKILL.md`)
|
|
151
|
+
|
|
152
|
+
// Check if already installed
|
|
153
|
+
if (existsSync(destPath)) {
|
|
154
|
+
return {
|
|
155
|
+
success: false,
|
|
156
|
+
name: skillName,
|
|
157
|
+
message: `Skill "${skillName}" is already installed. Remove it first to reinstall.`,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
mkdirSync(destDir, { recursive: true })
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
// Download the skill file from GitHub raw content
|
|
165
|
+
const rawUrl = githubRawUrl(entry.url, entry.file || `${skillName}.SKILL.md`)
|
|
166
|
+
const content = downloadFile(rawUrl)
|
|
167
|
+
|
|
168
|
+
// Validate it's a proper skill file (has frontmatter)
|
|
169
|
+
if (!content.includes('---')) {
|
|
170
|
+
return {
|
|
171
|
+
success: false,
|
|
172
|
+
name: skillName,
|
|
173
|
+
message: `Downloaded file does not appear to be a valid skill (missing frontmatter).`,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
writeFileSync(destPath, content, 'utf-8')
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
success: true,
|
|
181
|
+
name: skillName,
|
|
182
|
+
message: `Skill "${skillName}" installed to ${destPath}\nRun /reload-skills to activate it.`,
|
|
183
|
+
}
|
|
184
|
+
} catch (err: unknown) {
|
|
185
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
186
|
+
return {
|
|
187
|
+
success: false,
|
|
188
|
+
name: skillName,
|
|
189
|
+
message: `Failed to install "${skillName}": ${msg}`,
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Install a skill from a direct URL (GitHub raw, gist, or any HTTP URL).
|
|
196
|
+
*/
|
|
197
|
+
export function installSkillFromUrl(url: string): InstallResult {
|
|
198
|
+
// Derive skill name from URL
|
|
199
|
+
const name =
|
|
200
|
+
url
|
|
201
|
+
.split('/')
|
|
202
|
+
.pop()
|
|
203
|
+
?.replace(/\.(SKILL\.)?md$/i, '') || 'custom-skill'
|
|
204
|
+
|
|
205
|
+
const destDir = join(SKILLS_DIR)
|
|
206
|
+
const destPath = join(destDir, `${name}.SKILL.md`)
|
|
207
|
+
|
|
208
|
+
mkdirSync(destDir, { recursive: true })
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
const content = downloadFile(url)
|
|
212
|
+
|
|
213
|
+
if (!content.includes('---')) {
|
|
214
|
+
return {
|
|
215
|
+
success: false,
|
|
216
|
+
name,
|
|
217
|
+
message: `Downloaded file does not appear to be a valid skill (missing frontmatter).`,
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
writeFileSync(destPath, content, 'utf-8')
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
success: true,
|
|
225
|
+
name,
|
|
226
|
+
message: `Skill "${name}" installed from URL to ${destPath}\nRun /reload-skills to activate it.`,
|
|
227
|
+
}
|
|
228
|
+
} catch (err: unknown) {
|
|
229
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
230
|
+
return { success: false, name, message: `Failed to install from URL: ${msg}` }
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* List installed skills (files in ~/.mipham/skills/).
|
|
236
|
+
*/
|
|
237
|
+
export function listInstalledSkills(): string[] {
|
|
238
|
+
if (!existsSync(SKILLS_DIR)) return []
|
|
239
|
+
return readdirSync(SKILLS_DIR).filter(
|
|
240
|
+
(f: string) => f.endsWith('.SKILL.md') || f.endsWith('.mipham-skill.md'),
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Remove an installed skill.
|
|
246
|
+
*/
|
|
247
|
+
export function removeSkill(skillName: string): InstallResult {
|
|
248
|
+
const destPath = join(SKILLS_DIR, `${skillName}.SKILL.md`)
|
|
249
|
+
if (!existsSync(destPath)) {
|
|
250
|
+
return { success: false, name: skillName, message: `Skill "${skillName}" is not installed.` }
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
unlinkSync(destPath)
|
|
254
|
+
return {
|
|
255
|
+
success: true,
|
|
256
|
+
name: skillName,
|
|
257
|
+
message: `Skill "${skillName}" removed. Run /reload-skills to refresh.`,
|
|
258
|
+
}
|
|
259
|
+
} catch (err: unknown) {
|
|
260
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
261
|
+
return { success: false, name: skillName, message: `Failed to remove: ${msg}` }
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ═══════════════════════════════════════════════════════════════
|
|
266
|
+
// Helpers
|
|
267
|
+
// ═══════════════════════════════════════════════════════════════
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Convert a GitHub repo URL to a raw content URL.
|
|
271
|
+
* https://github.com/owner/repo → https://raw.githubusercontent.com/owner/repo/main/<file>
|
|
272
|
+
*/
|
|
273
|
+
function githubRawUrl(repoUrl: string, file: string): string {
|
|
274
|
+
const base = repoUrl.replace('https://github.com/', 'https://raw.githubusercontent.com/')
|
|
275
|
+
return `${base}/main/${file}`
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Allowed domains for remote skill installation */
|
|
279
|
+
const ALLOWED_DOMAINS = [
|
|
280
|
+
'raw.githubusercontent.com',
|
|
281
|
+
'github.com',
|
|
282
|
+
'gist.githubusercontent.com',
|
|
283
|
+
'gitlab.com',
|
|
284
|
+
]
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Download a file from a URL using curl with spawn (no shell injection).
|
|
288
|
+
* Only allows HTTPS URLs from known safe domains.
|
|
289
|
+
*/
|
|
290
|
+
function downloadFile(rawUrl: string): string {
|
|
291
|
+
let parsed: URL
|
|
292
|
+
try {
|
|
293
|
+
parsed = new URL(rawUrl)
|
|
294
|
+
} catch {
|
|
295
|
+
throw new Error(`Invalid URL: ${rawUrl}`)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Protocol must be HTTPS
|
|
299
|
+
if (parsed.protocol !== 'https:') {
|
|
300
|
+
throw new Error(`Only HTTPS URLs are allowed (got: ${parsed.protocol})`)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Domain must be in allowlist
|
|
304
|
+
if (!ALLOWED_DOMAINS.some((d) => parsed.hostname === d || parsed.hostname.endsWith('.' + d))) {
|
|
305
|
+
throw new Error(
|
|
306
|
+
`Domain not allowed: ${parsed.hostname}. Allowed: ${ALLOWED_DOMAINS.join(', ')}`,
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Use spawnSync with args array — no shell, no injection
|
|
311
|
+
const result = spawnSync('curl', ['-fsSL', rawUrl], {
|
|
312
|
+
encoding: 'utf-8',
|
|
313
|
+
timeout: 15_000,
|
|
314
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
if (result.error) {
|
|
318
|
+
throw new Error(`Failed to download: ${result.error.message}`)
|
|
319
|
+
}
|
|
320
|
+
if (result.status !== 0) {
|
|
321
|
+
throw new Error(`Download failed with status ${result.status}`)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return result.stdout
|
|
325
|
+
}
|
package/src/ui/app.tsx
CHANGED
|
@@ -423,7 +423,7 @@ export function App({
|
|
|
423
423
|
</Text>
|
|
424
424
|
<Text dimColor> · ⏵⏵ accept edits on</Text>
|
|
425
425
|
<Text dimColor> (Shift+Tab to cycle)</Text>
|
|
426
|
-
<Text dimColor> · Ctrl+P pick · /help · Esc cancel</Text>
|
|
426
|
+
<Text dimColor> · Ctrl+P pick · /help /commands · Esc cancel</Text>
|
|
427
427
|
<Text dimColor> · ← agents</Text>
|
|
428
428
|
</Box>
|
|
429
429
|
</Box>
|
package/src/ui/chat.tsx
CHANGED
|
@@ -7,6 +7,16 @@ interface ChatPanelProps {
|
|
|
7
7
|
focusMode?: boolean
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
/** Format cwd for display: replace HOME with ~, truncate if too long */
|
|
11
|
+
function displayCwd(): string {
|
|
12
|
+
const cwd = process.cwd()
|
|
13
|
+
const home = process.env.HOME || ''
|
|
14
|
+
if (home && cwd.startsWith(home)) {
|
|
15
|
+
return '~' + cwd.slice(home.length)
|
|
16
|
+
}
|
|
17
|
+
return cwd
|
|
18
|
+
}
|
|
19
|
+
|
|
10
20
|
export function ChatPanel({ messages, focusMode }: ChatPanelProps) {
|
|
11
21
|
// In focus mode, show only the last user+assistant exchange
|
|
12
22
|
const displayMessages = focusMode ? getLastExchange(messages) : messages
|
|
@@ -61,7 +71,7 @@ export function ChatPanel({ messages, focusMode }: ChatPanelProps) {
|
|
|
61
71
|
color={msg.role === 'user' ? 'green' : msg.role === 'system' ? 'yellow' : 'blue'}
|
|
62
72
|
>
|
|
63
73
|
{msg.role === 'user'
|
|
64
|
-
?
|
|
74
|
+
? `▸ ${displayCwd()}`
|
|
65
75
|
: msg.role === 'assistant'
|
|
66
76
|
? 'Mipham Code'
|
|
67
77
|
: '⚠ System'}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import React, { useState, useMemo, useEffect } from 'react'
|
|
2
|
+
import { Box, Text, useInput } from 'ink'
|
|
3
|
+
import TextInput from 'ink-text-input'
|
|
4
|
+
import { getCommandList, type CommandEntry } from './commands.js'
|
|
5
|
+
|
|
6
|
+
interface CommandPickerProps {
|
|
7
|
+
/** Text already typed (e.g. "/", "/age") — used as initial filter */
|
|
8
|
+
initialFilter: string
|
|
9
|
+
/** Called when user selects a command */
|
|
10
|
+
onSelect: (commandName: string) => void
|
|
11
|
+
/** Called when user presses Esc */
|
|
12
|
+
onClose: () => void
|
|
13
|
+
/** Max visible items before scrolling */
|
|
14
|
+
maxVisible?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const PAGE_SIZE = 12
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* CommandPicker — interactive slash-command selector.
|
|
21
|
+
*
|
|
22
|
+
* Reuses the ModelPicker's Ink interaction pattern:
|
|
23
|
+
* ↑/↓ cursor navigation (wrapping), Enter to select, Esc to close.
|
|
24
|
+
* Plus real-time filtering — user types to narrow the command list.
|
|
25
|
+
*/
|
|
26
|
+
export function CommandPicker({
|
|
27
|
+
initialFilter,
|
|
28
|
+
onSelect,
|
|
29
|
+
onClose,
|
|
30
|
+
maxVisible = PAGE_SIZE,
|
|
31
|
+
}: CommandPickerProps) {
|
|
32
|
+
const allCommands = useMemo(() => getCommandList(), [])
|
|
33
|
+
const [filter, setFilter] = useState(initialFilter)
|
|
34
|
+
const [cursorIdx, setCursorIdx] = useState(0)
|
|
35
|
+
|
|
36
|
+
// Filter commands based on user input
|
|
37
|
+
const filtered = useMemo(() => {
|
|
38
|
+
const q = filter.startsWith('/') ? filter.slice(1).toLowerCase() : filter.toLowerCase()
|
|
39
|
+
if (!q) return allCommands
|
|
40
|
+
return allCommands.filter(
|
|
41
|
+
(cmd) => cmd.name.toLowerCase().includes(q) || cmd.description.toLowerCase().includes(q),
|
|
42
|
+
)
|
|
43
|
+
}, [filter, allCommands])
|
|
44
|
+
|
|
45
|
+
// Reset cursor when filter changes
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
setCursorIdx(0)
|
|
48
|
+
}, [filter])
|
|
49
|
+
|
|
50
|
+
// Scroll window: keep cursor in the visible range
|
|
51
|
+
const scrollStart = Math.max(
|
|
52
|
+
0,
|
|
53
|
+
Math.min(cursorIdx - Math.floor(maxVisible / 2), filtered.length - maxVisible),
|
|
54
|
+
)
|
|
55
|
+
const visible = filtered.slice(scrollStart, scrollStart + maxVisible)
|
|
56
|
+
const adjustedCursor = cursorIdx - scrollStart
|
|
57
|
+
|
|
58
|
+
// Wrap cursor safely
|
|
59
|
+
const safeCursor = (i: number) =>
|
|
60
|
+
filtered.length === 0 ? 0 : ((i % filtered.length) + filtered.length) % filtered.length
|
|
61
|
+
|
|
62
|
+
useInput((_input, key) => {
|
|
63
|
+
if (key.escape) {
|
|
64
|
+
onClose()
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (key.return) {
|
|
69
|
+
const selected = filtered[cursorIdx]
|
|
70
|
+
if (selected) {
|
|
71
|
+
onSelect(selected.name)
|
|
72
|
+
}
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (key.upArrow) {
|
|
77
|
+
setCursorIdx((prev) => safeCursor(prev - 1))
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (key.downArrow) {
|
|
82
|
+
setCursorIdx((prev) => safeCursor(prev + 1))
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const itemColor = (isCursor: boolean) => (isCursor ? 'cyan' : undefined)
|
|
88
|
+
const itemBold = (isCursor: boolean) => isCursor
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<Box flexDirection="column" borderStyle="round" borderColor="cyan" padding={1}>
|
|
92
|
+
{/* Title */}
|
|
93
|
+
<Box marginBottom={1}>
|
|
94
|
+
<Text bold color="cyan">
|
|
95
|
+
Commands
|
|
96
|
+
</Text>
|
|
97
|
+
<Text dimColor> ↑↓ navigate · Enter select · Esc close</Text>
|
|
98
|
+
</Box>
|
|
99
|
+
|
|
100
|
+
{/* Command list */}
|
|
101
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
102
|
+
{visible.length === 0 && <Text dimColor> No commands match "{filter}"</Text>}
|
|
103
|
+
{visible.map((cmd, i) => {
|
|
104
|
+
const globalIdx = i + scrollStart
|
|
105
|
+
const isCursor = globalIdx === cursorIdx
|
|
106
|
+
return (
|
|
107
|
+
<Box key={cmd.name}>
|
|
108
|
+
<Text color={itemColor(isCursor)} bold={itemBold(isCursor)}>
|
|
109
|
+
{isCursor ? '▶ ' : ' '}
|
|
110
|
+
{cmd.name.padEnd(20)}
|
|
111
|
+
</Text>
|
|
112
|
+
<Text dimColor>{cmd.description}</Text>
|
|
113
|
+
</Box>
|
|
114
|
+
)
|
|
115
|
+
})}
|
|
116
|
+
</Box>
|
|
117
|
+
|
|
118
|
+
{/* Scroll indicator */}
|
|
119
|
+
{filtered.length > maxVisible && (
|
|
120
|
+
<Box marginBottom={1}>
|
|
121
|
+
<Text dimColor>
|
|
122
|
+
{scrollStart + 1}–{Math.min(scrollStart + maxVisible, filtered.length)} of{' '}
|
|
123
|
+
{filtered.length} commands
|
|
124
|
+
</Text>
|
|
125
|
+
</Box>
|
|
126
|
+
)}
|
|
127
|
+
|
|
128
|
+
{/* Filter input */}
|
|
129
|
+
<Box>
|
|
130
|
+
<Text color="cyan">/ </Text>
|
|
131
|
+
<TextInput
|
|
132
|
+
value={filter.startsWith('/') ? filter.slice(1) : filter}
|
|
133
|
+
onChange={(val) => setFilter(`/${val}`)}
|
|
134
|
+
onSubmit={() => {
|
|
135
|
+
const selected = filtered[cursorIdx]
|
|
136
|
+
if (selected) {
|
|
137
|
+
onSelect(selected.name)
|
|
138
|
+
}
|
|
139
|
+
}}
|
|
140
|
+
placeholder="Type to filter commands..."
|
|
141
|
+
/>
|
|
142
|
+
</Box>
|
|
143
|
+
</Box>
|
|
144
|
+
)
|
|
145
|
+
}
|