@onyx-robotics/agent 0.1.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 +202 -0
- package/README.md +72 -0
- package/bin/onyx.ts +4 -0
- package/package.json +52 -0
- package/scripts/install.sh +115 -0
- package/skills/onyx/SKILL.md +150 -0
- package/src/commands/agent.ts +23 -0
- package/src/commands/branch.ts +96 -0
- package/src/commands/exp.ts +432 -0
- package/src/commands/listen.ts +327 -0
- package/src/commands/login.ts +198 -0
- package/src/commands/profile.ts +112 -0
- package/src/commands/sync.ts +88 -0
- package/src/install.test.ts +38 -0
- package/src/lib/api.ts +227 -0
- package/src/lib/args.ts +68 -0
- package/src/lib/config.ts +148 -0
- package/src/lib/events.ts +97 -0
- package/src/lib/git.ts +57 -0
- package/src/lib/history.ts +272 -0
- package/src/lib/login.ts +233 -0
- package/src/lib/markdown.ts +148 -0
- package/src/lib/metrics.ts +41 -0
- package/src/lib/outbox.ts +173 -0
- package/src/lib/process.ts +73 -0
- package/src/lib/project.ts +42 -0
- package/src/lib/skill-content.ts +1 -0
- package/src/lib/skill.ts +50 -0
- package/src/lib/sync.ts +294 -0
- package/src/lib/tui.ts +364 -0
- package/src/main.ts +84 -0
- package/src/onyx.test.ts +952 -0
- package/src/onyx.ts +92 -0
- package/src/profile.test.ts +472 -0
- package/src/protocol/index.ts +2 -0
- package/src/protocol/local-research.ts +152 -0
- package/src/protocol/research.ts +75 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { watch, type FSWatcher } from "node:fs"
|
|
2
|
+
import { stat } from "node:fs/promises"
|
|
3
|
+
import { basename } from "node:path"
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
LocalResearchEvent,
|
|
7
|
+
LocalResearchHistoryRecord,
|
|
8
|
+
} from "../protocol"
|
|
9
|
+
|
|
10
|
+
import { readEvents } from "../lib/events"
|
|
11
|
+
import {
|
|
12
|
+
currentBranch,
|
|
13
|
+
gitResult,
|
|
14
|
+
nameFromGitBranch,
|
|
15
|
+
repoRoot,
|
|
16
|
+
} from "../lib/git"
|
|
17
|
+
import { historyPath, readHistory } from "../lib/history"
|
|
18
|
+
import { onyxStateDir, readLastRun, readOutbox, readState } from "../lib/outbox"
|
|
19
|
+
import { branchStateKey } from "../lib/project"
|
|
20
|
+
import { formatAge, renderFrame, type ListenModel } from "../lib/tui"
|
|
21
|
+
|
|
22
|
+
const CSI = "\x1b["
|
|
23
|
+
const RENDER_INTERVAL_MS = 500
|
|
24
|
+
const RENDER_MIN_GAP_MS = 100
|
|
25
|
+
// Matches the spinner's frame duration in lib/tui.ts.
|
|
26
|
+
const SPINNER_REDRAW_MS = 120
|
|
27
|
+
// The session counts as live for 2 minutes after the last event/commit, or
|
|
28
|
+
// for the whole eval window while a run is in flight (default timeout 600s).
|
|
29
|
+
const ACTIVE_WINDOW_MS = 2 * 60_000
|
|
30
|
+
const EVAL_ACTIVE_WINDOW_MS = 10 * 60_000
|
|
31
|
+
// While idle, rebuild the model every Nth interval tick (2s instead of 500ms)
|
|
32
|
+
// to keep git spawns and file reads minimal on constrained hardware.
|
|
33
|
+
const IDLE_REBUILD_EVERY = 4
|
|
34
|
+
|
|
35
|
+
// history.jsonl is re-read (and Zod-parsed) only when its mtime/size changes;
|
|
36
|
+
// the parse of a long history is the most expensive part of a rebuild.
|
|
37
|
+
let historyCache: {
|
|
38
|
+
key: string
|
|
39
|
+
records: LocalResearchHistoryRecord[]
|
|
40
|
+
} | null = null
|
|
41
|
+
|
|
42
|
+
async function readHistoryCached(
|
|
43
|
+
root: string
|
|
44
|
+
): Promise<LocalResearchHistoryRecord[]> {
|
|
45
|
+
let key: string
|
|
46
|
+
try {
|
|
47
|
+
const stats = await stat(await historyPath(root))
|
|
48
|
+
key = `${stats.mtimeMs}:${stats.size}`
|
|
49
|
+
} catch {
|
|
50
|
+
historyCache = null
|
|
51
|
+
return []
|
|
52
|
+
}
|
|
53
|
+
if (historyCache?.key === key) return historyCache.records
|
|
54
|
+
const { records } = await readHistory(root)
|
|
55
|
+
historyCache = { key, records }
|
|
56
|
+
return records
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function describeEvent(event: LocalResearchEvent, nowMs: number) {
|
|
60
|
+
const sha = event.commitSha ? event.commitSha.slice(0, 7) : null
|
|
61
|
+
const labels: Record<LocalResearchEvent["type"], string> = {
|
|
62
|
+
branch_created: "branch created",
|
|
63
|
+
exp_run_started: "running eval",
|
|
64
|
+
eval_finished: "eval finished",
|
|
65
|
+
checks_finished: "checks",
|
|
66
|
+
run_finished: "measured",
|
|
67
|
+
exp_logged: "logged",
|
|
68
|
+
flush_finished: "sync",
|
|
69
|
+
pushed: "pushed",
|
|
70
|
+
}
|
|
71
|
+
const parts = [labels[event.type], sha, event.message].filter(Boolean)
|
|
72
|
+
return `${parts.join(" · ")} · ${formatAge(event.ts, nowMs)}`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Latest commit on HEAD as a live-activity candidate. */
|
|
76
|
+
async function headCommitInfo(root: string) {
|
|
77
|
+
const result = await gitResult(
|
|
78
|
+
["log", "-1", "--format=%H%x09%cI%x09%s"],
|
|
79
|
+
root
|
|
80
|
+
)
|
|
81
|
+
if (result.code !== 0) return null
|
|
82
|
+
const [sha, committedAt, subject] = result.stdout.trim().split("\t")
|
|
83
|
+
if (!sha) return null
|
|
84
|
+
return { sha, committedAt: committedAt ?? "", subject: subject ?? "" }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function buildModel(root: string): Promise<ListenModel> {
|
|
88
|
+
const state = await readState(root)
|
|
89
|
+
const gitBranch = await currentBranch(root).catch(() => "")
|
|
90
|
+
const branchName = gitBranch ? nameFromGitBranch(gitBranch) : null
|
|
91
|
+
|
|
92
|
+
const records = await readHistoryCached(root)
|
|
93
|
+
// Ascending — the most recent experiment renders at the bottom of the
|
|
94
|
+
// table. Copy before sorting/pushing so the cached array stays untouched.
|
|
95
|
+
const rows = (
|
|
96
|
+
branchName
|
|
97
|
+
? records.filter((record) => record.branchName === branchName)
|
|
98
|
+
: [...records]
|
|
99
|
+
).sort((a, b) =>
|
|
100
|
+
a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
// Surface a measured-but-unlogged run at the bottom of the table.
|
|
104
|
+
const lastRun = await readLastRun(root)
|
|
105
|
+
if (lastRun && !rows.some((row) => row.runRef === lastRun.runRef)) {
|
|
106
|
+
rows.push({
|
|
107
|
+
schemaVersion: 1,
|
|
108
|
+
source: "local",
|
|
109
|
+
branchName: lastRun.branchName,
|
|
110
|
+
runRef: lastRun.runRef,
|
|
111
|
+
commitSha: lastRun.commitSha,
|
|
112
|
+
status: lastRun.status,
|
|
113
|
+
name: `(unlogged) ${lastRun.commitSha.slice(0, 7)}`,
|
|
114
|
+
primaryMetricName: lastRun.primaryMetricName,
|
|
115
|
+
primaryMetricValue: lastRun.primaryMetricValue,
|
|
116
|
+
metrics: lastRun.metrics,
|
|
117
|
+
agentNotes: lastRun.agentNotes,
|
|
118
|
+
startedAt: lastRun.startedAt ?? null,
|
|
119
|
+
completedAt: lastRun.completedAt ?? null,
|
|
120
|
+
createdAt: lastRun.createdAt,
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const meta = branchName
|
|
125
|
+
? state.branches[branchStateKey(state.projectPath ?? "", branchName)]
|
|
126
|
+
: undefined
|
|
127
|
+
const metricName = meta?.metricName ?? rows[0]?.primaryMetricName ?? null
|
|
128
|
+
const metricDirection = meta?.metricDirection ?? "maximize"
|
|
129
|
+
const measured = rows.filter(
|
|
130
|
+
(row) =>
|
|
131
|
+
(row.status === "succeeded" || row.status === "accepted") &&
|
|
132
|
+
row.primaryMetricValue !== null
|
|
133
|
+
)
|
|
134
|
+
const bestValue = measured.length
|
|
135
|
+
? measured.reduce(
|
|
136
|
+
(best, row) =>
|
|
137
|
+
metricDirection === "minimize"
|
|
138
|
+
? Math.min(best, row.primaryMetricValue as number)
|
|
139
|
+
: Math.max(best, row.primaryMetricValue as number),
|
|
140
|
+
metricDirection === "minimize" ? Infinity : -Infinity
|
|
141
|
+
)
|
|
142
|
+
: null
|
|
143
|
+
|
|
144
|
+
// Activity: most recent of (latest CLI event, latest commit on HEAD).
|
|
145
|
+
const nowMs = Date.now()
|
|
146
|
+
const [latestEvent] = (await readEvents(root, { tail: 1 })).slice(-1)
|
|
147
|
+
const head = await headCommitInfo(root)
|
|
148
|
+
let activity: string | null = latestEvent
|
|
149
|
+
? describeEvent(latestEvent, nowMs)
|
|
150
|
+
: null
|
|
151
|
+
if (
|
|
152
|
+
head &&
|
|
153
|
+
(!latestEvent || Date.parse(head.committedAt) > Date.parse(latestEvent.ts))
|
|
154
|
+
) {
|
|
155
|
+
activity = `committed ${head.sha.slice(0, 7)} · ${head.subject} · ${formatAge(head.committedAt, nowMs)}`
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Live while an eval is in flight (exp_run_started without a newer event,
|
|
159
|
+
// bounded by the eval timeout) or anything happened in the last 2 minutes.
|
|
160
|
+
const eventMs = latestEvent ? Date.parse(latestEvent.ts) : Number.NaN
|
|
161
|
+
const commitMs = head ? Date.parse(head.committedAt) : Number.NaN
|
|
162
|
+
const lastActivityMs = Math.max(
|
|
163
|
+
Number.isFinite(eventMs) ? eventMs : 0,
|
|
164
|
+
Number.isFinite(commitMs) ? commitMs : 0
|
|
165
|
+
)
|
|
166
|
+
const evalInFlight =
|
|
167
|
+
latestEvent?.type === "exp_run_started" &&
|
|
168
|
+
Number.isFinite(eventMs) &&
|
|
169
|
+
nowMs - eventMs < EVAL_ACTIVE_WINDOW_MS
|
|
170
|
+
const active =
|
|
171
|
+
evalInFlight ||
|
|
172
|
+
(lastActivityMs > 0 && nowMs - lastActivityMs < ACTIVE_WINDOW_MS)
|
|
173
|
+
|
|
174
|
+
const { records: outboxRecords } = await readOutbox(root)
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
projectName: basename(root),
|
|
178
|
+
branchName: gitBranch || null,
|
|
179
|
+
metricName,
|
|
180
|
+
metricUnit: meta?.metricUnit ?? null,
|
|
181
|
+
metricDirection,
|
|
182
|
+
bestValue,
|
|
183
|
+
activity,
|
|
184
|
+
active,
|
|
185
|
+
rows,
|
|
186
|
+
pendingOutbox: outboxRecords.length,
|
|
187
|
+
syncedCount: records.filter((record) => record.source === "api").length,
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function frameText(lines: string[], live: boolean) {
|
|
192
|
+
if (!live) return `${lines.join("\n")}\n`
|
|
193
|
+
// Home the cursor, clear each line as it's rewritten, clear the remainder.
|
|
194
|
+
return `${CSI}H${lines.map((line) => `${line}${CSI}K`).join("\n")}\n${CSI}J`
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Live, read-only view of the current repo's research session: tails
|
|
199
|
+
* `.git/onyx/` (events, history, last-run, outbox) and polls git for new
|
|
200
|
+
* commits. With no TTY it prints a single snapshot and exits.
|
|
201
|
+
*/
|
|
202
|
+
export async function commandListen() {
|
|
203
|
+
const root = await repoRoot()
|
|
204
|
+
await onyxStateDir(root) // ensure .git/onyx exists so fs.watch can attach
|
|
205
|
+
|
|
206
|
+
const size = () => ({
|
|
207
|
+
columns: process.stdout.columns ?? 100,
|
|
208
|
+
rows: process.stdout.rows ?? 30,
|
|
209
|
+
nowMs: Date.now(),
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
213
|
+
const model = await buildModel(root)
|
|
214
|
+
process.stdout.write(
|
|
215
|
+
frameText(renderFrame(model, { ...size(), color: false }), false)
|
|
216
|
+
)
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let closed = false
|
|
221
|
+
let renderTimer: ReturnType<typeof setTimeout> | null = null
|
|
222
|
+
let lastRenderAt = 0
|
|
223
|
+
let interval: ReturnType<typeof setInterval> | null = null
|
|
224
|
+
let spinnerInterval: ReturnType<typeof setInterval> | null = null
|
|
225
|
+
let watcher: FSWatcher | null = null
|
|
226
|
+
let resolveQuit = () => {}
|
|
227
|
+
let cachedModel: ListenModel | null = null
|
|
228
|
+
let rebuilding = false
|
|
229
|
+
let lastFrame = ""
|
|
230
|
+
|
|
231
|
+
const draw = () => {
|
|
232
|
+
if (closed || !cachedModel) return
|
|
233
|
+
const frame = frameText(renderFrame(cachedModel, size()), true)
|
|
234
|
+
// Identical frames (idle, no age changes) skip the terminal write.
|
|
235
|
+
if (frame === lastFrame) return
|
|
236
|
+
lastFrame = frame
|
|
237
|
+
process.stdout.write(frame)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const render = async () => {
|
|
241
|
+
if (closed || rebuilding) return
|
|
242
|
+
rebuilding = true
|
|
243
|
+
try {
|
|
244
|
+
const model = await buildModel(root)
|
|
245
|
+
cachedModel = model
|
|
246
|
+
} finally {
|
|
247
|
+
rebuilding = false
|
|
248
|
+
}
|
|
249
|
+
draw()
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const requestRender = () => {
|
|
253
|
+
if (closed || renderTimer) return
|
|
254
|
+
const wait = Math.max(0, RENDER_MIN_GAP_MS - (Date.now() - lastRenderAt))
|
|
255
|
+
renderTimer = setTimeout(() => {
|
|
256
|
+
renderTimer = null
|
|
257
|
+
lastRenderAt = Date.now()
|
|
258
|
+
void render().catch(() => {})
|
|
259
|
+
}, wait)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const onKey = (key: string) => {
|
|
263
|
+
if (key === "q" || key === "Q" || key === "\x03") quit()
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const cleanup = () => {
|
|
267
|
+
if (closed) return
|
|
268
|
+
closed = true
|
|
269
|
+
if (renderTimer) clearTimeout(renderTimer)
|
|
270
|
+
if (interval) clearInterval(interval)
|
|
271
|
+
if (spinnerInterval) clearInterval(spinnerInterval)
|
|
272
|
+
watcher?.close()
|
|
273
|
+
process.stdin.off("data", onKey)
|
|
274
|
+
process.stdout.off("resize", requestRender)
|
|
275
|
+
process.off("SIGINT", quit)
|
|
276
|
+
process.off("SIGTERM", quit)
|
|
277
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false)
|
|
278
|
+
process.stdin.pause()
|
|
279
|
+
// Show the cursor and leave the alternate screen buffer.
|
|
280
|
+
process.stdout.write(`${CSI}?25h${CSI}?1049l`)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const quit = () => {
|
|
284
|
+
cleanup()
|
|
285
|
+
resolveQuit()
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Enter the alternate screen buffer and hide the cursor.
|
|
289
|
+
process.stdout.write(`${CSI}?1049h${CSI}?25l${CSI}2J`)
|
|
290
|
+
process.stdin.setRawMode(true)
|
|
291
|
+
process.stdin.resume()
|
|
292
|
+
process.stdin.setEncoding("utf8")
|
|
293
|
+
process.stdin.on("data", onKey)
|
|
294
|
+
process.stdout.on("resize", requestRender)
|
|
295
|
+
process.on("SIGINT", quit)
|
|
296
|
+
process.on("SIGTERM", quit)
|
|
297
|
+
process.on("exit", cleanup)
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
watcher = watch(await onyxStateDir(root), requestRender)
|
|
301
|
+
} catch {
|
|
302
|
+
// Interval polling still keeps the view fresh.
|
|
303
|
+
}
|
|
304
|
+
// While idle, rebuild less often — fs.watch still wakes the loop the
|
|
305
|
+
// moment an agent writes to .git/onyx/, so liveness returns instantly.
|
|
306
|
+
let idleTicks = 0
|
|
307
|
+
interval = setInterval(() => {
|
|
308
|
+
if (cachedModel && !cachedModel.active) {
|
|
309
|
+
idleTicks += 1
|
|
310
|
+
if (idleTicks % IDLE_REBUILD_EVERY !== 0) return
|
|
311
|
+
} else {
|
|
312
|
+
idleTicks = 0
|
|
313
|
+
}
|
|
314
|
+
requestRender()
|
|
315
|
+
}, RENDER_INTERVAL_MS)
|
|
316
|
+
// Fast tick redraws the cached model so the activity spinner animates
|
|
317
|
+
// without re-reading files or spawning git between data refreshes. Idle
|
|
318
|
+
// sessions skip it entirely (the static frame can't change between ticks).
|
|
319
|
+
spinnerInterval = setInterval(() => {
|
|
320
|
+
if (cachedModel?.active) draw()
|
|
321
|
+
}, SPINNER_REDRAW_MS)
|
|
322
|
+
requestRender()
|
|
323
|
+
|
|
324
|
+
await new Promise<void>((resolve) => {
|
|
325
|
+
resolveQuit = resolve
|
|
326
|
+
})
|
|
327
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
|
|
3
|
+
import { optionalFlag, type Args } from "../lib/args"
|
|
4
|
+
import {
|
|
5
|
+
apiBaseUrl,
|
|
6
|
+
normalizeProfileName,
|
|
7
|
+
readConfig,
|
|
8
|
+
type CliProfile,
|
|
9
|
+
type Config,
|
|
10
|
+
writeConfig,
|
|
11
|
+
} from "../lib/config"
|
|
12
|
+
import { openBrowser, waitForCliLogin, type CliLoginResult } from "../lib/login"
|
|
13
|
+
|
|
14
|
+
export type CliLoginProfileManifestEntry = {
|
|
15
|
+
profileName: string
|
|
16
|
+
teamId: string
|
|
17
|
+
apiUrl: string
|
|
18
|
+
apiKeyId?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function loginProfileManifest(
|
|
22
|
+
config: Config
|
|
23
|
+
): CliLoginProfileManifestEntry[] {
|
|
24
|
+
return Object.entries(config.profiles).map(([profileName, profile]) => ({
|
|
25
|
+
profileName,
|
|
26
|
+
teamId: profile.teamId,
|
|
27
|
+
apiUrl: profile.apiUrl,
|
|
28
|
+
...(profile.apiKeyId ? { apiKeyId: profile.apiKeyId } : {}),
|
|
29
|
+
}))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function encodeLoginProfileManifest(
|
|
33
|
+
profiles: CliLoginProfileManifestEntry[]
|
|
34
|
+
) {
|
|
35
|
+
return Buffer.from(JSON.stringify(profiles), "utf8").toString("base64url")
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function profileNameForTeam(teamName: string) {
|
|
39
|
+
const [firstWord = ""] = teamName.trim().split(/\s+/)
|
|
40
|
+
return normalizeProfileName(firstWord) || "team"
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function profileNameForLoginResult({
|
|
44
|
+
config,
|
|
45
|
+
apiUrl,
|
|
46
|
+
teamId,
|
|
47
|
+
teamName,
|
|
48
|
+
}: {
|
|
49
|
+
config: Config
|
|
50
|
+
apiUrl: string
|
|
51
|
+
teamId: string
|
|
52
|
+
teamName: string
|
|
53
|
+
}) {
|
|
54
|
+
const existing = Object.entries(config.profiles).find(
|
|
55
|
+
([, profile]) => profile.teamId === teamId && profile.apiUrl === apiUrl
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if (existing) {
|
|
59
|
+
return existing[0]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const baseName = profileNameForTeam(teamName)
|
|
63
|
+
let candidate = baseName
|
|
64
|
+
let suffix = 2
|
|
65
|
+
|
|
66
|
+
while (config.profiles[candidate]) {
|
|
67
|
+
candidate = `${baseName}-${suffix}`
|
|
68
|
+
suffix += 1
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return candidate
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function buildCliLoginUrl({
|
|
75
|
+
baseUrl,
|
|
76
|
+
redirectUri,
|
|
77
|
+
state,
|
|
78
|
+
profiles,
|
|
79
|
+
refresh,
|
|
80
|
+
}: {
|
|
81
|
+
baseUrl: string
|
|
82
|
+
redirectUri: string
|
|
83
|
+
state: string
|
|
84
|
+
profiles: CliLoginProfileManifestEntry[]
|
|
85
|
+
refresh: boolean
|
|
86
|
+
}) {
|
|
87
|
+
const loginUrl = new URL("/cli/login", baseUrl)
|
|
88
|
+
loginUrl.searchParams.set("redirect_uri", redirectUri)
|
|
89
|
+
loginUrl.searchParams.set("state", state)
|
|
90
|
+
loginUrl.searchParams.set("profiles", encodeLoginProfileManifest(profiles))
|
|
91
|
+
if (refresh) {
|
|
92
|
+
loginUrl.searchParams.set("refresh", "true")
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return loginUrl
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function storedProfileFromLoginResult({
|
|
99
|
+
apiUrl,
|
|
100
|
+
result,
|
|
101
|
+
}: {
|
|
102
|
+
apiUrl: string
|
|
103
|
+
result: CliLoginResult
|
|
104
|
+
}): CliProfile {
|
|
105
|
+
if (!result.apiKey) {
|
|
106
|
+
throw new Error("Login callback did not include an API key.")
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
apiUrl,
|
|
111
|
+
apiKey: result.apiKey,
|
|
112
|
+
...(result.apiKeyId ? { apiKeyId: result.apiKeyId } : {}),
|
|
113
|
+
teamId: result.teamId,
|
|
114
|
+
teamName: result.teamName,
|
|
115
|
+
updatedAt: new Date().toISOString(),
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function saveLoginProfile({
|
|
120
|
+
baseUrl,
|
|
121
|
+
result,
|
|
122
|
+
}: {
|
|
123
|
+
baseUrl: string
|
|
124
|
+
result: CliLoginResult
|
|
125
|
+
}) {
|
|
126
|
+
const config = await readConfig()
|
|
127
|
+
const apiUrl = result.apiUrl ?? baseUrl
|
|
128
|
+
const profileName = profileNameForLoginResult({
|
|
129
|
+
config,
|
|
130
|
+
apiUrl,
|
|
131
|
+
teamId: result.teamId,
|
|
132
|
+
teamName: result.teamName,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
if (result.alreadyConfigured) {
|
|
136
|
+
if (!config.profiles[profileName]) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
"Login selected an already configured team, but no matching local profile was found."
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
await writeConfig({
|
|
143
|
+
...config,
|
|
144
|
+
currentProfile: profileName,
|
|
145
|
+
})
|
|
146
|
+
return { profileName, alreadyConfigured: true }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
await writeConfig({
|
|
150
|
+
profiles: {
|
|
151
|
+
...config.profiles,
|
|
152
|
+
[profileName]: storedProfileFromLoginResult({ apiUrl, result }),
|
|
153
|
+
},
|
|
154
|
+
currentProfile: profileName,
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
return { profileName, alreadyConfigured: false }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function commandLogin(args: Args) {
|
|
161
|
+
const baseUrl = await apiBaseUrl(args, { allowDefault: true })
|
|
162
|
+
const port = Number(args.options.port ?? 8765)
|
|
163
|
+
const state = randomUUID()
|
|
164
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`
|
|
165
|
+
const config = await readConfig()
|
|
166
|
+
const loginUrl = buildCliLoginUrl({
|
|
167
|
+
baseUrl,
|
|
168
|
+
redirectUri,
|
|
169
|
+
state,
|
|
170
|
+
profiles: loginProfileManifest(config),
|
|
171
|
+
refresh: optionalFlag(args, "refresh"),
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
const loginPromise = waitForCliLogin({
|
|
175
|
+
port,
|
|
176
|
+
state,
|
|
177
|
+
timeoutMs: Number(args.options.timeout ?? 120_000),
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
if (optionalFlag(args, "print-url")) {
|
|
181
|
+
console.log(loginUrl.toString())
|
|
182
|
+
} else {
|
|
183
|
+
await openBrowser(loginUrl.toString())
|
|
184
|
+
console.log("Waiting for browser login...")
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const result = await loginPromise
|
|
188
|
+
const saved = await saveLoginProfile({ baseUrl, result })
|
|
189
|
+
if (saved.alreadyConfigured) {
|
|
190
|
+
console.log(
|
|
191
|
+
`Using existing profile ${saved.profileName} for ${result.teamName} (${result.teamId})`
|
|
192
|
+
)
|
|
193
|
+
} else {
|
|
194
|
+
console.log(
|
|
195
|
+
`Logged in profile ${saved.profileName} for ${result.teamName} (${result.teamId})`
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { Args } from "../lib/args"
|
|
2
|
+
import { normalizeProfileName, readConfig, writeConfig } from "../lib/config"
|
|
3
|
+
|
|
4
|
+
const API_KEY_ENV_NAME = /^[A-Z_][A-Z0-9_]*$/
|
|
5
|
+
|
|
6
|
+
export async function commandProfile(args: Args) {
|
|
7
|
+
const sub = args.positional[1]
|
|
8
|
+
|
|
9
|
+
if (sub === "list") {
|
|
10
|
+
return commandProfileList()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (sub === "use") {
|
|
14
|
+
return commandProfileUse(args)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (sub === "set-api-key-env") {
|
|
18
|
+
return commandProfileSetApiKeyEnv(args)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
throw new Error(
|
|
22
|
+
"Usage: onyx profile list | onyx profile use <name> | onyx profile set-api-key-env <name> <ENV_VAR>"
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function commandProfileList() {
|
|
27
|
+
const config = await readConfig()
|
|
28
|
+
const entries = Object.entries(config.profiles).sort(([left], [right]) =>
|
|
29
|
+
left.localeCompare(right)
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if (entries.length === 0) {
|
|
33
|
+
console.log("No profiles. Run `onyx login`.")
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (const [name, profile] of entries) {
|
|
38
|
+
const marker = name === config.currentProfile ? "*" : " "
|
|
39
|
+
const credentialSource = profile.apiKeyEnv
|
|
40
|
+
? `env:${profile.apiKeyEnv} (${
|
|
41
|
+
process.env[profile.apiKeyEnv]?.trim() ? "set" : "missing"
|
|
42
|
+
})`
|
|
43
|
+
: profile.apiKey
|
|
44
|
+
? "stored key"
|
|
45
|
+
: "missing key"
|
|
46
|
+
console.log(
|
|
47
|
+
`${marker} ${name}\t${profile.teamName}\t${profile.teamId}\t${profile.apiUrl}\t${credentialSource}`
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function commandProfileUse(args: Args) {
|
|
53
|
+
const requested = args.positional[2]
|
|
54
|
+
if (!requested) {
|
|
55
|
+
throw new Error("Usage: onyx profile use <name>")
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const name = normalizeProfileName(requested)
|
|
59
|
+
if (!name) {
|
|
60
|
+
throw new Error("Profile name must contain at least one letter or number")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const config = await readConfig()
|
|
64
|
+
if (!config.profiles[name]) {
|
|
65
|
+
throw new Error(`Unknown Onyx CLI profile "${name}".`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await writeConfig({
|
|
69
|
+
...config,
|
|
70
|
+
currentProfile: name,
|
|
71
|
+
})
|
|
72
|
+
console.log(`Using profile ${name}`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function commandProfileSetApiKeyEnv(args: Args) {
|
|
76
|
+
const requested = args.positional[2]
|
|
77
|
+
const envVarName = args.positional[3]
|
|
78
|
+
if (!requested || !envVarName) {
|
|
79
|
+
throw new Error("Usage: onyx profile set-api-key-env <name> <ENV_VAR>")
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const name = normalizeProfileName(requested)
|
|
83
|
+
if (!name) {
|
|
84
|
+
throw new Error("Profile name must contain at least one letter or number")
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!API_KEY_ENV_NAME.test(envVarName)) {
|
|
88
|
+
throw new Error("Environment variable name must match /^[A-Z_][A-Z0-9_]*$/")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const config = await readConfig()
|
|
92
|
+
const profile = config.profiles[name]
|
|
93
|
+
if (!profile) {
|
|
94
|
+
throw new Error(`Unknown Onyx CLI profile "${name}".`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const nextProfile = {
|
|
98
|
+
...profile,
|
|
99
|
+
apiKeyEnv: envVarName,
|
|
100
|
+
updatedAt: new Date().toISOString(),
|
|
101
|
+
}
|
|
102
|
+
delete nextProfile.apiKey
|
|
103
|
+
|
|
104
|
+
await writeConfig({
|
|
105
|
+
...config,
|
|
106
|
+
profiles: {
|
|
107
|
+
...config.profiles,
|
|
108
|
+
[name]: nextProfile,
|
|
109
|
+
},
|
|
110
|
+
})
|
|
111
|
+
console.log(`Profile ${name} now reads its API key from ${envVarName}`)
|
|
112
|
+
}
|