@asobacloud/nehanda 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/LICENSE.md +57 -0
- package/README.md +404 -2
- package/bin/agent.mjs +1260 -0
- package/bin/nehanda-ui.mjs +824 -0
- package/lib/anthropicHeaders.mjs +8 -0
- package/lib/app.mjs +313 -0
- package/lib/auth.mjs +255 -0
- package/lib/bashguard.mjs +159 -0
- package/lib/compact.mjs +306 -0
- package/lib/energy-middleware.mjs +318 -0
- package/lib/hookShapeOrder.mjs +30 -0
- package/lib/hookplane.mjs +228 -0
- package/lib/instructions.mjs +23 -0
- package/lib/jev_read.mjs +216 -0
- package/lib/jev_write.mjs +253 -0
- package/lib/laya.mjs +296 -0
- package/lib/laya_sidecar.mjs +198 -0
- package/lib/mcp-config.mjs +189 -0
- package/lib/mcp.mjs +169 -0
- package/lib/modelConfig.mjs +152 -0
- package/lib/modelDiscovery.mjs +205 -0
- package/lib/openaiCompat.mjs +147 -0
- package/lib/orchestrate.mjs +1163 -0
- package/lib/paths.mjs +20 -0
- package/lib/permissions.mjs +44 -0
- package/lib/scripts/__pycache__/laya_worker.cpython-314.pyc +0 -0
- package/lib/scripts/audit-code-integrity.py +437 -0
- package/lib/scripts/ensure-laya-env.sh +55 -0
- package/lib/scripts/js-safety-checker.cjs +308 -0
- package/lib/scripts/laya_worker.py +171 -0
- package/lib/scripts/odse-transform.py +97 -0
- package/lib/scripts/python-safety-checker.py +197 -0
- package/lib/scripts/shell-safety-checker.sh +306 -0
- package/lib/settings.mjs +147 -0
- package/lib/store.mjs +278 -0
- package/lib/team.mjs +69 -0
- package/lib/testgen.mjs +146 -0
- package/lib/tools/AuditCodeIntegrity.json +30 -0
- package/lib/tools/JsSafetyChecker.json +29 -0
- package/lib/tools/PythonSafetyChecker.json +29 -0
- package/lib/tools/ShellSafetyChecker.json +29 -0
- package/lib/tools.mjs +907 -0
- package/lib/transcript.mjs +223 -0
- package/lib/ui.mjs +286 -0
- package/lib/workflow.mjs +91 -0
- package/lib/worktree.mjs +56 -0
- package/package.json +50 -4
- package/schema.sql +181 -0
- package/scripts/postinstall.mjs +62 -0
- package/scripts/sdlc-acceptance.sh +477 -0
- package/scripts/verify-sdlc-hook-order.mjs +26 -0
- package/templates/test_e2e.sh +32 -0
- package/templates/test_file.sh +14 -0
- package/templates/test_hook.sh +27 -0
- package/templates/test_http.sh +17 -0
- package/templates/test_phase.sh +23 -0
- package/templates/test_process.sh +14 -0
- package/templates/test_tool.sh +24 -0
- package/templates/validate_test.sh +55 -0
package/lib/app.mjs
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// Ink-based terminal UI — ported from claude-code/components/LogoV2/, messages/, PromptInput/
|
|
2
|
+
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
|
3
|
+
import { render, Box, Text, useApp, useStdout } from 'ink'
|
|
4
|
+
import TextInput from 'ink-text-input'
|
|
5
|
+
import { marked } from 'marked'
|
|
6
|
+
import TerminalRenderer from 'marked-terminal'
|
|
7
|
+
import chalk from 'chalk'
|
|
8
|
+
|
|
9
|
+
const e = React.createElement
|
|
10
|
+
|
|
11
|
+
// Figures — claude-code/constants/figures.ts
|
|
12
|
+
const BLACK_CIRCLE = process.platform === 'darwin' ? '\u23FA' : '\u25CF'
|
|
13
|
+
const TICK = '\u2713'
|
|
14
|
+
const CROSS = '\u2717'
|
|
15
|
+
const BULLET = '\u2219'
|
|
16
|
+
|
|
17
|
+
// ── Markdown rendering ───────────────────────────────────────
|
|
18
|
+
marked.setOptions({
|
|
19
|
+
renderer: new TerminalRenderer({
|
|
20
|
+
code: chalk.yellow,
|
|
21
|
+
codespan: chalk.yellow,
|
|
22
|
+
strong: chalk.bold,
|
|
23
|
+
em: chalk.italic,
|
|
24
|
+
heading: chalk.bold.cyan,
|
|
25
|
+
hr: () => chalk.dim('\u2500'.repeat(50)),
|
|
26
|
+
listitem: text => ` ${chalk.dim('\u2022')} ${text}`,
|
|
27
|
+
paragraph: text => text + '\n',
|
|
28
|
+
link: (href, title, text) => `${text} ${chalk.dim.underline(href)}`,
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
function renderMd(text) {
|
|
33
|
+
if (!text) return ''
|
|
34
|
+
try { return marked(text).replace(/\n{3,}/g, '\n\n').trimEnd() }
|
|
35
|
+
catch { return text }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Cape Town skyline — Table Mountain, Lion's Head, city lights ──
|
|
39
|
+
function CapeTownSkyline() {
|
|
40
|
+
return e(Box, { flexDirection: 'column', alignItems: 'center' },
|
|
41
|
+
e(Text, {},
|
|
42
|
+
e(Text, { dimColor: true }, ' \u2726 '),
|
|
43
|
+
e(Text, { color: 'yellow' }, '\u2600'),
|
|
44
|
+
),
|
|
45
|
+
e(Text, {},
|
|
46
|
+
e(Text, { color: '#cc785c' }, ' \u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584'),
|
|
47
|
+
e(Text, { dimColor: true }, ' \u2726 '),
|
|
48
|
+
e(Text, { color: '#cc785c' }, '\u25B2'),
|
|
49
|
+
),
|
|
50
|
+
e(Text, {},
|
|
51
|
+
e(Text, { color: '#cc785c' }, ' \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u258C \u259F\u258A'),
|
|
52
|
+
),
|
|
53
|
+
e(Text, {},
|
|
54
|
+
e(Text, { color: '#cc785c' }, ' \u259F\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2584\u2588\u2588\u258A\u2596'),
|
|
55
|
+
),
|
|
56
|
+
e(Text, {},
|
|
57
|
+
e(Text, { color: '#8a6040' }, ' \u2591\u2592\u2588\u2592\u2591\u2593\u2588\u2593\u2591\u2592\u2588\u2592\u2591\u2593\u2588\u2593\u2591'),
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Welcome Banner — from claude-code LogoV2/LogoV2.tsx ──
|
|
63
|
+
function WelcomeBanner({ version, provider, wireModel, cwd, permissionMode, bare }) {
|
|
64
|
+
let cols = 80
|
|
65
|
+
try { cols = process.stdout.columns || 80 } catch {}
|
|
66
|
+
|
|
67
|
+
const modelLine = `${wireModel}${bare ? ' [bare]' : ''}`
|
|
68
|
+
const cwdDisplay = cwd.startsWith(process.env.HOME || '~')
|
|
69
|
+
? '~' + cwd.slice((process.env.HOME || '~').length)
|
|
70
|
+
: cwd
|
|
71
|
+
|
|
72
|
+
const bannerWidth = Math.min(cols, 80)
|
|
73
|
+
const leftWidth = Math.floor(bannerWidth * 0.4)
|
|
74
|
+
const rightWidth = bannerWidth - leftWidth - 3
|
|
75
|
+
|
|
76
|
+
return e(Box, {
|
|
77
|
+
flexDirection: 'column', borderStyle: 'single', borderColor: '#cc785c',
|
|
78
|
+
width: bannerWidth, marginBottom: 1,
|
|
79
|
+
},
|
|
80
|
+
e(Box, { flexDirection: 'row', paddingX: 1 },
|
|
81
|
+
// Left panel
|
|
82
|
+
e(Box, { flexDirection: 'column', width: leftWidth, alignItems: 'center', paddingY: 1 },
|
|
83
|
+
e(Text, { bold: true, color: '#cc785c' }, 'Welcome to ona!'),
|
|
84
|
+
e(CapeTownSkyline, {}),
|
|
85
|
+
e(Text, { dimColor: true }, modelLine),
|
|
86
|
+
e(Text, { dimColor: true }, cwdDisplay),
|
|
87
|
+
),
|
|
88
|
+
// Separator
|
|
89
|
+
e(Box, { width: 1, borderStyle: 'single', borderColor: '#cc785c',
|
|
90
|
+
borderTop: false, borderBottom: false, borderRight: false, borderLeft: true }),
|
|
91
|
+
// Right panel
|
|
92
|
+
e(Box, { flexDirection: 'column', width: rightWidth, paddingLeft: 1, paddingY: 1 },
|
|
93
|
+
e(Text, { bold: true, color: '#cc785c' }, 'Tips for getting started'),
|
|
94
|
+
e(Text, { dimColor: true }, 'Run /init to create an Ona.md file with instructions'),
|
|
95
|
+
e(Text, { dimColor: true }, 'Use /help to see available commands'),
|
|
96
|
+
e(Text, { dimColor: true }, 'Use /model to switch models'),
|
|
97
|
+
e(Box, { marginTop: 1 },
|
|
98
|
+
e(Text, { bold: true }, 'Recent activity'),
|
|
99
|
+
),
|
|
100
|
+
e(Text, { dimColor: true }, 'No recent activity'),
|
|
101
|
+
),
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Slash commands ───────────────────────────────────────────
|
|
107
|
+
const SLASH_COMMANDS = [
|
|
108
|
+
{ name: '/phase', desc: 'Current SDLC phase' },
|
|
109
|
+
{ name: '/plan', desc: 'Plan status' },
|
|
110
|
+
{ name: '/code', desc: 'Implement plan' },
|
|
111
|
+
{ name: '/test', desc: 'Run tests' },
|
|
112
|
+
{ name: '/verify', desc: 'Coverage report' },
|
|
113
|
+
{ name: '/done', desc: 'Complete workflow' },
|
|
114
|
+
{ name: '/init', desc: 'Create Ona.md' },
|
|
115
|
+
{ name: '/diff', desc: 'Uncommitted changes' },
|
|
116
|
+
{ name: '/cost', desc: 'Token usage' },
|
|
117
|
+
{ name: '/doctor', desc: 'Diagnostics' },
|
|
118
|
+
{ name: '/permissions', desc: 'Permission rules' },
|
|
119
|
+
{ name: '/pr-comments', desc: 'PR comments' },
|
|
120
|
+
{ name: '/compact', desc: 'Compact conversation' },
|
|
121
|
+
{ name: '/team', desc: 'Manage teams' },
|
|
122
|
+
{ name: '/model', desc: 'Change model' },
|
|
123
|
+
{ name: '/login', desc: 'Authenticate' },
|
|
124
|
+
{ name: '/logout', desc: 'Clear credentials' },
|
|
125
|
+
{ name: '/status', desc: 'Auth status' },
|
|
126
|
+
{ name: '/config', desc: 'Settings' },
|
|
127
|
+
{ name: '/clear', desc: 'New conversation' },
|
|
128
|
+
{ name: '/exit', desc: 'Quit' },
|
|
129
|
+
{ name: '/help', desc: 'All commands' },
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
// ── Message — from claude-code messages/*.tsx ────────────────
|
|
133
|
+
function MessageView({ msg }) {
|
|
134
|
+
if (msg.role === 'user') {
|
|
135
|
+
return e(Box, { marginLeft: 0, flexDirection: 'row' },
|
|
136
|
+
e(Text, { bold: true, color: '#cc785c' }, '\u276F '),
|
|
137
|
+
e(Box, { flexShrink: 1 }, e(Text, { wrap: 'wrap' }, msg.text))
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
if (msg.role === 'assistant') {
|
|
141
|
+
return e(Box, { flexDirection: 'column' },
|
|
142
|
+
e(Text, {}, renderMd(msg.text))
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
if (msg.role === 'tool_start') {
|
|
146
|
+
return e(Box, { marginLeft: 1 },
|
|
147
|
+
e(Text, { color: '#cc785c' }, `${BLACK_CIRCLE} `),
|
|
148
|
+
e(Text, { bold: true }, msg.toolName),
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
if (msg.role === 'tool_result') {
|
|
152
|
+
const icon = msg.isError
|
|
153
|
+
? e(Text, { color: 'red' }, `${CROSS} `)
|
|
154
|
+
: e(Text, { color: 'green' }, `${TICK} `)
|
|
155
|
+
const preview = (msg.text || '').split('\n')[0].slice(0, 120)
|
|
156
|
+
return e(Box, { marginLeft: 2 }, icon, e(Text, { dimColor: true }, preview))
|
|
157
|
+
}
|
|
158
|
+
if (msg.role === 'system') {
|
|
159
|
+
return e(Box, { marginLeft: 1 }, e(Text, { dimColor: true }, msg.text))
|
|
160
|
+
}
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── Spinner — from claude-code Spinner/SpinnerGlyph.tsx ──────
|
|
165
|
+
const SPINNER_FRAMES = ['\u280B', '\u2819', '\u2839', '\u2838', '\u283C', '\u2834', '\u2826', '\u2827', '\u2807', '\u280F']
|
|
166
|
+
|
|
167
|
+
function Spinner({ label }) {
|
|
168
|
+
const [frame, setFrame] = useState(0)
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
const t = setInterval(() => setFrame(f => (f + 1) % SPINNER_FRAMES.length), 80)
|
|
171
|
+
return () => clearInterval(t)
|
|
172
|
+
}, [])
|
|
173
|
+
return e(Box, { marginLeft: 1 },
|
|
174
|
+
e(Text, { color: '#cc785c' }, SPINNER_FRAMES[frame] + ' '),
|
|
175
|
+
e(Text, { dimColor: true }, label || 'Thinking\u2026'),
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Slash menu ──────────────────────────────────────────────
|
|
180
|
+
function SlashMenu({ filter }) {
|
|
181
|
+
const matches = SLASH_COMMANDS.filter(c => c.name.startsWith(filter) || c.name.includes(filter))
|
|
182
|
+
if (!matches.length) return null
|
|
183
|
+
return e(Box, { flexDirection: 'column', marginLeft: 2 },
|
|
184
|
+
...matches.map((c, i) =>
|
|
185
|
+
e(Box, { key: String(i) },
|
|
186
|
+
e(Text, { color: 'cyan' }, c.name.padEnd(14)),
|
|
187
|
+
e(Text, { dimColor: true }, c.desc),
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── Ask prompt ──────────────────────────────────────────────
|
|
194
|
+
function AskPrompt({ question, onAnswer }) {
|
|
195
|
+
const [value, setValue] = useState('')
|
|
196
|
+
return e(Box, { flexDirection: 'column' },
|
|
197
|
+
e(Box, { marginLeft: 1 }, e(Text, { dimColor: true }, question)),
|
|
198
|
+
e(Box, { marginLeft: 1 },
|
|
199
|
+
e(Text, { color: '#cc785c' }, '\u276F '),
|
|
200
|
+
e(TextInput, { value, onChange: setValue, onSubmit: useCallback(v => onAnswer(v.trim()), [onAnswer]) }),
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── Input ───────────────────────────────────────────────────
|
|
206
|
+
function PromptInputArea({ onSubmit, isLoading }) {
|
|
207
|
+
const [value, setValue] = useState('')
|
|
208
|
+
const showSlashMenu = value.startsWith('/') && !value.includes(' ')
|
|
209
|
+
|
|
210
|
+
const handleSubmit = useCallback((v) => {
|
|
211
|
+
if (!v.trim()) return
|
|
212
|
+
setValue('')
|
|
213
|
+
onSubmit(v.trim())
|
|
214
|
+
}, [onSubmit])
|
|
215
|
+
|
|
216
|
+
if (isLoading) return null
|
|
217
|
+
|
|
218
|
+
let cols = 80
|
|
219
|
+
try { cols = process.stdout.columns || 80 } catch {}
|
|
220
|
+
|
|
221
|
+
return e(Box, { flexDirection: 'column' },
|
|
222
|
+
e(Text, { dimColor: true }, '\u2500'.repeat(cols)),
|
|
223
|
+
showSlashMenu ? e(SlashMenu, { filter: value }) : null,
|
|
224
|
+
e(Box, { paddingX: 1 },
|
|
225
|
+
e(Text, { bold: true, color: '#cc785c' }, '\u276F '),
|
|
226
|
+
e(TextInput, { value, onChange: setValue, onSubmit: handleSubmit }),
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── Footer — from claude-code PromptInput/PromptInputFooter.tsx ──
|
|
232
|
+
function Footer({ phase, model, permissionMode }) {
|
|
233
|
+
return e(Box, { marginTop: 1 },
|
|
234
|
+
e(Text, { dimColor: true }, '? for shortcuts'),
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Main App ────────────────────────────────────────────────
|
|
239
|
+
export function App({ config, onUserInput, onExit }) {
|
|
240
|
+
const { exit } = useApp()
|
|
241
|
+
const [messages, setMessages] = useState([])
|
|
242
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
243
|
+
const [streamText, setStreamText] = useState('')
|
|
244
|
+
const [askState, setAskState] = useState(null)
|
|
245
|
+
|
|
246
|
+
const ref = useRef({ setMessages, setIsLoading, setStreamText, messages, setAskState })
|
|
247
|
+
ref.current.messages = messages
|
|
248
|
+
useEffect(() => {
|
|
249
|
+
if (config._bridge) {
|
|
250
|
+
config._bridge.setMessages = setMessages
|
|
251
|
+
config._bridge.setIsLoading = setIsLoading
|
|
252
|
+
config._bridge.setStreamText = setStreamText
|
|
253
|
+
config._bridge.setAskState = setAskState
|
|
254
|
+
config._bridge.getMessages = () => ref.current.messages
|
|
255
|
+
config._bridge.exit = () => { exit(); onExit?.() }
|
|
256
|
+
}
|
|
257
|
+
}, [config._bridge, exit, onExit])
|
|
258
|
+
|
|
259
|
+
const handleSubmit = useCallback((text) => {
|
|
260
|
+
setMessages(prev => [...prev, { role: 'user', text }])
|
|
261
|
+
onUserInput(text)
|
|
262
|
+
}, [onUserInput])
|
|
263
|
+
|
|
264
|
+
const handleAskAnswer = useCallback((answer) => {
|
|
265
|
+
if (askState?.resolve) askState.resolve(answer)
|
|
266
|
+
setAskState(null)
|
|
267
|
+
}, [askState])
|
|
268
|
+
|
|
269
|
+
return e(Box, { flexDirection: 'column' },
|
|
270
|
+
e(WelcomeBanner, {
|
|
271
|
+
version: config.version,
|
|
272
|
+
provider: config.provider,
|
|
273
|
+
wireModel: config.wireModel,
|
|
274
|
+
cwd: config.cwd || process.cwd(),
|
|
275
|
+
permissionMode: config.permissionMode || 'default',
|
|
276
|
+
bare: config.bare,
|
|
277
|
+
}),
|
|
278
|
+
|
|
279
|
+
...messages.map((msg, i) => e(MessageView, { key: String(i), msg })),
|
|
280
|
+
|
|
281
|
+
streamText ? e(Box, {}, e(Text, {}, streamText)) : null,
|
|
282
|
+
isLoading ? e(Spinner, { label: 'Thinking\u2026' }) : null,
|
|
283
|
+
askState ? e(AskPrompt, { question: askState.question, onAnswer: handleAskAnswer }) : null,
|
|
284
|
+
!askState ? e(Box, {}, e(PromptInputArea, { onSubmit: handleSubmit, isLoading })) : null,
|
|
285
|
+
e(Footer, { phase: config.phase || 'idle', model: config.wireModel, permissionMode: config.permissionMode || 'default' }),
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── Render entry point ──────────────────────────────────────
|
|
290
|
+
export function startApp(config) {
|
|
291
|
+
const bridge = {}
|
|
292
|
+
config._bridge = bridge
|
|
293
|
+
|
|
294
|
+
const instance = render(
|
|
295
|
+
e(App, { config, onUserInput: config.onUserInput, onExit: config.onExit }),
|
|
296
|
+
{ exitOnCtrlC: true }
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
bridge, instance,
|
|
301
|
+
addMessage(msg) { bridge.setMessages?.(prev => [...prev, msg]) },
|
|
302
|
+
startLoading() { bridge.setIsLoading?.(true); bridge.setStreamText?.('') },
|
|
303
|
+
stopLoading() { bridge.setIsLoading?.(false) },
|
|
304
|
+
updateStream(text) { bridge.setStreamText?.(text) },
|
|
305
|
+
clearStream() { bridge.setStreamText?.('') },
|
|
306
|
+
addSystemMessage(text) { bridge.setMessages?.(prev => [...prev, { role: 'system', text }]) },
|
|
307
|
+
addToolStart(toolName) { bridge.setMessages?.(prev => [...prev, { role: 'tool_start', toolName }]) },
|
|
308
|
+
addToolResult(toolName, text, isError) { bridge.setMessages?.(prev => [...prev, { role: 'tool_result', toolName, text, isError }]) },
|
|
309
|
+
addAssistantMessage(text) { bridge.setMessages?.(prev => [...prev, { role: 'assistant', text }]) },
|
|
310
|
+
askUser(question) { return new Promise(resolve => { bridge.setAskState?.({ question, resolve: (ans) => { bridge.setMessages?.(prev => [...prev, { role: 'system', text: `${question} ${ans}` }]); resolve(ans) } }) }) },
|
|
311
|
+
exit() { bridge.exit?.() }
|
|
312
|
+
}
|
|
313
|
+
}
|
package/lib/auth.mjs
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import http from 'node:http'
|
|
4
|
+
import { randomBytes, createHash } from 'node:crypto'
|
|
5
|
+
import { spawnSync } from 'node:child_process'
|
|
6
|
+
import { secureAuthPath } from './paths.mjs'
|
|
7
|
+
|
|
8
|
+
function nonempty(s) {
|
|
9
|
+
if (typeof s !== 'string') return ''
|
|
10
|
+
const t = s.trim()
|
|
11
|
+
return t.length ? t : ''
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getAuthPreference() {
|
|
15
|
+
const v = nonempty(process.env.ONA_AUTH_PREFERENCE).toLowerCase()
|
|
16
|
+
if (v === 'subscription' || v === 'oauth' || v === 'bearer') return 'subscription'
|
|
17
|
+
if (v === 'api_key' || v === 'console' || v === 'key') return 'api_key'
|
|
18
|
+
return 'auto'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** §2.7 — Anthropic credential resolution (no secrets in SQLite). */
|
|
22
|
+
export function resolveAnthropicCredentials(ctx) {
|
|
23
|
+
const { bareMode = false, apiKeyHelper = null } = ctx
|
|
24
|
+
const pref = getAuthPreference()
|
|
25
|
+
|
|
26
|
+
if (bareMode) {
|
|
27
|
+
const key = nonempty(process.env.ANTHROPIC_API_KEY)
|
|
28
|
+
if (key) return { mode: 'api_key', secret: key, source: 'ANTHROPIC_API_KEY', bare: true }
|
|
29
|
+
if (apiKeyHelper) {
|
|
30
|
+
const h = runApiKeyHelper(apiKeyHelper)
|
|
31
|
+
if (h) return { mode: 'api_key', secret: h, source: 'apiKeyHelper', bare: true }
|
|
32
|
+
}
|
|
33
|
+
return { mode: 'none', source: 'none', bare: true }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const envBearer = envBearerWithSource()
|
|
37
|
+
const envKey = nonempty(process.env.ANTHROPIC_API_KEY)
|
|
38
|
+
const fileCred = readSecureAuthFile()
|
|
39
|
+
|
|
40
|
+
if (pref === 'subscription') {
|
|
41
|
+
if (envBearer.token) return { mode: 'bearer', secret: envBearer.token, source: envBearer.source, bare: false }
|
|
42
|
+
if (fileCred?.bearerToken) return { mode: 'bearer', secret: fileCred.bearerToken, source: 'secure_file_bearer', bare: false }
|
|
43
|
+
return { mode: 'none', source: 'none_subscription_preference', bare: false }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (pref === 'api_key') {
|
|
47
|
+
if (envKey) return { mode: 'api_key', secret: envKey, source: 'ANTHROPIC_API_KEY', bare: false }
|
|
48
|
+
if (fileCred?.apiKey) return { mode: 'api_key', secret: fileCred.apiKey, source: 'secure_file_api_key', bare: false }
|
|
49
|
+
if (apiKeyHelper) {
|
|
50
|
+
const h = runApiKeyHelper(apiKeyHelper)
|
|
51
|
+
if (h) return { mode: 'api_key', secret: h, source: 'apiKeyHelper', bare: false }
|
|
52
|
+
}
|
|
53
|
+
return { mode: 'none', source: 'none_api_key_preference', bare: false }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// auto: bearer env → API key env → secure file bearer → secure file API key → claude code keychain → helper
|
|
57
|
+
if (envBearer.token) return { mode: 'bearer', secret: envBearer.token, source: envBearer.source, bare: false }
|
|
58
|
+
if (envKey) return { mode: 'api_key', secret: envKey, source: 'ANTHROPIC_API_KEY', bare: false }
|
|
59
|
+
if (fileCred?.bearerToken) return { mode: 'bearer', secret: fileCred.bearerToken, source: 'secure_file_bearer', bare: false }
|
|
60
|
+
if (fileCred?.apiKey) return { mode: 'api_key', secret: fileCred.apiKey, source: 'secure_file_api_key', bare: false }
|
|
61
|
+
// Try Claude Code's existing OAuth credentials (keychain or plaintext)
|
|
62
|
+
const ccToken = readClaudeCodeOAuthToken()
|
|
63
|
+
if (ccToken) return { mode: 'bearer', secret: ccToken, source: 'claude_code_oauth', bare: false }
|
|
64
|
+
if (apiKeyHelper) {
|
|
65
|
+
const h = runApiKeyHelper(apiKeyHelper)
|
|
66
|
+
if (h) return { mode: 'api_key', secret: h, source: 'apiKeyHelper', bare: false }
|
|
67
|
+
}
|
|
68
|
+
return { mode: 'none', source: 'none', bare: false }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function envBearerWithSource() {
|
|
72
|
+
const a = nonempty(process.env.ANTHROPIC_AUTH_TOKEN)
|
|
73
|
+
if (a) return { token: a, source: 'ANTHROPIC_AUTH_TOKEN' }
|
|
74
|
+
const c = nonempty(process.env.CLAUDE_CODE_OAUTH_TOKEN)
|
|
75
|
+
if (c) return { token: c, source: 'CLAUDE_CODE_OAUTH_TOKEN' }
|
|
76
|
+
return { token: '', source: '' }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function authStatusSummary(ctx) {
|
|
80
|
+
const pref = getAuthPreference()
|
|
81
|
+
const c = resolveAnthropicCredentials(ctx)
|
|
82
|
+
const also = computeAlsoConfiguredHints(c, pref)
|
|
83
|
+
if (c.mode === 'none') {
|
|
84
|
+
return { ok: false, kind: 'none', source: c.source, preference: pref, oauth_beta_header: false, alsoConfigured: also }
|
|
85
|
+
}
|
|
86
|
+
const subscriptionStyle = c.mode === 'bearer'
|
|
87
|
+
return { ok: true, kind: subscriptionStyle ? 'oauth_bearer' : 'api_key', source: c.source, preference: pref, oauth_beta_header: subscriptionStyle, alsoConfigured: also }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function computeAlsoConfiguredHints(active, pref) {
|
|
91
|
+
const envBearer = !!(nonempty(process.env.ANTHROPIC_AUTH_TOKEN) || nonempty(process.env.CLAUDE_CODE_OAUTH_TOKEN))
|
|
92
|
+
const envKey = !!nonempty(process.env.ANTHROPIC_API_KEY)
|
|
93
|
+
const file = readSecureAuthFile()
|
|
94
|
+
const fileB = !!file?.bearerToken
|
|
95
|
+
const fileK = !!file?.apiKey
|
|
96
|
+
const ignored = []
|
|
97
|
+
if (active.mode === 'bearer') {
|
|
98
|
+
if (envKey) ignored.push('ANTHROPIC_API_KEY in env (export ONA_AUTH_PREFERENCE=api_key to prefer it)')
|
|
99
|
+
if (fileK) ignored.push('apiKey in ~/.ona/secure (not used while bearer wins)')
|
|
100
|
+
}
|
|
101
|
+
if (active.mode === 'api_key') {
|
|
102
|
+
if (envBearer) ignored.push('bearer env vars present but not chosen')
|
|
103
|
+
if (fileB) ignored.push('bearer in ~/.ona/secure (not used while API key wins)')
|
|
104
|
+
}
|
|
105
|
+
if (active.mode === 'none') {
|
|
106
|
+
if (pref === 'subscription' && (envKey || fileK)) ignored.push('API key present but ONA_AUTH_PREFERENCE=subscription requires a bearer token')
|
|
107
|
+
if (pref === 'api_key' && (envBearer || fileB)) ignored.push('Bearer token present but ONA_AUTH_PREFERENCE=api_key ignores it')
|
|
108
|
+
}
|
|
109
|
+
return { envBearer, envApiKey: envKey, secureFileBearer: fileB, secureFileApiKey: fileK, ignoredHints: ignored }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function saveSecureCredentials({ apiKey, bearerToken, zhipuApiKey }) {
|
|
113
|
+
const p = secureAuthPath()
|
|
114
|
+
const prev = readSecureAuthFile() || {}
|
|
115
|
+
const next = { ...prev }
|
|
116
|
+
if (apiKey !== undefined) { if (apiKey === '') delete next.apiKey; else next.apiKey = apiKey }
|
|
117
|
+
if (bearerToken !== undefined) { if (bearerToken === '') delete next.bearerToken; else next.bearerToken = bearerToken }
|
|
118
|
+
if (zhipuApiKey !== undefined) { if (zhipuApiKey === '') delete next.zhipuApiKey; else next.zhipuApiKey = zhipuApiKey }
|
|
119
|
+
fs.writeFileSync(p, JSON.stringify(next, null, 0), { mode: 0o600 })
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function resolveZhipuApiKey() {
|
|
123
|
+
const fromEnv = nonempty(process.env.ZAI_API_KEY)
|
|
124
|
+
if (fromEnv) return fromEnv
|
|
125
|
+
const file = readSecureAuthFile()
|
|
126
|
+
return nonempty(file?.zhipuApiKey) || ''
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function clearSecureCredentials() {
|
|
130
|
+
try { fs.unlinkSync(secureAuthPath()) } catch { /* ignore */ }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function readSecureAuthFile() {
|
|
134
|
+
try {
|
|
135
|
+
const raw = fs.readFileSync(secureAuthPath(), 'utf8')
|
|
136
|
+
const j = JSON.parse(raw)
|
|
137
|
+
return { apiKey: nonempty(j.apiKey), bearerToken: nonempty(j.bearerToken), zhipuApiKey: nonempty(j.zhipuApiKey) }
|
|
138
|
+
} catch { return null }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Read OAuth token from Claude Code's secure storage (keychain or ~/.claude/.credentials.json). */
|
|
142
|
+
function readClaudeCodeOAuthToken() {
|
|
143
|
+
// Try macOS Keychain first
|
|
144
|
+
if (process.platform === 'darwin') {
|
|
145
|
+
try {
|
|
146
|
+
const username = nonempty(process.env.USER) || require('os').userInfo().username
|
|
147
|
+
const r = spawnSync('security', ['find-generic-password', '-a', username, '-w', '-s', 'Claude Code-credentials'], { encoding: 'utf8', timeout: 5000 })
|
|
148
|
+
if (!r.error && r.status === 0 && r.stdout) {
|
|
149
|
+
const data = JSON.parse(r.stdout.trim())
|
|
150
|
+
const token = nonempty(data?.claudeAiOauth?.accessToken)
|
|
151
|
+
if (token) return token
|
|
152
|
+
}
|
|
153
|
+
} catch { /* fall through to plaintext */ }
|
|
154
|
+
}
|
|
155
|
+
// Try plaintext fallback at ~/.claude/.credentials.json
|
|
156
|
+
try {
|
|
157
|
+
const home = process.env.CLAUDE_CONFIG_DIR || path.join(process.env.HOME || '', '.claude')
|
|
158
|
+
const raw = fs.readFileSync(path.join(home, '.credentials.json'), 'utf8')
|
|
159
|
+
const data = JSON.parse(raw)
|
|
160
|
+
return nonempty(data?.claudeAiOauth?.accessToken) || ''
|
|
161
|
+
} catch { return '' }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function runApiKeyHelper(cmd) {
|
|
165
|
+
if (!cmd || typeof cmd !== 'string') return ''
|
|
166
|
+
const r = spawnSync(cmd, { shell: true, encoding: 'utf8', timeout: 30_000 })
|
|
167
|
+
if (r.error || r.status !== 0) return ''
|
|
168
|
+
return nonempty(r.stdout || '')
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** §2.7 A3 — Browser PKCE OAuth flow. */
|
|
172
|
+
export async function interactiveOAuthLogin(io) {
|
|
173
|
+
const authUrl = process.env.ANTHROPIC_OAUTH_AUTHORIZATION_URL || 'https://console.anthropic.com/oauth/authorize'
|
|
174
|
+
const tokenUrl = process.env.ANTHROPIC_OAUTH_TOKEN_URL || 'https://console.anthropic.com/oauth/token'
|
|
175
|
+
const clientId = process.env.ANTHROPIC_OAUTH_CLIENT_ID || 'ona-sdlc-repl'
|
|
176
|
+
|
|
177
|
+
const verifier = randomBytes(32).toString('base64url')
|
|
178
|
+
const challenge = createHash('sha256').update(verifier).digest('base64url')
|
|
179
|
+
const state = randomBytes(16).toString('hex')
|
|
180
|
+
|
|
181
|
+
const server = http.createServer()
|
|
182
|
+
const port = await new Promise((resolve, reject) => {
|
|
183
|
+
server.listen(0, '127.0.0.1', () => resolve(server.address().port))
|
|
184
|
+
server.on('error', reject)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`
|
|
188
|
+
const params = new URLSearchParams({
|
|
189
|
+
response_type: 'code',
|
|
190
|
+
client_id: clientId,
|
|
191
|
+
redirect_uri: redirectUri,
|
|
192
|
+
scope: 'user:inference',
|
|
193
|
+
code_challenge: challenge,
|
|
194
|
+
code_challenge_method: 'S256',
|
|
195
|
+
state,
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
const fullUrl = `${authUrl}?${params}`
|
|
199
|
+
io.println(`Open this URL in your browser to authenticate:\n${fullUrl}`)
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
const { exec } = await import('node:child_process')
|
|
203
|
+
const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'
|
|
204
|
+
exec(`${openCmd} "${fullUrl}"`)
|
|
205
|
+
} catch { /* user will open manually */ }
|
|
206
|
+
|
|
207
|
+
io.println('Waiting for OAuth callback...')
|
|
208
|
+
|
|
209
|
+
const code = await new Promise((resolve, reject) => {
|
|
210
|
+
const timeout = setTimeout(() => { server.close(); reject(new Error('OAuth timeout (120s)')) }, 120_000)
|
|
211
|
+
server.on('request', (req, res) => {
|
|
212
|
+
const u = new URL(req.url, `http://127.0.0.1:${port}`)
|
|
213
|
+
if (u.pathname !== '/callback') { res.writeHead(404); res.end(); return }
|
|
214
|
+
const returnedState = u.searchParams.get('state')
|
|
215
|
+
const returnedCode = u.searchParams.get('code')
|
|
216
|
+
const err = u.searchParams.get('error')
|
|
217
|
+
if (err) {
|
|
218
|
+
res.writeHead(200, { 'Content-Type': 'text/html' })
|
|
219
|
+
res.end('<h1>Authentication failed</h1><p>You can close this tab.</p>')
|
|
220
|
+
clearTimeout(timeout); server.close(); reject(new Error(`OAuth error: ${err}`)); return
|
|
221
|
+
}
|
|
222
|
+
if (returnedState !== state) {
|
|
223
|
+
res.writeHead(400, { 'Content-Type': 'text/html' })
|
|
224
|
+
res.end('<h1>State mismatch</h1>'); clearTimeout(timeout); server.close(); reject(new Error('State mismatch')); return
|
|
225
|
+
}
|
|
226
|
+
res.writeHead(200, { 'Content-Type': 'text/html' })
|
|
227
|
+
res.end('<h1>Authentication successful</h1><p>You can close this tab and return to ona.</p>')
|
|
228
|
+
clearTimeout(timeout); server.close(); resolve(returnedCode)
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
const tokenResp = await fetch(tokenUrl, {
|
|
233
|
+
method: 'POST',
|
|
234
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
235
|
+
body: new URLSearchParams({
|
|
236
|
+
grant_type: 'authorization_code',
|
|
237
|
+
code,
|
|
238
|
+
redirect_uri: redirectUri,
|
|
239
|
+
client_id: clientId,
|
|
240
|
+
code_verifier: verifier,
|
|
241
|
+
}),
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
if (!tokenResp.ok) {
|
|
245
|
+
const txt = await tokenResp.text()
|
|
246
|
+
throw new Error(`Token exchange failed (${tokenResp.status}): ${txt.slice(0, 500)}`)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const tokenData = await tokenResp.json()
|
|
250
|
+
const accessToken = tokenData.access_token
|
|
251
|
+
if (!accessToken) throw new Error('No access_token in token response')
|
|
252
|
+
|
|
253
|
+
saveSecureCredentials({ bearerToken: accessToken })
|
|
254
|
+
io.println('OAuth login successful. Bearer token saved to secure storage.')
|
|
255
|
+
}
|