@miphamai/cli 0.16.5 → 0.18.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 -1
- package/src/agent/agent-context.ts +86 -17
- package/src/agent/agent-experience.ts +105 -0
- package/src/agent/sub-agent.ts +56 -0
- package/src/commands/keys.ts +94 -0
- package/src/config/keys-manager.ts +121 -0
- package/src/core/memory/memory-manager.ts +211 -3
- package/src/core/session-store.ts +133 -0
- package/src/index.tsx +45 -2
- package/src/security/gate.ts +93 -0
- package/src/ui/app.tsx +8 -4
- package/src/ui/chat.tsx +1 -3
- package/src/ui/commands.ts +92 -2
package/package.json
CHANGED
|
@@ -5,6 +5,7 @@ import { homedir } from 'node:os'
|
|
|
5
5
|
import { ContextManager } from '../core/context'
|
|
6
6
|
import type { ToolDefinition } from '../shared/index.ts'
|
|
7
7
|
import type { AgentDefinition } from './types'
|
|
8
|
+
import { AgentExperience } from './agent-experience'
|
|
8
9
|
|
|
9
10
|
export interface AgentContextResult {
|
|
10
11
|
context: ContextManager
|
|
@@ -31,28 +32,96 @@ function loadAgentMemory(agentName: string, scope: 'user' | 'project' | 'local')
|
|
|
31
32
|
break
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
// Load static memory files
|
|
36
|
+
let staticMemory = ''
|
|
37
|
+
if (existsSync(memoryDir)) {
|
|
38
|
+
try {
|
|
39
|
+
const files = readdirSync(memoryDir).filter((f) => f.endsWith('.md'))
|
|
40
|
+
if (files.length > 0) {
|
|
41
|
+
const contents: string[] = []
|
|
42
|
+
for (const file of files.slice(0, 10)) {
|
|
43
|
+
try {
|
|
44
|
+
const content = readFileSync(join(memoryDir, file), 'utf-8').trim()
|
|
45
|
+
if (content) contents.push(content)
|
|
46
|
+
} catch {
|
|
47
|
+
// skip unreadable
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (contents.length > 0) {
|
|
51
|
+
staticMemory = [`[Agent Memory — ${scope} scope]`, ...contents].join('\n\n')
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// memory dir unreadable
|
|
56
|
+
}
|
|
57
|
+
}
|
|
35
58
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
59
|
+
// Load auto-accumulated experience (always from user scope)
|
|
60
|
+
const exp = new AgentExperience(agentName)
|
|
61
|
+
const experienceContent = exp.getExperience()
|
|
39
62
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
63
|
+
let experienceMemory = ''
|
|
64
|
+
if (experienceContent) {
|
|
65
|
+
const lines = experienceContent.split('\n')
|
|
66
|
+
const statsIdx = lines.findIndex((l) => l.startsWith('## Stats'))
|
|
67
|
+
const successIdx = lines.findIndex((l) => l.startsWith('## Success Patterns'))
|
|
68
|
+
const failureIdx = lines.findIndex((l) => l.startsWith('## Failure Patterns'))
|
|
69
|
+
|
|
70
|
+
// Header: content before the first section
|
|
71
|
+
const firstSection = Math.min(
|
|
72
|
+
successIdx !== -1 ? successIdx : Infinity,
|
|
73
|
+
failureIdx !== -1 ? failureIdx : Infinity,
|
|
74
|
+
statsIdx !== -1 ? statsIdx : Infinity,
|
|
75
|
+
)
|
|
76
|
+
const header = lines.slice(0, firstSection === Infinity ? 3 : firstSection)
|
|
77
|
+
|
|
78
|
+
// Last 5 success entries (summary line only, not detail)
|
|
79
|
+
const successLines: string[] = []
|
|
80
|
+
if (successIdx !== -1) {
|
|
81
|
+
const endIdx = Math.min(
|
|
82
|
+
failureIdx !== -1 && failureIdx > successIdx ? failureIdx : Infinity,
|
|
83
|
+
statsIdx !== -1 && statsIdx > successIdx ? statsIdx : Infinity,
|
|
84
|
+
)
|
|
85
|
+
successLines.push(
|
|
86
|
+
...lines
|
|
87
|
+
.slice(successIdx + 1, endIdx === Infinity ? undefined : endIdx)
|
|
88
|
+
.filter((l) => l.startsWith('- ['))
|
|
89
|
+
.slice(-5),
|
|
90
|
+
)
|
|
49
91
|
}
|
|
50
92
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
93
|
+
// Last 3 failure entries (summary line only, not detail)
|
|
94
|
+
const failureLines: string[] = []
|
|
95
|
+
if (failureIdx !== -1) {
|
|
96
|
+
const endIdx = statsIdx !== -1 && statsIdx > failureIdx ? statsIdx : Infinity
|
|
97
|
+
failureLines.push(
|
|
98
|
+
...lines
|
|
99
|
+
.slice(failureIdx + 1, endIdx === Infinity ? undefined : endIdx)
|
|
100
|
+
.filter((l) => l.startsWith('- ['))
|
|
101
|
+
.slice(-3),
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Stats section (header + stat line)
|
|
106
|
+
const stats = statsIdx !== -1 ? lines.slice(statsIdx, statsIdx + 3) : []
|
|
107
|
+
|
|
108
|
+
if (successLines.length > 0 || failureLines.length > 0) {
|
|
109
|
+
experienceMemory = [
|
|
110
|
+
'## Agent Experience',
|
|
111
|
+
...header.filter((l) => l.trim()),
|
|
112
|
+
...successLines,
|
|
113
|
+
...failureLines,
|
|
114
|
+
...stats,
|
|
115
|
+
].join('\n')
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Combine both memory sources
|
|
120
|
+
if (staticMemory && experienceMemory) {
|
|
121
|
+
return `${staticMemory}\n\n---\n\n${experienceMemory}`
|
|
55
122
|
}
|
|
123
|
+
if (experienceMemory) return experienceMemory
|
|
124
|
+
return staticMemory
|
|
56
125
|
}
|
|
57
126
|
|
|
58
127
|
/**
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const MAX_EXPERIENCES = 20
|
|
5
|
+
|
|
6
|
+
export class AgentExperience {
|
|
7
|
+
private readonly expFile: string
|
|
8
|
+
private readonly expDir: string
|
|
9
|
+
|
|
10
|
+
constructor(
|
|
11
|
+
private readonly agentName: string,
|
|
12
|
+
baseDir: string = join(process.env.HOME || '~', '.mipham', 'agent-memory'),
|
|
13
|
+
) {
|
|
14
|
+
this.expDir = join(baseDir, agentName)
|
|
15
|
+
this.expFile = join(this.expDir, 'experience.md')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
logSuccess(description: string, whenToApply: string): void {
|
|
19
|
+
const date = new Date().toISOString().slice(0, 10)
|
|
20
|
+
const entry = `- [${date}] ${description}\n **When to apply:** ${whenToApply}\n`
|
|
21
|
+
this.appendToSection('## Success Patterns', entry, 'success')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
logFailure(description: string, whenToAvoid: string): void {
|
|
25
|
+
const date = new Date().toISOString().slice(0, 10)
|
|
26
|
+
const entry = `- [${date}] ${description}\n **When to avoid:** ${whenToAvoid}\n`
|
|
27
|
+
this.appendToSection('## Failure Patterns', entry, 'failure')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
getExperience(): string {
|
|
31
|
+
if (!existsSync(this.expFile)) return ''
|
|
32
|
+
try {
|
|
33
|
+
return readFileSync(this.expFile, 'utf-8')
|
|
34
|
+
} catch {
|
|
35
|
+
return ''
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
reset(): void {
|
|
40
|
+
if (existsSync(this.expFile)) {
|
|
41
|
+
try {
|
|
42
|
+
unlinkSync(this.expFile)
|
|
43
|
+
} catch {
|
|
44
|
+
/* ok */
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private appendToSection(section: string, entry: string, type: 'success' | 'failure'): void {
|
|
50
|
+
mkdirSync(this.expDir, { recursive: true })
|
|
51
|
+
|
|
52
|
+
let content = this.getExperience()
|
|
53
|
+
if (!content) {
|
|
54
|
+
content = `# Agent Experience — ${this.agentName}\n\n## Success Patterns\n\n## Failure Patterns\n\n## Stats\n- 总执行: 0 次 | 成功: 0 | 失败: 0\n`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Find section and append entry
|
|
58
|
+
const sectionIndex = content.indexOf(section)
|
|
59
|
+
if (sectionIndex === -1) {
|
|
60
|
+
// Section missing — add before Stats
|
|
61
|
+
const statsIndex = content.indexOf('## Stats')
|
|
62
|
+
if (statsIndex !== -1) {
|
|
63
|
+
content =
|
|
64
|
+
content.slice(0, statsIndex) + `${section}\n${entry}\n` + content.slice(statsIndex)
|
|
65
|
+
} else {
|
|
66
|
+
content += `\n${section}\n${entry}\n`
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
// Find next section header after this one
|
|
70
|
+
const nextSection = content.indexOf('\n## ', sectionIndex + section.length)
|
|
71
|
+
const insertAt = nextSection !== -1 ? nextSection : content.length
|
|
72
|
+
content = content.slice(0, insertAt) + entry + content.slice(insertAt)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Trim old entries if over limit
|
|
76
|
+
const lines = content.split('\n')
|
|
77
|
+
let finalEntries = lines.filter((l) => l.startsWith('- ['))
|
|
78
|
+
while (finalEntries.length > MAX_EXPERIENCES) {
|
|
79
|
+
// Remove oldest entry (first one found)
|
|
80
|
+
const firstEntryIdx = lines.findIndex((l) => l.startsWith('- ['))
|
|
81
|
+
if (firstEntryIdx !== -1) {
|
|
82
|
+
const nextEntryLine = lines[firstEntryIdx + 1]
|
|
83
|
+
const removeCount = nextEntryLine?.startsWith(' **') ? 2 : 1
|
|
84
|
+
lines.splice(firstEntryIdx, removeCount)
|
|
85
|
+
finalEntries = lines.filter((l) => l.startsWith('- ['))
|
|
86
|
+
} else {
|
|
87
|
+
break
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
content = lines.join('\n')
|
|
91
|
+
|
|
92
|
+
// Update stats (merged from incrementStat — single file write)
|
|
93
|
+
content = content.replace(
|
|
94
|
+
/- 总执行: (\d+) 次 \| 成功: (\d+) \| 失败: (\d+)/,
|
|
95
|
+
(_match, total: string, success: string, failure: string) => {
|
|
96
|
+
const newTotal = parseInt(total) + 1
|
|
97
|
+
const newSuccess = type === 'success' ? parseInt(success) + 1 : parseInt(success)
|
|
98
|
+
const newFailure = type === 'failure' ? parseInt(failure) + 1 : parseInt(failure)
|
|
99
|
+
return `- 总执行: ${newTotal} 次 | 成功: ${newSuccess} | 失败: ${newFailure}`
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
writeFileSync(this.expFile, content, 'utf-8')
|
|
104
|
+
}
|
|
105
|
+
}
|
package/src/agent/sub-agent.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { getBackgroundAgentRegistry } from './background-registry'
|
|
|
6
6
|
import { getMessageBus } from './message-bus'
|
|
7
7
|
import type { HookEngine } from '../core/hooks'
|
|
8
8
|
import type { PermissionSystem } from '../core/permission'
|
|
9
|
+
import { AgentExperience } from './agent-experience'
|
|
9
10
|
|
|
10
11
|
const TYPE_SYSTEM_PROMPTS: Record<SubAgentType, string> = {
|
|
11
12
|
general: 'You are a focused sub-agent. Complete the assigned task thoroughly and return results.',
|
|
@@ -73,6 +74,17 @@ export class SubAgent {
|
|
|
73
74
|
task.result || task.error,
|
|
74
75
|
)
|
|
75
76
|
}
|
|
77
|
+
// Auto-log agent experience for background tasks
|
|
78
|
+
if (task.status === 'completed') {
|
|
79
|
+
this.logSuccessExperience(agentType, description, task.result || '', options.agentDef)
|
|
80
|
+
} else {
|
|
81
|
+
this.logFailureExperience(
|
|
82
|
+
agentType,
|
|
83
|
+
description,
|
|
84
|
+
task.error || 'Unknown error',
|
|
85
|
+
options.agentDef,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
76
88
|
})
|
|
77
89
|
|
|
78
90
|
return `[background-task:${taskId}]`
|
|
@@ -84,6 +96,7 @@ export class SubAgent {
|
|
|
84
96
|
if (this.hookEngine) {
|
|
85
97
|
await this.hookEngine.executeSubagentStop(agentType, description, 'sub-agent', true, result)
|
|
86
98
|
}
|
|
99
|
+
this.logSuccessExperience(agentType, description, result, options.agentDef)
|
|
87
100
|
return result
|
|
88
101
|
} catch (err) {
|
|
89
102
|
if (this.hookEngine) {
|
|
@@ -95,6 +108,7 @@ export class SubAgent {
|
|
|
95
108
|
String(err),
|
|
96
109
|
)
|
|
97
110
|
}
|
|
111
|
+
this.logFailureExperience(agentType, description, String(err), options.agentDef)
|
|
98
112
|
throw err
|
|
99
113
|
}
|
|
100
114
|
}
|
|
@@ -314,4 +328,46 @@ export class SubAgent {
|
|
|
314
328
|
|
|
315
329
|
return chunks.join('')
|
|
316
330
|
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Log a successful execution to AgentExperience.
|
|
334
|
+
* Wrapped in try/catch so it never breaks agent execution.
|
|
335
|
+
*/
|
|
336
|
+
private logSuccessExperience(
|
|
337
|
+
agentType: SubAgentType,
|
|
338
|
+
description: string,
|
|
339
|
+
result: string,
|
|
340
|
+
agentDef?: AgentDefinition,
|
|
341
|
+
): void {
|
|
342
|
+
try {
|
|
343
|
+
const name = agentDef?.name || agentType
|
|
344
|
+
const exp = new AgentExperience(name)
|
|
345
|
+
if (result && result.trim()) {
|
|
346
|
+
const firstLine = result.trim().split('\n')[0]?.slice(0, 150) || 'Task completed'
|
|
347
|
+
exp.logSuccess(firstLine, description)
|
|
348
|
+
}
|
|
349
|
+
} catch {
|
|
350
|
+
// Never let experience logging break execution
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Log a failed execution to AgentExperience.
|
|
356
|
+
* Wrapped in try/catch so it never breaks agent execution.
|
|
357
|
+
*/
|
|
358
|
+
private logFailureExperience(
|
|
359
|
+
agentType: SubAgentType,
|
|
360
|
+
description: string,
|
|
361
|
+
errMsg: string,
|
|
362
|
+
agentDef?: AgentDefinition,
|
|
363
|
+
): void {
|
|
364
|
+
try {
|
|
365
|
+
const name = agentDef?.name || agentType
|
|
366
|
+
const exp = new AgentExperience(name)
|
|
367
|
+
const truncated = errMsg.slice(0, 200)
|
|
368
|
+
exp.logFailure(truncated, description)
|
|
369
|
+
} catch {
|
|
370
|
+
// Never let experience logging break execution
|
|
371
|
+
}
|
|
372
|
+
}
|
|
317
373
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { CommandHandler } from '../ui/commands'
|
|
2
|
+
import { KeyManager } from '../config/keys-manager'
|
|
3
|
+
|
|
4
|
+
export const keysCmd: CommandHandler = async (_ctx, args) => {
|
|
5
|
+
const manager = new KeyManager()
|
|
6
|
+
const sub = args[0]?.toLowerCase()
|
|
7
|
+
|
|
8
|
+
// /keys rotate <provider>
|
|
9
|
+
if (sub === 'rotate') {
|
|
10
|
+
const provider = args[1]
|
|
11
|
+
if (!provider) {
|
|
12
|
+
return {
|
|
13
|
+
content: 'Usage: /keys rotate <provider>\n\nExample: /keys rotate deepseek',
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
// Interactive: prompt user to provide new key via follow-up chat message
|
|
17
|
+
return {
|
|
18
|
+
content: [
|
|
19
|
+
'── Key Rotation ──',
|
|
20
|
+
'',
|
|
21
|
+
`Provider: ${provider}`,
|
|
22
|
+
'',
|
|
23
|
+
'To complete rotation, the new API key must be provided.',
|
|
24
|
+
'Type your new key in chat and the AI will handle rotation securely.',
|
|
25
|
+
'',
|
|
26
|
+
'The old key will be backed up to ~/.mipham/keys/<provider>.backup (chmod 600).',
|
|
27
|
+
].join('\n'),
|
|
28
|
+
forwardToAI: `The user wants to rotate the API key for provider "${provider}". Ask them to provide the new key value, then:
|
|
29
|
+
1. Call KeyManager.rotate("${provider}", newKey) with the provided key
|
|
30
|
+
2. The new key should be stored in ~/.mipham/config.json (or appropriate config)
|
|
31
|
+
3. Confirm rotation count and backup location
|
|
32
|
+
4. Remind them the old key is backed up at ~/.mipham/keys/${provider}.backup`,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// /keys audit
|
|
37
|
+
if (sub === 'audit') {
|
|
38
|
+
const expired = manager.audit()
|
|
39
|
+
if (expired.length === 0) {
|
|
40
|
+
return {
|
|
41
|
+
content:
|
|
42
|
+
'── Key Audit ──\n\n✅ All keys are within the 90-day rotation window.\n\nNo expired keys found.',
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const lines: string[] = [
|
|
47
|
+
'── Key Audit ──',
|
|
48
|
+
'',
|
|
49
|
+
`⚠️ ${expired.length} key(s) have exceeded the 90-day rotation threshold:`,
|
|
50
|
+
'',
|
|
51
|
+
...expired.map(
|
|
52
|
+
(k) =>
|
|
53
|
+
` 🔴 ${k.provider.padEnd(16)} ${k.ageDays} days since last rotation (${k.lastRotated.slice(0, 10)})`,
|
|
54
|
+
),
|
|
55
|
+
'',
|
|
56
|
+
'Rotate expired keys with: /keys rotate <provider>',
|
|
57
|
+
]
|
|
58
|
+
return { content: lines.join('\n') }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// /keys (list)
|
|
62
|
+
const keys = manager.list()
|
|
63
|
+
|
|
64
|
+
if (keys.length === 0) {
|
|
65
|
+
return {
|
|
66
|
+
content: [
|
|
67
|
+
'── API Keys ──',
|
|
68
|
+
'',
|
|
69
|
+
'No API keys tracked yet.',
|
|
70
|
+
'',
|
|
71
|
+
'Keys are tracked in ~/.mipham/keys.json when you configure providers.',
|
|
72
|
+
'Use /keys rotate <provider> to begin tracking rotation for a provider.',
|
|
73
|
+
].join('\n'),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const lines: string[] = [
|
|
78
|
+
'── API Keys ──',
|
|
79
|
+
'',
|
|
80
|
+
'Provider Age Rotations Status',
|
|
81
|
+
'─'.repeat(55),
|
|
82
|
+
...keys.map((k) => {
|
|
83
|
+
const status = k.expired ? '🔴 EXPIRED' : '🟢 OK'
|
|
84
|
+
return ` ${k.provider.padEnd(16)} ${String(k.ageDays).padStart(4)}d ${String(k.rotationCount).padStart(5)} ${status}`
|
|
85
|
+
}),
|
|
86
|
+
'',
|
|
87
|
+
`${keys.length} key(s) tracked.`,
|
|
88
|
+
'',
|
|
89
|
+
'Commands:',
|
|
90
|
+
' /keys rotate <provider> — rotate a key',
|
|
91
|
+
' /keys audit — check for expired keys',
|
|
92
|
+
]
|
|
93
|
+
return { content: lines.join('\n') }
|
|
94
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs'
|
|
2
|
+
import { join, dirname } from 'node:path'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
|
|
5
|
+
const MIPHAM_DIR = join(homedir(), '.mipham')
|
|
6
|
+
const KEYS_FILE = join(MIPHAM_DIR, 'keys.json')
|
|
7
|
+
|
|
8
|
+
export interface KeyEntry {
|
|
9
|
+
createdAt: string
|
|
10
|
+
lastRotated: string
|
|
11
|
+
rotationCount: number
|
|
12
|
+
provider: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface KeyStatus {
|
|
16
|
+
provider: string
|
|
17
|
+
createdAt: string
|
|
18
|
+
lastRotated: string
|
|
19
|
+
rotationCount: number
|
|
20
|
+
ageDays: number
|
|
21
|
+
expired: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface KeysData {
|
|
25
|
+
[provider: string]: KeyEntry
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function loadKeys(): KeysData {
|
|
29
|
+
if (!existsSync(KEYS_FILE)) return {}
|
|
30
|
+
try {
|
|
31
|
+
const raw = readFileSync(KEYS_FILE, 'utf-8')
|
|
32
|
+
return JSON.parse(raw) as KeysData
|
|
33
|
+
} catch {
|
|
34
|
+
return {}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function saveKeys(data: KeysData): void {
|
|
39
|
+
mkdirSync(dirname(KEYS_FILE), { recursive: true })
|
|
40
|
+
const tmp = KEYS_FILE + '.tmp'
|
|
41
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
|
|
42
|
+
writeFileSync(KEYS_FILE, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
|
|
43
|
+
try {
|
|
44
|
+
chmodSync(KEYS_FILE, 0o600)
|
|
45
|
+
} catch {
|
|
46
|
+
// chmod on Windows is a no-op
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function daysSince(iso: string): number {
|
|
51
|
+
const then = new Date(iso).getTime()
|
|
52
|
+
const now = Date.now()
|
|
53
|
+
return Math.floor((now - then) / (1000 * 60 * 60 * 24))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class KeyManager {
|
|
57
|
+
list(): KeyStatus[] {
|
|
58
|
+
const data = loadKeys()
|
|
59
|
+
return Object.entries(data).map(([provider, entry]) => ({
|
|
60
|
+
provider,
|
|
61
|
+
createdAt: entry.createdAt,
|
|
62
|
+
lastRotated: entry.lastRotated,
|
|
63
|
+
rotationCount: entry.rotationCount,
|
|
64
|
+
ageDays: daysSince(entry.lastRotated),
|
|
65
|
+
expired: daysSince(entry.lastRotated) > 90,
|
|
66
|
+
}))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
rotate(provider: string, _newKey: string): { success: boolean; message: string } {
|
|
70
|
+
const data = loadKeys()
|
|
71
|
+
const now = new Date().toISOString()
|
|
72
|
+
const existing = data[provider]
|
|
73
|
+
|
|
74
|
+
// Backup old entry if it exists
|
|
75
|
+
if (existing) {
|
|
76
|
+
const backupDir = join(MIPHAM_DIR, 'keys')
|
|
77
|
+
mkdirSync(backupDir, { recursive: true })
|
|
78
|
+
const backupPath = join(backupDir, `${provider}.backup`)
|
|
79
|
+
writeFileSync(backupPath, JSON.stringify(existing, null, 2) + '\n', { mode: 0o600 })
|
|
80
|
+
try {
|
|
81
|
+
chmodSync(backupPath, 0o600)
|
|
82
|
+
} catch {
|
|
83
|
+
// Windows
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
data[provider] = {
|
|
87
|
+
createdAt: existing.createdAt,
|
|
88
|
+
lastRotated: now,
|
|
89
|
+
rotationCount: existing.rotationCount + 1,
|
|
90
|
+
provider,
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
data[provider] = {
|
|
94
|
+
createdAt: now,
|
|
95
|
+
lastRotated: now,
|
|
96
|
+
rotationCount: 1,
|
|
97
|
+
provider,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
saveKeys(data)
|
|
102
|
+
return {
|
|
103
|
+
success: true,
|
|
104
|
+
message: `Key for "${provider}" rotated (rotation #${data[provider]!.rotationCount}). Backup saved to ~/.mipham/keys/${provider}.backup`,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
audit(): KeyStatus[] {
|
|
109
|
+
return this.list().filter((k) => k.expired)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
getExpiryReminder(): string | null {
|
|
113
|
+
const expired = this.audit()
|
|
114
|
+
if (expired.length === 0) return null
|
|
115
|
+
|
|
116
|
+
const lines = expired.map(
|
|
117
|
+
(k) => ` - ${k.provider}: last rotated ${k.ageDays} days ago (90-day threshold exceeded)`,
|
|
118
|
+
)
|
|
119
|
+
return `⚠️ API key rotation overdue:\n${lines.join('\n')}\n\nRun /keys rotate <provider> to rotate.`
|
|
120
|
+
}
|
|
121
|
+
}
|