@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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSE.md +57 -0
  3. package/README.md +404 -2
  4. package/bin/agent.mjs +1260 -0
  5. package/bin/nehanda-ui.mjs +824 -0
  6. package/lib/anthropicHeaders.mjs +8 -0
  7. package/lib/app.mjs +313 -0
  8. package/lib/auth.mjs +255 -0
  9. package/lib/bashguard.mjs +159 -0
  10. package/lib/compact.mjs +306 -0
  11. package/lib/energy-middleware.mjs +318 -0
  12. package/lib/hookShapeOrder.mjs +30 -0
  13. package/lib/hookplane.mjs +228 -0
  14. package/lib/instructions.mjs +23 -0
  15. package/lib/jev_read.mjs +216 -0
  16. package/lib/jev_write.mjs +253 -0
  17. package/lib/laya.mjs +296 -0
  18. package/lib/laya_sidecar.mjs +198 -0
  19. package/lib/mcp-config.mjs +189 -0
  20. package/lib/mcp.mjs +169 -0
  21. package/lib/modelConfig.mjs +152 -0
  22. package/lib/modelDiscovery.mjs +205 -0
  23. package/lib/openaiCompat.mjs +147 -0
  24. package/lib/orchestrate.mjs +1163 -0
  25. package/lib/paths.mjs +20 -0
  26. package/lib/permissions.mjs +44 -0
  27. package/lib/scripts/__pycache__/laya_worker.cpython-314.pyc +0 -0
  28. package/lib/scripts/audit-code-integrity.py +437 -0
  29. package/lib/scripts/ensure-laya-env.sh +55 -0
  30. package/lib/scripts/js-safety-checker.cjs +308 -0
  31. package/lib/scripts/laya_worker.py +171 -0
  32. package/lib/scripts/odse-transform.py +97 -0
  33. package/lib/scripts/python-safety-checker.py +197 -0
  34. package/lib/scripts/shell-safety-checker.sh +306 -0
  35. package/lib/settings.mjs +147 -0
  36. package/lib/store.mjs +278 -0
  37. package/lib/team.mjs +69 -0
  38. package/lib/testgen.mjs +146 -0
  39. package/lib/tools/AuditCodeIntegrity.json +30 -0
  40. package/lib/tools/JsSafetyChecker.json +29 -0
  41. package/lib/tools/PythonSafetyChecker.json +29 -0
  42. package/lib/tools/ShellSafetyChecker.json +29 -0
  43. package/lib/tools.mjs +907 -0
  44. package/lib/transcript.mjs +223 -0
  45. package/lib/ui.mjs +286 -0
  46. package/lib/workflow.mjs +91 -0
  47. package/lib/worktree.mjs +56 -0
  48. package/package.json +50 -4
  49. package/schema.sql +181 -0
  50. package/scripts/postinstall.mjs +62 -0
  51. package/scripts/sdlc-acceptance.sh +477 -0
  52. package/scripts/verify-sdlc-hook-order.mjs +26 -0
  53. package/templates/test_e2e.sh +32 -0
  54. package/templates/test_file.sh +14 -0
  55. package/templates/test_hook.sh +27 -0
  56. package/templates/test_http.sh +17 -0
  57. package/templates/test_phase.sh +23 -0
  58. package/templates/test_process.sh +14 -0
  59. package/templates/test_tool.sh +24 -0
  60. package/templates/validate_test.sh +55 -0
@@ -0,0 +1,8 @@
1
+ export const ANTHROPIC_OAUTH_BETA = 'oauth-2025-04-20'
2
+
3
+ export function subscriptionStyleHeaders(bearerSecret) {
4
+ return {
5
+ Authorization: `Bearer ${bearerSecret}`,
6
+ 'anthropic-beta': ANTHROPIC_OAUTH_BETA,
7
+ }
8
+ }
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
+ }