@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 +1 -2
- package/src/commands/keys.ts +26 -0
- package/src/config/credential-crypto.ts +114 -0
- package/src/config/defaults.ts +1 -1
- package/src/config/loader.ts +65 -14
- package/src/core/instructions.ts +36 -12
- package/src/index.tsx +4 -8
- package/src/mcp/token-store.ts +2 -37
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +3 -3
- package/src/skills/bin-check.ts +40 -0
- package/src/skills/loader.ts +35 -10
- package/src/skills/sanitizer.ts +1 -2
- package/src/skills/seam.ts +1 -1
- package/src/tools/agent/skill.ts +14 -0
- package/src/ui/commands.ts +3 -0
- package/src/ui/config-wizard.tsx +4 -1
- package/src/core/tokenizer.ts +0 -80
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miphamai/cli",
|
|
3
|
-
"version": "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"
|
package/src/commands/keys.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/config/defaults.ts
CHANGED
package/src/config/loader.ts
CHANGED
|
@@ -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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
package/src/core/instructions.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/mcp/token-store.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
package/src/shared/types.ts
CHANGED
|
@@ -151,7 +151,7 @@ export interface MiphamConfig {
|
|
|
151
151
|
|
|
152
152
|
export interface FeatureFlags {
|
|
153
153
|
mcp: { oauthEnabled: boolean }
|
|
154
|
-
context: {
|
|
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
|
-
/**
|
|
487
|
-
|
|
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
|
+
}
|
package/src/skills/loader.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
197
|
-
*
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
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 (
|
|
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
|
|
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
|
package/src/skills/sanitizer.ts
CHANGED
|
@@ -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
|
|
package/src/skills/seam.ts
CHANGED
|
@@ -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。 */
|
package/src/tools/agent/skill.ts
CHANGED
|
@@ -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
|
package/src/ui/commands.ts
CHANGED
|
@@ -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',
|
package/src/ui/config-wizard.tsx
CHANGED
|
@@ -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: ${
|
|
113
|
+
` apiKey: ${storedKey}`,
|
|
111
114
|
` models:`,
|
|
112
115
|
...models.map((m) => ` - id: ${m.id}`),
|
|
113
116
|
]
|
package/src/core/tokenizer.ts
DELETED
|
@@ -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
|
-
}
|