@miphamai/cli 0.51.0 → 0.52.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.51.0",
3
+ "version": "0.52.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -45,7 +45,6 @@
45
45
  "commander": "^13.1.0",
46
46
  "ink": "^5.2.1",
47
47
  "ink-text-input": "^6.0.0",
48
- "js-tiktoken": "^1.0.21",
49
48
  "react": "^18.3.1",
50
49
  "react-devtools-core": "^4.28.5",
51
50
  "yaml": "^2.9.0"
@@ -1,5 +1,6 @@
1
1
  import type { CommandHandler } from '../ui/commands'
2
2
  import { KeyManager } from '../config/keys-manager'
3
+ import { getProviderApiKey } from '../config/loader'
3
4
 
4
5
  export const keysCmd: CommandHandler = async (_ctx, args) => {
5
6
  const manager = new KeyManager()
@@ -58,6 +59,31 @@ export const keysCmd: CommandHandler = async (_ctx, args) => {
58
59
  return { content: lines.join('\n') }
59
60
  }
60
61
 
62
+ // /keys view <provider> — show the plaintext API key (decrypting if stored encrypted)
63
+ if (sub === 'view' || sub === 'show') {
64
+ const provider = args[1]
65
+ if (!provider) {
66
+ return { content: 'Usage: /keys view <provider>\n\nExample: /keys view deepseek' }
67
+ }
68
+ const key = getProviderApiKey(provider)
69
+ if (key === null) {
70
+ return {
71
+ content: `No API key found for provider "${provider}".\n\nIf it was stored encrypted, it may be unreadable (missing/corrupt credential key).`,
72
+ }
73
+ }
74
+ return {
75
+ content: [
76
+ '── Key View ──',
77
+ '',
78
+ `Provider: ${provider}`,
79
+ `API Key: ${key}`,
80
+ '',
81
+ '⚠️ This is your plaintext API key. Do not share it or paste it anywhere.',
82
+ ' Rotate it at any time with /keys rotate <provider>.',
83
+ ].join('\n'),
84
+ }
85
+ }
86
+
61
87
  // /keys (list)
62
88
  const keys = manager.list()
63
89
 
@@ -0,0 +1,114 @@
1
+ import {
2
+ existsSync,
3
+ readFileSync,
4
+ writeFileSync,
5
+ mkdirSync,
6
+ chmodSync,
7
+ copyFileSync,
8
+ } from 'node:fs'
9
+ import { dirname, join } from 'node:path'
10
+ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
11
+
12
+ /**
13
+ * Shared AES-256-GCM primitives for encrypting credentials at rest.
14
+ *
15
+ * Extracted from the MCP token store so config.yml API keys get the exact
16
+ * same at-rest protection (parent/subsidiary CLAUDE.md mandate "存储层
17
+ * AES-256-GCM"). The wire format `iv || authTag || ciphertext` (base64) is
18
+ * preserved verbatim from the original token-store implementation so existing
19
+ * `~/.mipham/mcp-tokens/*.enc` files remain decryptable.
20
+ */
21
+
22
+ const ALGORITHM = 'aes-256-gcm'
23
+ const IV_LENGTH = 16
24
+ const AUTH_TAG_LENGTH = 16
25
+ const KEY_LENGTH = 32
26
+
27
+ /** Prefix marking an encrypted API key value in config.yml. */
28
+ export const ENC_PREFIX = 'enc:v1:'
29
+
30
+ /** Shared master-credential key file (used by both MCP tokens and config.yml). */
31
+ const CREDENTIAL_KEY_FILENAME = '.cred-key'
32
+ /** Pre-shared-key filename; migrated to {@link CREDENTIAL_KEY_FILENAME} on first use. */
33
+ const LEGACY_KEY_FILENAME = '.mcp-key'
34
+
35
+ /**
36
+ * Load the shared credential key from `keyDir`, migrating the legacy
37
+ * MCP-only `.mcp-key` to the shared `.cred-key` so existing encrypted MCP
38
+ * tokens stay decryptable. Falls back to creating a fresh key when neither
39
+ * exists.
40
+ */
41
+ export function getCredentialKey(keyDir: string): Buffer {
42
+ const keyPath = join(keyDir, CREDENTIAL_KEY_FILENAME)
43
+ if (!existsSync(keyPath)) {
44
+ const legacyPath = join(keyDir, LEGACY_KEY_FILENAME)
45
+ if (existsSync(legacyPath)) {
46
+ copyFileSync(legacyPath, keyPath)
47
+ chmodSync(keyPath, 0o400)
48
+ }
49
+ }
50
+ return getOrCreateKey(keyPath)
51
+ }
52
+
53
+ /**
54
+ * Load the 32-byte encryption key from `keyPath`, creating a fresh random key
55
+ * (with owner-only read permissions) on first use.
56
+ */
57
+ export function getOrCreateKey(keyPath: string): Buffer {
58
+ if (existsSync(keyPath)) {
59
+ return readFileSync(keyPath)
60
+ }
61
+ const key = randomBytes(KEY_LENGTH)
62
+ mkdirSync(dirname(keyPath), { recursive: true })
63
+ writeFileSync(keyPath, key)
64
+ chmodSync(keyPath, 0o400)
65
+ return key
66
+ }
67
+
68
+ /** Encrypt plaintext with AES-256-GCM. Returns `base64(iv || authTag || ciphertext)`. */
69
+ export function encrypt(plaintext: string, key: Buffer): string {
70
+ const iv = randomBytes(IV_LENGTH)
71
+ const cipher = createCipheriv(ALGORITHM, key, iv)
72
+ const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()])
73
+ const authTag = cipher.getAuthTag()
74
+ return Buffer.concat([iv, authTag, encrypted]).toString('base64')
75
+ }
76
+
77
+ /** Decrypt a value produced by {@link encrypt}. Throws on wrong key / corrupt data. */
78
+ export function decrypt(ciphertext: string, key: Buffer): string {
79
+ const buf = Buffer.from(ciphertext, 'base64')
80
+ const iv = buf.subarray(0, IV_LENGTH)
81
+ const authTag = buf.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH)
82
+ const encrypted = buf.subarray(IV_LENGTH + AUTH_TAG_LENGTH)
83
+ const decipher = createDecipheriv(ALGORITHM, key, iv)
84
+ decipher.setAuthTag(authTag)
85
+ return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf-8')
86
+ }
87
+
88
+ /**
89
+ * True when `value` is an environment-variable template rather than a literal
90
+ * secret — both `${VAR}` and `$VAR` forms (the provider resolvers accept both).
91
+ */
92
+ export function isEnvTemplate(value: string): boolean {
93
+ return /^\$\{.*\}$/.test(value) || /^\$[A-Z_][A-Z0-9_]*$/.test(value)
94
+ }
95
+
96
+ /**
97
+ * Encrypt an API key for storage. Environment-variable templates and empty
98
+ * values are not secrets, so they pass through unchanged (keeping config.yml
99
+ * readable for env-based setups). Literal secrets get the `enc:v1:` prefix.
100
+ */
101
+ export function encryptApiKey(apiKey: string, key: Buffer): string {
102
+ if (!apiKey || isEnvTemplate(apiKey)) return apiKey
103
+ return ENC_PREFIX + encrypt(apiKey, key)
104
+ }
105
+
106
+ /**
107
+ * Decrypt a stored API key. Plaintext (legacy or template) values pass through
108
+ * unchanged; `enc:v1:` values are decrypted. Throws on a missing/corrupt key so
109
+ * the caller can surface a clear error instead of sending a garbage key.
110
+ */
111
+ export function decryptApiKey(stored: string, key: Buffer): string {
112
+ if (!stored.startsWith(ENC_PREFIX)) return stored
113
+ return decrypt(stored.slice(ENC_PREFIX.length), key)
114
+ }
@@ -28,7 +28,7 @@ export const DEFAULT_CONFIG: MiphamConfig = {
28
28
  },
29
29
  features: {
30
30
  mcp: { oauthEnabled: true },
31
- context: { useRealTokenizer: true, adaptiveThresholds: true },
31
+ context: { adaptiveThresholds: true },
32
32
  },
33
33
  crsi: {
34
34
  ruleInjection: true,
@@ -27,6 +27,7 @@ import {
27
27
  DEFAULT_BACKGROUND_AGENT_CONFIG,
28
28
  DEFAULT_CROSS_SESSION_CONFIG,
29
29
  } from './defaults'
30
+ import { getCredentialKey, encryptApiKey, decryptApiKey, ENC_PREFIX } from './credential-crypto'
30
31
 
31
32
  const MIPHAM_HOME = join(homedir(), '.mipham')
32
33
  const BACKUP_PREFIX = 'config.backup-'
@@ -294,6 +295,9 @@ export function loadConfig(cwd: string = process.cwd()): MiphamConfig {
294
295
  backupConfig(userConfigPath)
295
296
  }
296
297
 
298
+ // ── Decrypt API keys at rest (enc:v1:) back to plaintext ──
299
+ decryptProviderApiKeys(config.providers)
300
+
297
301
  return config
298
302
  }
299
303
 
@@ -438,6 +442,58 @@ export function loadCrossSessionConfig(cwd: string = process.cwd()): CrossSessio
438
442
  return merged
439
443
  }
440
444
 
445
+ /**
446
+ * Decrypt any encrypted (`enc:v1:`) provider API keys in place, after config
447
+ * merge. Plaintext (legacy / env-template) values pass through untouched. On
448
+ * decrypt failure (missing/corrupt credential key) the key is cleared and a
449
+ * warning is written, so the provider surfaces "apiKey not set" rather than
450
+ * sending a garbage value.
451
+ */
452
+ function decryptProviderApiKeys(providers: ProviderConfig[] | undefined): void {
453
+ if (!providers) return
454
+ const needsKey = providers.some((p) => p.apiKey.startsWith(ENC_PREFIX))
455
+ if (!needsKey) return
456
+ const key = getCredentialKey(MIPHAM_HOME)
457
+ for (const p of providers) {
458
+ if (!p.apiKey.startsWith(ENC_PREFIX)) continue
459
+ try {
460
+ p.apiKey = decryptApiKey(p.apiKey, key)
461
+ } catch (err: unknown) {
462
+ const msg = err instanceof Error ? err.message : String(err)
463
+ process.stderr.write(
464
+ `⚠ Mipham Code: failed to decrypt API key for "${p.id}" (credential key missing/corrupt?): ${msg}\n`,
465
+ )
466
+ p.apiKey = ''
467
+ }
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Read a single provider's API key from config.yml, decrypting it if stored
473
+ * encrypted. Returns null when the provider has no key or the key can't be
474
+ * decrypted. Used by `/keys view` to show the plaintext key on request.
475
+ */
476
+ export function getProviderApiKey(providerId: string, cwd: string = process.cwd()): string | null {
477
+ const userConfigPath = join(MIPHAM_HOME, 'config.yml')
478
+ const projectConfigPath = join(cwd, '.mipham', 'config.yml')
479
+ const configPath = existsSync(userConfigPath) ? userConfigPath : projectConfigPath
480
+
481
+ try {
482
+ if (!existsSync(configPath)) return null
483
+ const raw = readFileSync(configPath, 'utf-8')
484
+ const doc = (parseYaml(raw) as Record<string, unknown>) || {}
485
+ const providers = (doc.providers as Array<Record<string, unknown>>) || []
486
+ const p = providers.find((x) => x.id === providerId)
487
+ if (!p || typeof p.apiKey !== 'string') return null
488
+ if (!p.apiKey.startsWith(ENC_PREFIX)) return p.apiKey
489
+ return decryptApiKey(p.apiKey, getCredentialKey(MIPHAM_HOME))
490
+ } catch (err: unknown) {
491
+ const msg = err instanceof Error ? err.message : String(err)
492
+ process.stderr.write(`⚠ Mipham Code: failed to read API key for "${providerId}": ${msg}\n`)
493
+ return null
494
+ }
495
+ }
496
+
441
497
  /**
442
498
  * Persist an API key for a single provider to the user-level config.yml.
443
499
  * Reads the existing YAML, updates/replaces the provider's apiKey field,
@@ -445,16 +501,10 @@ export function loadCrossSessionConfig(cwd: string = process.cwd()): CrossSessio
445
501
  *
446
502
  * Returns true on success, false on failure.
447
503
  */
448
- export function saveProviderApiKey(
449
- providerId: string,
450
- apiKey: string,
451
- cwd: string = process.cwd(),
452
- ): boolean {
453
- const userConfigPath = join(MIPHAM_HOME, 'config.yml')
454
- const projectConfigPath = join(cwd, '.mipham', 'config.yml')
455
-
456
- // Prefer user-level config; fall back to project-level if no user config exists.
457
- const configPath = existsSync(userConfigPath) ? userConfigPath : projectConfigPath
504
+ export function saveProviderApiKey(providerId: string, apiKey: string): boolean {
505
+ // API keys are user-level secrets — always persist to the user config, never
506
+ // the project config (which lives in the repo and could be committed).
507
+ const configPath = join(MIPHAM_HOME, 'config.yml')
458
508
 
459
509
  try {
460
510
  mkdirSync(MIPHAM_HOME, { recursive: true, mode: 0o700 })
@@ -464,19 +514,20 @@ export function saveProviderApiKey(
464
514
  if (existsSync(configPath)) {
465
515
  const raw = readFileSync(configPath, 'utf-8')
466
516
  doc = (parseYaml(raw) as Record<string, unknown>) || {}
467
- } else if (configPath === projectConfigPath) {
468
- mkdirSync(join(cwd, '.mipham'), { recursive: true })
469
517
  }
470
518
 
471
519
  // Find and update the provider in the providers array
472
520
  const providers = (doc.providers as Array<Record<string, unknown>>) || []
473
521
  const idx = providers.findIndex((p) => p.id === providerId)
474
522
 
523
+ // Encrypt literal keys at rest (env-variable templates pass through).
524
+ const storedKey = encryptApiKey(apiKey, getCredentialKey(MIPHAM_HOME))
525
+
475
526
  if (idx >= 0) {
476
- providers[idx] = { ...providers[idx], apiKey }
527
+ providers[idx] = { ...providers[idx], apiKey: storedKey }
477
528
  } else {
478
529
  // Provider not in config — append it
479
- providers.push({ id: providerId, apiKey })
530
+ providers.push({ id: providerId, apiKey: storedKey })
480
531
  }
481
532
 
482
533
  doc.providers = providers
@@ -20,9 +20,37 @@ function parseFrontmatter(raw: string): FrontmatterResult {
20
20
  }
21
21
  }
22
22
 
23
+ /** Strip the named sections (by heading title) from a markdown document. */
24
+ export function stripSections(content: string, excluded: string[]): string {
25
+ if (excluded.length === 0) return content
26
+ const lines = content.split('\n')
27
+ const out: string[] = []
28
+ let skipLevel = 0
29
+ for (const line of lines) {
30
+ const m = line.match(/^(#{1,3})\s+(.+?)\s*$/)
31
+ if (m) {
32
+ const level = m[1]!.length
33
+ const title = m[2]!.trim()
34
+ if (excluded.includes(title)) {
35
+ skipLevel = level
36
+ } else if (skipLevel > 0 && level <= skipLevel) {
37
+ skipLevel = 0
38
+ }
39
+ }
40
+ if (skipLevel === 0) out.push(line)
41
+ }
42
+ return out.join('\n')
43
+ }
44
+
45
+ /** Normalize a `prompt-exclude` frontmatter value (YAML list or single string). */
46
+ export function parsePromptExclude(value: unknown): string[] {
47
+ if (Array.isArray(value)) return value.map((v) => String(v))
48
+ if (typeof value === 'string') return [value]
49
+ return []
50
+ }
51
+
23
52
  export class InstructionsLoader {
24
53
  private instructions: InstructionFile[] = []
25
- private skillsReminder = ''
26
54
 
27
55
  loadAll(cwd: string): void {
28
56
  this.instructions = []
@@ -59,7 +87,13 @@ export class InstructionsLoader {
59
87
  directory: 'Directory Rules',
60
88
  user: 'User Preferences',
61
89
  }
62
- parts.push(`<!-- ${levelLabel[inst.level] || inst.level} (${inst.path}) -->\n${inst.content}`)
90
+ // Strip doc-only sections declared via `prompt-exclude` frontmatter
91
+ // (changelog/roadmap/catalog are human-facing, not machine rules).
92
+ const content = stripSections(
93
+ inst.content,
94
+ parsePromptExclude(inst.frontmatter['prompt-exclude']),
95
+ )
96
+ parts.push(`<!-- ${levelLabel[inst.level] || inst.level} (${inst.path}) -->\n${content}`)
63
97
  }
64
98
 
65
99
  // P2-2: Inject current permission mode so the model knows its constraints
@@ -67,11 +101,6 @@ export class InstructionsLoader {
67
101
  parts.push(this.buildPermissionContext(permissionMode))
68
102
  }
69
103
 
70
- // Append skills reminder after all instructions
71
- if (this.skillsReminder) {
72
- parts.push(this.skillsReminder)
73
- }
74
-
75
104
  // Inject critical thinking self-check layer (for analysis/comparison tasks)
76
105
  parts.push(`## Critical Thinking Self-Check
77
106
 
@@ -177,11 +206,6 @@ Never omit it or present the work as purely human-authored.`)
177
206
  return parts.join('\n\n---\n\n')
178
207
  }
179
208
 
180
- /** Set the skills system-reminder block to inject into the system prompt. */
181
- setSkillsReminder(reminder: string): void {
182
- this.skillsReminder = reminder
183
- }
184
-
185
209
  /**
186
210
  * P2-2: Build a permission-mode context block for the system prompt.
187
211
  * Tells the model its current permission level and what to expect.
package/src/index.tsx CHANGED
@@ -281,12 +281,6 @@ export async function runApp(options: RunOptions): Promise<void> {
281
281
  skillsLoader.loadExternal(config.skills.paths)
282
282
  }
283
283
 
284
- // Inject skills system-reminder into system prompt for AI auto-triggering
285
- const skillsReminder = skillsLoader.buildSystemReminder()
286
- if (skillsReminder) {
287
- instructions.setSkillsReminder(skillsReminder)
288
- }
289
-
290
284
  // Initialize plugin manager
291
285
  const pluginManager = new PluginManager()
292
286
 
@@ -327,8 +321,6 @@ export async function runApp(options: RunOptions): Promise<void> {
327
321
  // Phase 9 feature flags (all default true — opt-out via config)
328
322
  const features = config.features || {}
329
323
  const adaptiveThresholds = features.context?.adaptiveThresholds !== false
330
- // gated via TokenCounter — set to false to fall back to chars/4 heuristic
331
- const _useRealTokenizer = features.context?.useRealTokenizer !== false
332
324
 
333
325
  const context = new ContextManager({
334
326
  maxTokens: contextMaxTokens,
@@ -372,9 +364,13 @@ export async function runApp(options: RunOptions): Promise<void> {
372
364
  if (context.getMessageCount() === 0) {
373
365
  const basePrompt = instructions.buildSystemPrompt(config.permission as string)
374
366
  const memoryReminder = loadSessionMemories(basePrompt)
367
+ const skillsReminder = skillsLoader.buildSystemReminder(basePrompt)
375
368
 
376
369
  // Inject previous session summary for AI continuity
377
370
  let prompt = basePrompt
371
+ if (skillsReminder) {
372
+ prompt = `${prompt}\n\n${skillsReminder}`
373
+ }
378
374
  if (memoryReminder) {
379
375
  prompt = `${prompt}\n\n${memoryReminder}`
380
376
  }
@@ -8,13 +8,8 @@ import {
8
8
  chmodSync,
9
9
  } from 'node:fs'
10
10
  import { join, dirname } from 'node:path'
11
- import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
12
11
  import { homedir } from 'node:os'
13
-
14
- const ALGORITHM = 'aes-256-gcm'
15
- const IV_LENGTH = 16
16
- const AUTH_TAG_LENGTH = 16
17
- const KEY_LENGTH = 32
12
+ import { encrypt, decrypt, getCredentialKey } from '../config/credential-crypto'
18
13
 
19
14
  interface TokenData {
20
15
  accessToken: string
@@ -24,43 +19,13 @@ interface TokenData {
24
19
  scopes?: string[]
25
20
  }
26
21
 
27
- function getEncryptionKey(keyPath: string): Buffer {
28
- if (existsSync(keyPath)) {
29
- return readFileSync(keyPath)
30
- }
31
- const key = randomBytes(KEY_LENGTH)
32
- mkdirSync(dirname(keyPath), { recursive: true })
33
- writeFileSync(keyPath, key)
34
- chmodSync(keyPath, 0o400)
35
- return key
36
- }
37
-
38
- function encrypt(plaintext: string, key: Buffer): string {
39
- const iv = randomBytes(IV_LENGTH)
40
- const cipher = createCipheriv(ALGORITHM, key, iv)
41
- const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()])
42
- const authTag = cipher.getAuthTag()
43
- return Buffer.concat([iv, authTag, encrypted]).toString('base64')
44
- }
45
-
46
- function decrypt(ciphertext: string, key: Buffer): string {
47
- const buf = Buffer.from(ciphertext, 'base64')
48
- const iv = buf.subarray(0, IV_LENGTH)
49
- const authTag = buf.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH)
50
- const encrypted = buf.subarray(IV_LENGTH + AUTH_TAG_LENGTH)
51
- const decipher = createDecipheriv(ALGORITHM, key, iv)
52
- decipher.setAuthTag(authTag)
53
- return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf-8')
54
- }
55
-
56
22
  export class TokenStore {
57
23
  private key: Buffer
58
24
  private storeDir: string
59
25
 
60
26
  constructor(storeDir?: string) {
61
27
  this.storeDir = storeDir || join(homedir(), '.mipham', 'mcp-tokens')
62
- const keyPath = join(dirname(this.storeDir), '.mcp-key')
63
- this.key = getEncryptionKey(keyPath)
28
+ this.key = getCredentialKey(dirname(this.storeDir))
64
29
  }
65
30
 
66
31
  save(serverName: string, data: TokenData): void {
@@ -9,7 +9,7 @@
9
9
  export const PACKAGE_NAME = '@miphamai/cli' as const
10
10
 
11
11
  /** 当前发布版本 */
12
- export const PACKAGE_VERSION = '0.51.0' as const
12
+ export const PACKAGE_VERSION = '0.52.0' as const
13
13
 
14
14
  /** npm install 全局安装命令 */
15
15
  export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
@@ -151,7 +151,7 @@ export interface MiphamConfig {
151
151
 
152
152
  export interface FeatureFlags {
153
153
  mcp: { oauthEnabled: boolean }
154
- context: { useRealTokenizer: boolean; adaptiveThresholds: boolean }
154
+ context: { adaptiveThresholds: boolean }
155
155
  }
156
156
 
157
157
  export interface CrsiConfig {
@@ -483,8 +483,8 @@ export interface SkillDefinition {
483
483
  allowedTools?: string[]
484
484
  /** When true, the skill is NOT shown in system-reminder for AI auto-triggering */
485
485
  disableModelInvocation?: boolean
486
- /** When true, users can invoke this skill directly via /<name> */
487
- userInvocable?: boolean
486
+ /** External command-line binaries the skill requires (frontmatter: requires-bins). */
487
+ requiresBins?: string[]
488
488
  /** The markdown body content of the skill file (instructions for the AI to follow). */
489
489
  body?: string
490
490
  }
@@ -0,0 +1,40 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join, delimiter } from 'node:path'
3
+
4
+ // Windows executable extensions (subset of PATHEXT) probed for bare names.
5
+ const WINDOWS_EXECUTABLES = ['.exe', '.cmd', '.bat', '.com']
6
+
7
+ /**
8
+ * Check whether a command-line binary is available on the system PATH.
9
+ * Accepts an explicit `pathVar` for testability; defaults to `process.env.PATH`.
10
+ * A value containing a path separator is treated as an explicit path and
11
+ * checked for existence directly.
12
+ */
13
+ export function isBinAvailable(bin: string, pathVar: string = process.env.PATH || ''): boolean {
14
+ // Explicit path (absolute or relative) — check existence directly.
15
+ if (bin.includes('/') || bin.includes('\\')) {
16
+ return existsSync(bin)
17
+ }
18
+
19
+ const isWindows = process.platform === 'win32'
20
+ const names = isWindows ? WINDOWS_EXECUTABLES.map((ext) => bin + ext) : [bin]
21
+ const dirs = pathVar.split(delimiter).filter(Boolean)
22
+
23
+ for (const dir of dirs) {
24
+ for (const name of names) {
25
+ if (existsSync(join(dir, name))) return true
26
+ }
27
+ }
28
+ return false
29
+ }
30
+
31
+ /**
32
+ * Return the subset of `bins` that are NOT available on PATH. An empty result
33
+ * means every required binary is present.
34
+ */
35
+ export function checkRequiredBins(
36
+ bins: string[],
37
+ pathVar: string = process.env.PATH || '',
38
+ ): string[] {
39
+ return bins.filter((bin) => !isBinAvailable(bin, pathVar))
40
+ }
@@ -182,7 +182,7 @@ export class SkillsLoader implements Skills {
182
182
  model: data.model as string | undefined,
183
183
  allowedTools: data['allowed-tools'] as string[] | undefined,
184
184
  disableModelInvocation: data['disable-model-invocation'] as boolean | undefined,
185
- userInvocable: data['user-invocable'] as boolean | undefined,
185
+ requiresBins: data['requires-bins'] as string[] | undefined,
186
186
  }
187
187
 
188
188
  this.skills.set(skill.name, skill)
@@ -191,18 +191,43 @@ export class SkillsLoader implements Skills {
191
191
  }
192
192
  }
193
193
 
194
+ /**
195
+ * Recall the skills most relevant to a context (e.g. the session's system
196
+ * prompt / project context), mirroring MemoryManager.recall's keyword
197
+ * scoring. Name keywords are a strong signal (+3); description keywords are
198
+ * weak (+1). Returns up to `limit` skills sorted by relevance.
199
+ */
200
+ recall(context: string, limit: number = 5): SkillDefinition[] {
201
+ const ctxWords = new Set(context.toLowerCase().split(/\s+/))
202
+ const scored: Array<{ skill: SkillDefinition; score: number }> = []
203
+
204
+ for (const skill of this.list()) {
205
+ if (skill.disableModelInvocation) continue
206
+ let score = 0
207
+ for (const word of skill.name.toLowerCase().split(/[-_\s]+/)) {
208
+ if (word.length > 2 && ctxWords.has(word)) score += 3
209
+ }
210
+ for (const word of skill.description.toLowerCase().split(/\s+/)) {
211
+ if (word.length > 3 && ctxWords.has(word)) score += 1
212
+ }
213
+ if (score > 0) scored.push({ skill, score })
214
+ }
215
+
216
+ scored.sort((a, b) => b.score - a.score)
217
+ return scored.slice(0, limit).map((s) => s.skill)
218
+ }
219
+
194
220
  /**
195
221
  * Build the system-reminder block for AI auto-triggering.
196
- * Injects available skill names + descriptions so the AI can match
197
- * user requests to relevant skills.
222
+ * With a `context`, only the most relevant skills are injected (selective,
223
+ * to save tokens); without one, all skills are listed.
198
224
  */
199
- buildSystemReminder(maxTokens: number = 5000): string {
200
- const skills = this.list().filter((s) => {
201
- // Skip skills that disable model invocation
202
- return !s.disableModelInvocation
203
- })
225
+ buildSystemReminder(context?: string, maxTokens: number = 5000): string {
226
+ const selected = context
227
+ ? this.recall(context)
228
+ : this.list().filter((s) => !s.disableModelInvocation)
204
229
 
205
- if (skills.length === 0) return ''
230
+ if (selected.length === 0) return ''
206
231
 
207
232
  const lines: string[] = [
208
233
  '<system-reminder>',
@@ -210,7 +235,7 @@ export class SkillsLoader implements Skills {
210
235
  ]
211
236
 
212
237
  let tokenBudget = 0
213
- for (const skill of skills) {
238
+ for (const skill of selected) {
214
239
  const safeDesc = sanitizeSkillDescription(skill.description, skill.type)
215
240
  const entry = `- ${skill.name}: ${safeDesc}`
216
241
  const entryTokens = Math.ceil(entry.length / 4) + 1 // rough estimate
@@ -54,7 +54,6 @@ const BUILTIN_COMMANDS = new Set([
54
54
  '/sis',
55
55
  '/plan',
56
56
  '/no-plan',
57
- '/triage',
58
57
  '/workflows',
59
58
  '/tasks',
60
59
  ])
@@ -182,7 +181,7 @@ export function checkSkillShadow(
182
181
  mcpToolNames?: string[],
183
182
  ): ShadowCheck {
184
183
  // Check against builtin commands
185
- if (BUILTIN_COMMANDS.has(skillName)) {
184
+ if (BUILTIN_COMMANDS.has('/' + skillName)) {
186
185
  return { shadowed: true, conflictsWith: skillName, conflictType: 'command' }
187
186
  }
188
187
 
@@ -6,7 +6,7 @@ export interface Skills {
6
6
  get(name: string): SkillDefinition | undefined
7
7
  list(): SkillDefinition[]
8
8
  has(name: string): boolean
9
- buildSystemReminder(maxTokens?: number): string
9
+ buildSystemReminder(context?: string, maxTokens?: number): string
10
10
  }
11
11
 
12
12
  /** 缝键:ctx.skills。 */
@@ -2,6 +2,7 @@ import type { ToolDefinition } from '../../shared'
2
2
  import { executeForkedSkill } from '../../skills/fork-executor'
3
3
  import { sanitizeSkillBody } from '../../skills/sanitizer'
4
4
  import { ensureSkillAssets } from '../../skills/skill-assets'
5
+ import { checkRequiredBins } from '../../skills/bin-check'
5
6
 
6
7
  export const skillTool: ToolDefinition = {
7
8
  name: 'Skill',
@@ -47,6 +48,19 @@ export const skillTool: ToolDefinition = {
47
48
  console.warn(`Skill asset extraction failed for "${skillName}":`, err)
48
49
  }
49
50
 
51
+ // Preflight: fail fast with a clear error if a required binary is missing.
52
+ if (skill.requiresBins?.length) {
53
+ const missing = checkRequiredBins(skill.requiresBins)
54
+ if (missing.length > 0) {
55
+ const list = missing.map((b) => `\`${b}\``).join(', ')
56
+ return {
57
+ success: false,
58
+ content: '',
59
+ error: `Skill "${skillName}" requires ${list}, which ${missing.length === 1 ? 'is' : 'are'} not available on PATH. Install and retry.`,
60
+ }
61
+ }
62
+ }
63
+
50
64
  // Check if skill has context: fork — execute in isolated subagent
51
65
  if (skill.context === 'fork') {
52
66
  const registry = ctx.registry
@@ -4665,6 +4665,7 @@ const commandsListCmd: CommandHandler = () => {
4665
4665
  '/keys': 'Account',
4666
4666
  '/keys rotate': 'Account',
4667
4667
  '/keys audit': 'Account',
4668
+ '/keys view': 'Account',
4668
4669
  '/feedback': 'Account',
4669
4670
  '/agents': 'Agents',
4670
4671
  '/bg': 'Agents',
@@ -4847,6 +4848,7 @@ registry.set('/logout', logoutCmd)
4847
4848
  registry.set('/keys', keysCmd)
4848
4849
  registry.set('/keys rotate', keysCmd)
4849
4850
  registry.set('/keys audit', keysCmd)
4851
+ registry.set('/keys view', keysCmd)
4850
4852
  registry.set('/feedback', feedbackCmd)
4851
4853
  registry.set('/agents', agentsCmd)
4852
4854
  registry.set('/bg', bgCmd)
@@ -5006,6 +5008,7 @@ const COMMAND_DESCRIPTIONS: Record<string, string> = {
5006
5008
  '/keys': 'List API key rotation status',
5007
5009
  '/keys rotate': 'Rotate an API key',
5008
5010
  '/keys audit': 'Check for expired keys',
5011
+ '/keys view': 'View a provider plaintext API key',
5009
5012
  '/feedback': 'Send feedback',
5010
5013
  '/agents': 'Agent view dashboard',
5011
5014
  '/bg': 'Run a background agent task',
@@ -18,6 +18,7 @@ import { mkdirSync, writeFileSync } from 'node:fs'
18
18
  import { join } from 'node:path'
19
19
  import { homedir } from 'node:os'
20
20
  import { execSync } from 'node:child_process'
21
+ import { getCredentialKey, encryptApiKey } from '../config/credential-crypto'
21
22
 
22
23
  // ── Types ──
23
24
 
@@ -93,6 +94,8 @@ function writeConfigFile(providerId: string, modelId: string, apiKey: string): v
93
94
  }
94
95
  return result
95
96
  }
97
+ const storedKey = encryptApiKey(apiKey, getCredentialKey(configDir))
98
+
96
99
  const lines = [
97
100
  '# Mipham Code Configuration',
98
101
  `# Generated by Config Wizard — ${new Date().toISOString()}`,
@@ -107,7 +110,7 @@ function writeConfigFile(providerId: string, modelId: string, apiKey: string): v
107
110
  ` name: ${provider?.name || providerId}`,
108
111
  ` protocol: ${provider?.protocol || 'openai-compatible'}`,
109
112
  ...(provider?.baseUrl ? [` baseUrl: ${provider.baseUrl}`] : []),
110
- ` apiKey: ${apiKey}`,
113
+ ` apiKey: ${storedKey}`,
111
114
  ` models:`,
112
115
  ...models.map((m) => ` - id: ${m.id}`),
113
116
  ]
@@ -1,80 +0,0 @@
1
- import type { Tiktoken } from 'js-tiktoken'
2
- import type { Message } from '../shared/types'
3
-
4
- let encoder: Tiktoken | null = null
5
- let initPromise: Promise<void> | null = null
6
-
7
- async function getEncoder(): Promise<Tiktoken> {
8
- if (encoder) return encoder
9
- if (!initPromise) {
10
- initPromise = (async () => {
11
- const { getEncoding } = await import('js-tiktoken')
12
- encoder = getEncoding('cl100k_base')
13
- })()
14
- }
15
- await initPromise
16
- return encoder!
17
- }
18
-
19
- export class TokenCounter {
20
- private cache = new Map<string, number>()
21
- private initialized = false
22
-
23
- private async ensureInit(): Promise<void> {
24
- if (!this.initialized) {
25
- await getEncoder()
26
- this.initialized = true
27
- }
28
- }
29
-
30
- async count(text: string): Promise<number> {
31
- if (!text) return 0
32
- const cached = this.cache.get(text)
33
- if (cached !== undefined) return cached
34
- await this.ensureInit()
35
- const tokens = encoder!.encode(text).length
36
- this.cache.set(text, tokens)
37
- return tokens
38
- }
39
-
40
- /** Synchronous fallback — chars/4 heuristic when WASM not loaded. */
41
- countSync(text: string): number {
42
- if (!text) return 0
43
- const cached = this.cache.get(text)
44
- if (cached !== undefined) return cached
45
- return Math.ceil(text.length / 4)
46
- }
47
-
48
- async countMessages(messages: Message[]): Promise<number> {
49
- let total = 0
50
- for (const msg of messages) {
51
- if (typeof msg.content === 'string') {
52
- total += await this.count(msg.content)
53
- } else if (Array.isArray(msg.content)) {
54
- for (const block of msg.content) {
55
- if (block.type === 'text' && block.text) {
56
- total += await this.count(block.text)
57
- }
58
- }
59
- }
60
- total += 4 // per-message format overhead
61
- }
62
- return total
63
- }
64
-
65
- async truncateToTokens(text: string, maxTokens: number): Promise<string> {
66
- await this.ensureInit()
67
- const tokens = encoder!.encode(text)
68
- if (tokens.length <= maxTokens) return text
69
- return text.slice(0, Math.floor(text.length * (maxTokens / tokens.length)))
70
- }
71
-
72
- invalidateCache(): void {
73
- this.cache.clear()
74
- }
75
-
76
- static reset(): void {
77
- encoder = null
78
- initPromise = null
79
- }
80
- }