@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 CHANGED
@@ -178,6 +178,97 @@ async function runPluginCLI(): Promise<boolean> {
178
178
  return true
179
179
  }
180
180
 
181
+ // ── Update self ────────────────────────────────────────────────────────────────
182
+
183
+ async function runUpdate(): Promise<boolean> {
184
+ const args = process.argv.slice(2)
185
+ if (args[0] !== 'update' && args[0] !== 'upgrade') return false
186
+
187
+ const { getCurrentVersion, backupConfig, performUpdate, restoreConfig, getConfigPath } =
188
+ await import('../src/shared/update')
189
+
190
+ const { existsSync } = await import('node:fs')
191
+
192
+ const PACKAGE = '@miphamai/cli'
193
+
194
+ const currentVersion = getCurrentVersion()
195
+ console.log(`Mipham Code update`)
196
+ console.log(` Current: v${currentVersion}`)
197
+ console.log(` Checking npm registry for latest version...`)
198
+ console.log()
199
+
200
+ // Check latest version from npm
201
+ let latestVersion = ''
202
+ try {
203
+ const { execSync } = await import('node:child_process')
204
+ const result = execSync(`npm view ${PACKAGE} version --json`, {
205
+ encoding: 'utf-8',
206
+ timeout: 10_000,
207
+ stdio: ['pipe', 'pipe', 'pipe'],
208
+ }).trim()
209
+ latestVersion = result.replace(/"/g, '')
210
+ } catch (err: unknown) {
211
+ const msg = err instanceof Error ? err.message : String(err)
212
+ console.log(`✗ Failed to check latest version: ${msg}`)
213
+ console.log(` Check manually: https://www.npmjs.com/package/${PACKAGE}`)
214
+ process.exit(1)
215
+ }
216
+
217
+ if (!latestVersion) {
218
+ console.log('✗ Could not determine latest version.')
219
+ process.exit(1)
220
+ }
221
+
222
+ console.log(` Latest: v${latestVersion}`)
223
+ console.log()
224
+
225
+ // Compare versions
226
+ if (currentVersion === latestVersion) {
227
+ console.log(`✓ Already up to date (v${currentVersion})`)
228
+ process.exit(0)
229
+ }
230
+
231
+ console.log(`→ New version available: v${currentVersion} → v${latestVersion}`)
232
+ console.log()
233
+
234
+ // Backup config before updating
235
+ const backupPath = backupConfig(`update-v${currentVersion}`)
236
+ if (backupPath) {
237
+ console.log(`✓ Config backed up to: ${backupPath}`)
238
+ }
239
+
240
+ console.log()
241
+ console.log(`Updating ${PACKAGE} to v${latestVersion}...`)
242
+
243
+ const ok = performUpdate(latestVersion)
244
+ if (!ok) {
245
+ console.log()
246
+ console.log(`✗ Update failed.`)
247
+ if (backupPath && existsSync(backupPath)) {
248
+ console.log(` Your config backup is at: ${backupPath}`)
249
+ }
250
+ process.exit(1)
251
+ }
252
+
253
+ // Verify config survived
254
+ const configPath = getConfigPath()
255
+ if (existsSync(configPath)) {
256
+ console.log()
257
+ console.log(`✓ Config preserved: ${configPath}`)
258
+ } else if (backupPath && existsSync(backupPath)) {
259
+ console.log()
260
+ console.log('⚠ Config was removed during update. Restoring from backup...')
261
+ if (restoreConfig(backupPath)) {
262
+ console.log(`✓ Config restored.`)
263
+ }
264
+ }
265
+
266
+ console.log()
267
+ console.log(`✓ Updated to ${PACKAGE} v${latestVersion}`)
268
+ console.log(` Run 'mipham --version' to verify.`)
269
+ process.exit(0)
270
+ }
271
+
181
272
  async function main() {
182
273
  // ── Version flag ──────────────────────────────────────────────────────────
183
274
  if (
@@ -190,6 +281,10 @@ async function main() {
190
281
  process.exit(0)
191
282
  }
192
283
 
284
+ // ── Update / upgrade ──────────────────────────────────────────────────────
285
+ const handledUpdate = await runUpdate()
286
+ if (handledUpdate) return
287
+
193
288
  // Check for plugin subcommands first
194
289
  const handledPlugin = await runPluginCLI()
195
290
  if (handledPlugin) return
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.5.7",
3
+ "version": "0.5.9",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -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',