@miphamai/cli 0.5.8 → 0.5.10

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.
@@ -1,30 +1,194 @@
1
- import { readFileSync, existsSync } from 'node:fs'
2
- import { join } from 'node:path'
1
+ import {
2
+ readFileSync,
3
+ existsSync,
4
+ copyFileSync,
5
+ mkdirSync,
6
+ readdirSync,
7
+ unlinkSync,
8
+ chmodSync,
9
+ } from 'node:fs'
10
+ import { join, dirname } from 'node:path'
11
+ import { homedir } from 'node:os'
3
12
  import { parse as parseYaml } from 'yaml'
4
- import type { MiphamConfig } from '../shared/index.ts'
13
+ import type { MiphamConfig, ProviderConfig } from '../shared/index.ts'
5
14
  import { DEFAULT_CONFIG } from './defaults'
6
15
 
16
+ const MIPHAM_HOME = join(homedir(), '.mipham')
17
+ const BACKUP_PREFIX = 'config.backup-'
18
+
19
+ /**
20
+ * Parse a YAML config file safely. Returns null on any error (missing file, bad syntax, etc).
21
+ * Prints a warning to stderr so the user knows something is wrong.
22
+ */
23
+ function safeParseYaml(path: string, label: string): Partial<MiphamConfig> | null {
24
+ try {
25
+ if (!existsSync(path)) return null
26
+ const raw = readFileSync(path, 'utf-8')
27
+ return parseYaml(raw) as Partial<MiphamConfig>
28
+ } catch (err: unknown) {
29
+ const msg = err instanceof Error ? err.message : String(err)
30
+ process.stderr.write(`⚠ Mipham Code: failed to parse ${label} (${path}): ${msg}\n`)
31
+ return null
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Deep-merge providers: for each provider in the user config, only override
37
+ * the fields the user explicitly set (apiKey, baseUrl). All other fields
38
+ * (name, protocol, models, status) come from the base defaults.
39
+ *
40
+ * This prevents users from accidentally losing model definitions when they
41
+ * only want to set their API key.
42
+ */
43
+ function mergeProviders(
44
+ baseProviders: ProviderConfig[],
45
+ overrideProviders: ProviderConfig[],
46
+ ): ProviderConfig[] {
47
+ const merged = [...baseProviders]
48
+
49
+ for (const op of overrideProviders) {
50
+ const idx = merged.findIndex((bp) => bp.id === op.id)
51
+ if (idx === -1) {
52
+ // Provider not in defaults — add it wholesale (custom provider)
53
+ merged.push(op)
54
+ continue
55
+ }
56
+
57
+ // Merge: user overrides only the fields they provide
58
+ const base = merged[idx]!
59
+ merged[idx] = {
60
+ id: base.id,
61
+ name: op.name || base.name,
62
+ protocol: op.protocol || base.protocol,
63
+ baseUrl: op.baseUrl ?? base.baseUrl,
64
+ apiKey: op.apiKey ?? base.apiKey,
65
+ models: op.models?.length ? op.models : base.models,
66
+ status: op.status ?? base.status,
67
+ }
68
+ }
69
+
70
+ return merged
71
+ }
72
+
73
+ function mergeConfig(base: MiphamConfig, override: Partial<MiphamConfig>): MiphamConfig {
74
+ const merged: MiphamConfig = { ...base, ...override }
75
+ if (override.providers) {
76
+ merged.providers = mergeProviders(base.providers, override.providers)
77
+ } else {
78
+ merged.providers = base.providers
79
+ }
80
+ return merged
81
+ }
82
+
83
+ /**
84
+ * Save a timestamped backup of config.yml to ~/.mipham/.
85
+ * Keeps at most 5 backups; older ones are pruned.
86
+ */
87
+ function backupConfig(configPath: string): void {
88
+ try {
89
+ if (!existsSync(configPath)) return
90
+ mkdirSync(MIPHAM_HOME, { recursive: true, mode: 0o700 })
91
+
92
+ const ts = new Date().toISOString().replace(/[:.]/g, '-')
93
+ const backupPath = join(MIPHAM_HOME, `${BACKUP_PREFIX}${ts}.yml`)
94
+ copyFileSync(configPath, backupPath)
95
+ chmodSync(backupPath, 0o600) // owner read/write only — contains API keys
96
+
97
+ // Prune old backups (keep last 5)
98
+ const files = readdirSync(MIPHAM_HOME)
99
+ .filter((f) => f.startsWith(BACKUP_PREFIX) && f.endsWith('.yml'))
100
+ .sort()
101
+ while (files.length > 5) {
102
+ const old = files.shift()!
103
+ try {
104
+ unlinkSync(join(MIPHAM_HOME, old))
105
+ } catch {
106
+ // best-effort cleanup
107
+ }
108
+ }
109
+ } catch {
110
+ // best-effort; never crash because backup failed
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Try to restore config from the most recent backup.
116
+ * Returns true if restored successfully.
117
+ */
118
+ function tryRestoreFromBackup(configPath: string): boolean {
119
+ try {
120
+ if (!existsSync(MIPHAM_HOME)) return false
121
+ const files = readdirSync(MIPHAM_HOME)
122
+ .filter((f) => f.startsWith(BACKUP_PREFIX) && f.endsWith('.yml'))
123
+ .sort()
124
+ .reverse() // newest first
125
+
126
+ if (files.length === 0) return false
127
+
128
+ const latestBackup = join(MIPHAM_HOME, files[0]!)
129
+ copyFileSync(latestBackup, configPath)
130
+ process.stderr.write(`⚠ Mipham Code: restored config from backup (${files[0]})\n`)
131
+ return true
132
+ } catch (err: unknown) {
133
+ const msg = err instanceof Error ? err.message : String(err)
134
+ process.stderr.write(`⚠ Mipham Code: failed to restore config from backup: ${msg}\n`)
135
+ return false
136
+ }
137
+ }
138
+
7
139
  export function loadConfig(cwd: string = process.cwd()): MiphamConfig {
8
140
  const configPath = join(cwd, '.mipham', 'config.yml')
9
- const userConfigPath = join(process.env.HOME || '~', '.mipham', 'config.yml')
141
+ const userConfigPath = join(MIPHAM_HOME, 'config.yml')
10
142
 
11
143
  let config = { ...DEFAULT_CONFIG }
12
144
 
13
- if (existsSync(configPath)) {
14
- const raw = readFileSync(configPath, 'utf-8')
15
- const projectConfig = parseYaml(raw) as Partial<MiphamConfig>
145
+ // ── Load project-level config ──
146
+ const projectConfig = safeParseYaml(configPath, 'project config')
147
+ if (projectConfig) {
16
148
  config = mergeConfig(config, projectConfig)
149
+ } else if (existsSync(configPath)) {
150
+ // File exists but failed to parse — try to restore from backup
151
+ process.stderr.write(`⚠ Mipham Code: project config is corrupted, attempting recovery...\n`)
152
+ if (!tryRestoreFromBackup(configPath)) {
153
+ process.stderr.write(`⚠ Mipham Code: no backup available for project config. Skipping.\n`)
154
+ } else {
155
+ // Retry parsing after restore
156
+ const restored = safeParseYaml(configPath, 'restored project config')
157
+ if (restored) {
158
+ config = mergeConfig(config, restored)
159
+ }
160
+ }
17
161
  }
18
162
 
19
- if (existsSync(userConfigPath)) {
20
- const raw = readFileSync(userConfigPath, 'utf-8')
21
- const userConfig = parseYaml(raw) as Partial<MiphamConfig>
163
+ // ── Load user-level config ──
164
+ const userConfig = safeParseYaml(userConfigPath, 'user config')
165
+ if (userConfig) {
22
166
  config = mergeConfig(config, userConfig)
167
+ } else if (existsSync(userConfigPath)) {
168
+ // File exists but failed to parse — try to restore from backup
169
+ process.stderr.write(`⚠ Mipham Code: user config is corrupted, attempting recovery...\n`)
170
+ if (!tryRestoreFromBackup(userConfigPath)) {
171
+ process.stderr.write(`⚠ Mipham Code: no backup available for user config. Skipping.\n`)
172
+ } else {
173
+ // Retry parsing after restore
174
+ const restored = safeParseYaml(userConfigPath, 'restored user config')
175
+ if (restored) {
176
+ config = mergeConfig(config, restored)
177
+ }
178
+ }
179
+ } else {
180
+ // No user config yet — create the directory so it's ready
181
+ try {
182
+ mkdirSync(MIPHAM_HOME, { recursive: true })
183
+ } catch {
184
+ // best-effort
185
+ }
23
186
  }
24
187
 
25
- return config
26
- }
188
+ // ── Auto-backup: save a copy of the user config if it loaded successfully ──
189
+ if (userConfig) {
190
+ backupConfig(userConfigPath)
191
+ }
27
192
 
28
- function mergeConfig(base: MiphamConfig, override: Partial<MiphamConfig>): MiphamConfig {
29
- return { ...base, ...override, providers: override.providers ?? base.providers }
193
+ return config
30
194
  }
@@ -1,6 +1,7 @@
1
1
  import { mkdirSync, existsSync, writeFileSync, readFileSync, readdirSync, rmSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { homedir } from 'node:os'
4
+ import { execSync } from 'node:child_process'
4
5
  import { validatePlugin } from './plugin-validator'
5
6
 
6
7
  const PLUGIN_DIR = join(homedir(), '.mipham', 'plugins')
@@ -58,6 +59,82 @@ export class PluginManager {
58
59
  }
59
60
  }
60
61
 
62
+ /** Validate npm package name against the npm spec. */
63
+ private isValidPackageName(name: string): boolean {
64
+ // npm package name regex: optional @scope/ + package name
65
+ return /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(name)
66
+ }
67
+
68
+ /**
69
+ * Install a plugin from npm (e.g. `npm install <package>` into the plugins dir).
70
+ * The npm package must export a valid plugin manifest at its root.
71
+ */
72
+ installFromNpm(packageName: string): { success: boolean; message: string } {
73
+ if (!this.isValidPackageName(packageName)) {
74
+ return { success: false, message: `Invalid package name: "${packageName}"` }
75
+ }
76
+
77
+ // Derive plugin name from npm package name (strip scope prefix)
78
+ const pluginName = packageName.replace(/^@.+\//, '').replace(/^mipham-plugin-/, '')
79
+
80
+ const destDir = join(this.pluginDir, pluginName)
81
+ if (existsSync(destDir)) {
82
+ return {
83
+ success: false,
84
+ message: `Plugin "${pluginName}" is already installed`,
85
+ }
86
+ }
87
+
88
+ try {
89
+ // Install the npm package into the plugins directory
90
+ mkdirSync(destDir, { recursive: true })
91
+
92
+ // Create a minimal package.json so npm install works in a subdirectory
93
+ writeFileSync(
94
+ join(destDir, 'package.json'),
95
+ JSON.stringify({ private: true }, null, 2),
96
+ 'utf-8',
97
+ )
98
+
99
+ execSync(`npm install ${packageName} --prefix "${destDir}" --no-save`, {
100
+ encoding: 'utf-8',
101
+ stdio: 'pipe',
102
+ timeout: 60_000,
103
+ })
104
+
105
+ // Validate the installed plugin
106
+ const validation = validatePlugin(destDir)
107
+ if (!validation.valid) {
108
+ // Clean up on invalid plugin
109
+ rmSync(destDir, { recursive: true, force: true })
110
+ return { success: false, message: validation.errors.join('; ') }
111
+ }
112
+
113
+ this.plugins.push({
114
+ name: validation.manifest?.name || pluginName,
115
+ version: validation.manifest?.version || '0.0.0',
116
+ path: destDir,
117
+ enabled: true,
118
+ installedAt: new Date().toISOString(),
119
+ })
120
+
121
+ this.saveState()
122
+ return {
123
+ success: true,
124
+ message: `Plugin "${pluginName}" installed from npm`,
125
+ }
126
+ } catch (err: unknown) {
127
+ const msg = err instanceof Error ? err.message : String(err)
128
+ // Clean up on failure
129
+ try {
130
+ rmSync(destDir, { recursive: true, force: true })
131
+ } catch {
132
+ /* ok */
133
+ }
134
+ return { success: false, message: `npm install failed: ${msg}` }
135
+ }
136
+ }
137
+
61
138
  list(): InstalledPlugin[] {
62
139
  return [...this.plugins]
63
140
  }
@@ -278,9 +278,19 @@ export class AnthropicProvider implements ProviderInstance {
278
278
  }
279
279
 
280
280
  private resolveApiKey(keyTemplate: string): string {
281
- const match = keyTemplate.match(/^\$\{(.+)\}$/)
281
+ // Accept both ${VAR} and $VAR syntax
282
+ let match = keyTemplate.match(/^\$\{(.+)\}$/)
283
+ if (!match) match = keyTemplate.match(/^\$([A-Z_][A-Z0-9_]*)$/)
282
284
  if (match?.[1]) {
283
- return process.env[match[1]] || ''
285
+ const varName = match[1]
286
+ const value = process.env[varName]
287
+ if (!value) {
288
+ process.stderr.write(
289
+ `⚠ Anthropic provider: apiKey references $${varName} but that environment variable is not set\n`,
290
+ )
291
+ return ''
292
+ }
293
+ return value
284
294
  }
285
295
  return keyTemplate
286
296
  }
@@ -13,8 +13,18 @@ export function bootstrapProviders(
13
13
  for (const config of configs) {
14
14
  if (config.status === 'upcoming') continue
15
15
 
16
- switch (config.protocol) {
16
+ // Warn if apiKey is empty (will cause API errors at runtime)
17
+ if (!config.apiKey || config.apiKey.trim() === '') {
18
+ process.stderr.write(
19
+ `⚠ Provider "${config.name}" (${config.id}): apiKey not set. ` +
20
+ `Set it in ~/.mipham/config.yml or via environment variable.\n`,
21
+ )
22
+ }
23
+
24
+ const protocol = config.protocol?.toLowerCase()
25
+ switch (protocol) {
17
26
  case 'openai-compatible':
27
+ case 'openai-compat': // common user typo
18
28
  registry.register(config.id, new OpenAICompatProvider(config))
19
29
  break
20
30
  case 'anthropic':
@@ -6,7 +6,9 @@ export class OpenAICompatProvider implements ProviderInstance {
6
6
  constructor(public config: ProviderConfig) {}
7
7
 
8
8
  async *chat(req: ChatRequest): AsyncGenerator<StreamChunk> {
9
- const baseUrl = this.config.baseUrl?.replace(/\/+$/, '') || 'https://api.openai.com/v1'
9
+ // Accept both baseUrl and baseURL (common YAML typo)
10
+ const rawBase = (this.config as any).baseUrl || (this.config as any).baseURL
11
+ const baseUrl = rawBase?.replace(/\/+$/, '') || 'https://api.openai.com/v1'
10
12
  const apiKey = this.resolveApiKey(this.config.apiKey)
11
13
 
12
14
  const body = {
@@ -136,7 +138,8 @@ export class OpenAICompatProvider implements ProviderInstance {
136
138
 
137
139
  async healthCheck(): Promise<boolean> {
138
140
  try {
139
- const baseUrl = this.config.baseUrl?.replace(/\/+$/, '') || 'https://api.openai.com/v1'
141
+ const rawBase = (this.config as any).baseUrl || (this.config as any).baseURL
142
+ const baseUrl = rawBase?.replace(/\/+$/, '') || 'https://api.openai.com/v1'
140
143
  const apiKey = this.resolveApiKey(this.config.apiKey)
141
144
  const res = await fetch(`${baseUrl}/models`, {
142
145
  headers: { Authorization: `Bearer ${apiKey}` },
@@ -187,9 +190,19 @@ export class OpenAICompatProvider implements ProviderInstance {
187
190
  }
188
191
 
189
192
  private resolveApiKey(keyTemplate: string): string {
190
- const match = keyTemplate.match(/^\$\{(.+)\}$/)
193
+ // Accept both ${VAR} and $VAR syntax
194
+ let match = keyTemplate.match(/^\$\{(.+)\}$/)
195
+ if (!match) match = keyTemplate.match(/^\$([A-Z_][A-Z0-9_]*)$/)
191
196
  if (match?.[1]) {
192
- return process.env[match[1]] || ''
197
+ const varName = match[1]
198
+ const value = process.env[varName]
199
+ if (!value) {
200
+ process.stderr.write(
201
+ `⚠ OpenAI-compatible provider: apiKey references $${varName} but that environment variable is not set\n`,
202
+ )
203
+ return ''
204
+ }
205
+ return value
193
206
  }
194
207
  return keyTemplate
195
208
  }
@@ -244,6 +244,51 @@ export const DEFAULT_PROVIDERS: ProviderConfig[] = [
244
244
  },
245
245
  ],
246
246
  },
247
+ {
248
+ id: 'kimi',
249
+ name: 'Kimi (月之暗面)',
250
+ protocol: 'openai-compatible',
251
+ baseUrl: 'https://api.moonshot.cn/v1',
252
+ apiKey: '${KIMI_API_KEY}',
253
+ models: [
254
+ {
255
+ id: 'kimi-latest',
256
+ name: 'Kimi Latest',
257
+ providerId: 'kimi',
258
+ contextWindow: 128_000,
259
+ maxOutput: 16_000,
260
+ vision: true,
261
+ status: 'active',
262
+ },
263
+ {
264
+ id: 'moonshot-v1-8k',
265
+ name: 'Moonshot v1 8K',
266
+ providerId: 'kimi',
267
+ contextWindow: 8_000,
268
+ maxOutput: 4_000,
269
+ vision: false,
270
+ status: 'active',
271
+ },
272
+ {
273
+ id: 'moonshot-v1-32k',
274
+ name: 'Moonshot v1 32K',
275
+ providerId: 'kimi',
276
+ contextWindow: 32_000,
277
+ maxOutput: 4_000,
278
+ vision: false,
279
+ status: 'active',
280
+ },
281
+ {
282
+ id: 'moonshot-v1-128k',
283
+ name: 'Moonshot v1 128K',
284
+ providerId: 'kimi',
285
+ contextWindow: 128_000,
286
+ maxOutput: 4_000,
287
+ vision: false,
288
+ status: 'active',
289
+ },
290
+ ],
291
+ },
247
292
  {
248
293
  id: 'mipham',
249
294
  name: 'MiphamAI',
@@ -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
+ }