@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,88 @@
|
|
|
1
|
+
import type { Args } from "../lib/args"
|
|
2
|
+
import { requestProjectSync, resolveProject } from "../lib/api"
|
|
3
|
+
import { emitEvent } from "../lib/events"
|
|
4
|
+
import { currentBranch, pushBranch, repoRoot } from "../lib/git"
|
|
5
|
+
import { hydrateHistoryFromApi } from "../lib/history"
|
|
6
|
+
import { readLastRun, readOutbox } from "../lib/outbox"
|
|
7
|
+
import { resolveProjectPath } from "../lib/project"
|
|
8
|
+
import { flushOutbox } from "../lib/sync"
|
|
9
|
+
|
|
10
|
+
export async function commandPush(args: Args) {
|
|
11
|
+
const root = await repoRoot()
|
|
12
|
+
const branchName = await currentBranch(root)
|
|
13
|
+
if (!branchName) throw new Error("Cannot push from a detached HEAD.")
|
|
14
|
+
await pushBranch(root, branchName)
|
|
15
|
+
await emitEvent(root, { type: "pushed", message: branchName })
|
|
16
|
+
console.log(`Pushed ${branchName}`)
|
|
17
|
+
await flushOutbox(root, args)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function commandSync(args: Args) {
|
|
21
|
+
const root = await repoRoot()
|
|
22
|
+
const result = await flushOutbox(root, args)
|
|
23
|
+
if (result.offline) return
|
|
24
|
+
|
|
25
|
+
// Refresh the local history cache to the canonical API state. Runs even
|
|
26
|
+
// when the outbox was empty so fresh clones pick up cross-branch history.
|
|
27
|
+
try {
|
|
28
|
+
const hydrated = await hydrateHistoryFromApi(root, args)
|
|
29
|
+
console.log(
|
|
30
|
+
`history: ${hydrated.experiments} experiment(s) across ${hydrated.branches} branch(es)${
|
|
31
|
+
hydrated.pendingLocal ? `, ${hydrated.pendingLocal} pending local` : ""
|
|
32
|
+
}`
|
|
33
|
+
)
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.warn(
|
|
36
|
+
`History refresh skipped: ${
|
|
37
|
+
error instanceof Error ? error.message : String(error)
|
|
38
|
+
}`
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Warm the server mirror so file/diff views stay fast.
|
|
43
|
+
try {
|
|
44
|
+
const project = await resolveProject(root, args)
|
|
45
|
+
await requestProjectSync(project.id, args)
|
|
46
|
+
console.log(`Requested mirror refresh for project ${project.id}`)
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.warn(
|
|
49
|
+
`Mirror refresh skipped: ${
|
|
50
|
+
error instanceof Error ? error.message : String(error)
|
|
51
|
+
}`
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function commandStatus(args: Args) {
|
|
57
|
+
const root = await repoRoot()
|
|
58
|
+
const projectPath = await resolveProjectPath(root, args)
|
|
59
|
+
const branchName = await currentBranch(root)
|
|
60
|
+
const { records, corrupt } = await readOutbox(root)
|
|
61
|
+
const lastRun = await readLastRun(root)
|
|
62
|
+
const experiments = records.filter(
|
|
63
|
+
(record) => record.type === "experiment_logged"
|
|
64
|
+
).length
|
|
65
|
+
const branches = records.filter(
|
|
66
|
+
(record) => record.type === "branch_started"
|
|
67
|
+
).length
|
|
68
|
+
|
|
69
|
+
console.log(`branch: ${branchName || "(detached)"}`)
|
|
70
|
+
console.log(`projectPath: ${projectPath || "(repo root)"}`)
|
|
71
|
+
console.log(
|
|
72
|
+
`outbox: ${experiments} experiment(s), ${branches} branch(es) pending${
|
|
73
|
+
corrupt ? `, ${corrupt} unreadable` : ""
|
|
74
|
+
}`
|
|
75
|
+
)
|
|
76
|
+
if (lastRun) {
|
|
77
|
+
console.log(
|
|
78
|
+
`last run: ${lastRun.commitSha.slice(0, 7)} (${lastRun.status}, ${lastRun.primaryMetricName}=${lastRun.primaryMetricValue ?? "null"})`
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const project = await resolveProject(root, args)
|
|
84
|
+
console.log(`project: ${project.name} (${project.id})`)
|
|
85
|
+
} catch {
|
|
86
|
+
console.log("project: not linked / offline")
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
|
|
3
|
+
async function dryRun(os: string, arch: string) {
|
|
4
|
+
const process = Bun.spawn(["sh", "scripts/install.sh"], {
|
|
5
|
+
cwd: import.meta.dir + "/..",
|
|
6
|
+
env: {
|
|
7
|
+
...Bun.env,
|
|
8
|
+
ONYX_INSTALL_DRY_RUN: "1",
|
|
9
|
+
ONYX_INSTALL_OS: os,
|
|
10
|
+
ONYX_INSTALL_ARCH: arch,
|
|
11
|
+
},
|
|
12
|
+
stdout: "pipe",
|
|
13
|
+
stderr: "pipe",
|
|
14
|
+
})
|
|
15
|
+
const stdout = await new Response(process.stdout).text()
|
|
16
|
+
const stderr = await new Response(process.stderr).text()
|
|
17
|
+
const code = await process.exited
|
|
18
|
+
if (code !== 0) {
|
|
19
|
+
throw new Error(stderr || stdout)
|
|
20
|
+
}
|
|
21
|
+
return stdout
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("install script", () => {
|
|
25
|
+
test("maps macOS arm64 to the darwin arm64 asset", async () => {
|
|
26
|
+
const output = await dryRun("Darwin", "arm64")
|
|
27
|
+
|
|
28
|
+
expect(output).toContain("target=darwin-arm64")
|
|
29
|
+
expect(output).toContain("asset=onyx-darwin-arm64")
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test("maps Linux x64 to the baseline asset", async () => {
|
|
33
|
+
const output = await dryRun("Linux", "x86_64")
|
|
34
|
+
|
|
35
|
+
expect(output).toContain("target=linux-x64-baseline")
|
|
36
|
+
expect(output).toContain("asset=onyx-linux-x64-baseline")
|
|
37
|
+
})
|
|
38
|
+
})
|
package/src/lib/api.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CreateResearchExperimentRequest,
|
|
3
|
+
CreateResearchBranchRequest,
|
|
4
|
+
} from "../protocol"
|
|
5
|
+
|
|
6
|
+
import type { Args } from "./args"
|
|
7
|
+
import { apiBaseUrl, apiKey } from "./config"
|
|
8
|
+
import { normalizeRepositoryUrl, repositoryUrl } from "./git"
|
|
9
|
+
import { resolveProjectPath } from "./project"
|
|
10
|
+
|
|
11
|
+
export type ApiProject = {
|
|
12
|
+
id: string
|
|
13
|
+
name: string
|
|
14
|
+
repositoryUrl: string
|
|
15
|
+
repositoryFullName: string | null
|
|
16
|
+
defaultBranch: string
|
|
17
|
+
projectPath: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type ApiBranch = {
|
|
21
|
+
id: string
|
|
22
|
+
name: string
|
|
23
|
+
gitBranchName: string | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type ApiExperiment = {
|
|
27
|
+
id: string
|
|
28
|
+
sequenceNumber: number
|
|
29
|
+
runRef: string
|
|
30
|
+
commitSha: string
|
|
31
|
+
status: string
|
|
32
|
+
primaryMetricName: string
|
|
33
|
+
primaryMetricValue: number | null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type ApiTreeExperiment = ApiExperiment & {
|
|
37
|
+
branchId: string
|
|
38
|
+
name: string
|
|
39
|
+
description: string | null
|
|
40
|
+
secondaryMetrics: Record<string, unknown>
|
|
41
|
+
agentNotes: Record<string, unknown>
|
|
42
|
+
checks: {
|
|
43
|
+
status: "passed" | "failed" | "timed_out"
|
|
44
|
+
durationMs: number | null
|
|
45
|
+
outputSummary: string | null
|
|
46
|
+
} | null
|
|
47
|
+
durationMs: number | null
|
|
48
|
+
outputSummary: string | null
|
|
49
|
+
startedAt: string | null
|
|
50
|
+
completedAt: string | null
|
|
51
|
+
createdAt: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type ApiTreeBranch = ApiBranch & {
|
|
55
|
+
experiments: ApiTreeExperiment[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type ApiProjectTree = {
|
|
59
|
+
project: ApiProject
|
|
60
|
+
branches: ApiTreeBranch[]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function callApi(
|
|
64
|
+
method: string,
|
|
65
|
+
path: string,
|
|
66
|
+
body?: unknown,
|
|
67
|
+
args?: Args
|
|
68
|
+
) {
|
|
69
|
+
const response = await fetch(`${await apiBaseUrl(args)}${path}`, {
|
|
70
|
+
method,
|
|
71
|
+
headers: {
|
|
72
|
+
"content-type": "application/json",
|
|
73
|
+
authorization: `Bearer ${await apiKey(args)}`,
|
|
74
|
+
},
|
|
75
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const text = await response.text()
|
|
79
|
+
let payload: unknown = null
|
|
80
|
+
try {
|
|
81
|
+
payload = text ? JSON.parse(text) : null
|
|
82
|
+
} catch {
|
|
83
|
+
payload = text
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
throw new ApiError(method, path, response.status, payload)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return payload
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class ApiError extends Error {
|
|
94
|
+
status: number
|
|
95
|
+
|
|
96
|
+
constructor(method: string, path: string, status: number, payload: unknown) {
|
|
97
|
+
super(
|
|
98
|
+
`${method} ${path} failed (${status}): ${
|
|
99
|
+
typeof payload === "string" ? payload : JSON.stringify(payload)
|
|
100
|
+
}`
|
|
101
|
+
)
|
|
102
|
+
this.name = "ApiError"
|
|
103
|
+
this.status = status
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function apiData<T>(payload: unknown): T {
|
|
108
|
+
if (!payload || typeof payload !== "object" || !("data" in payload)) {
|
|
109
|
+
throw new Error(`Unexpected API response: ${JSON.stringify(payload)}`)
|
|
110
|
+
}
|
|
111
|
+
return (payload as { data: T }).data
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Resolves the linked Onyx project for this repository by matching the origin
|
|
116
|
+
* URL + projectPath against the team's projects (or an explicit --project id).
|
|
117
|
+
* Throws when nothing matches so callers can keep work queued in the outbox.
|
|
118
|
+
*/
|
|
119
|
+
export async function resolveProject(
|
|
120
|
+
root: string,
|
|
121
|
+
args: Args
|
|
122
|
+
): Promise<ApiProject> {
|
|
123
|
+
const projectPath = await resolveProjectPath(root, args)
|
|
124
|
+
const projects = apiData<ApiProject[]>(
|
|
125
|
+
await callApi("GET", "/api/v1/research/projects", undefined, args)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if (args.options.project) {
|
|
129
|
+
const byId = projects.find(
|
|
130
|
+
(candidate) => candidate.id === args.options.project
|
|
131
|
+
)
|
|
132
|
+
if (byId) return byId
|
|
133
|
+
return {
|
|
134
|
+
id: args.options.project,
|
|
135
|
+
name: args.options.project,
|
|
136
|
+
repositoryUrl: "",
|
|
137
|
+
repositoryFullName: null,
|
|
138
|
+
defaultBranch: "main",
|
|
139
|
+
projectPath,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const url = normalizeRepositoryUrl(
|
|
144
|
+
await repositoryUrl(root, args.options["repository-url"])
|
|
145
|
+
)
|
|
146
|
+
const project = projects.find(
|
|
147
|
+
(candidate) =>
|
|
148
|
+
normalizeRepositoryUrl(candidate.repositoryUrl) === url &&
|
|
149
|
+
candidate.projectPath === projectPath
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
if (!project) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
"No linked Onyx project matched this repository. Link the repo in Onyx first."
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return project
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function listProjectBranches(
|
|
162
|
+
projectId: string,
|
|
163
|
+
args?: Args
|
|
164
|
+
): Promise<ApiBranch[]> {
|
|
165
|
+
return apiData<ApiBranch[]>(
|
|
166
|
+
await callApi(
|
|
167
|
+
"GET",
|
|
168
|
+
`/api/v1/research/projects/${projectId}/branches`,
|
|
169
|
+
undefined,
|
|
170
|
+
args
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Full project hierarchy (branches + experiments) for history hydration. */
|
|
176
|
+
export async function getProjectTree(
|
|
177
|
+
projectId: string,
|
|
178
|
+
args?: Args
|
|
179
|
+
): Promise<ApiProjectTree> {
|
|
180
|
+
return apiData<ApiProjectTree>(
|
|
181
|
+
await callApi(
|
|
182
|
+
"GET",
|
|
183
|
+
`/api/v1/research/projects/${projectId}/tree`,
|
|
184
|
+
undefined,
|
|
185
|
+
args
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function upsertBranch(
|
|
191
|
+
projectId: string,
|
|
192
|
+
body: CreateResearchBranchRequest,
|
|
193
|
+
args?: Args
|
|
194
|
+
): Promise<ApiBranch> {
|
|
195
|
+
return apiData<ApiBranch>(
|
|
196
|
+
await callApi(
|
|
197
|
+
"POST",
|
|
198
|
+
`/api/v1/research/projects/${projectId}/branches`,
|
|
199
|
+
body,
|
|
200
|
+
args
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function reportExperiment(
|
|
206
|
+
branchId: string,
|
|
207
|
+
body: CreateResearchExperimentRequest,
|
|
208
|
+
args?: Args
|
|
209
|
+
): Promise<ApiExperiment> {
|
|
210
|
+
return apiData<ApiExperiment>(
|
|
211
|
+
await callApi(
|
|
212
|
+
"POST",
|
|
213
|
+
`/api/v1/research/branches/${branchId}/experiments`,
|
|
214
|
+
body,
|
|
215
|
+
args
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function requestProjectSync(projectId: string, args?: Args) {
|
|
221
|
+
await callApi(
|
|
222
|
+
"POST",
|
|
223
|
+
`/api/v1/research/projects/${projectId}/sync`,
|
|
224
|
+
undefined,
|
|
225
|
+
args
|
|
226
|
+
)
|
|
227
|
+
}
|
package/src/lib/args.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export type Args = {
|
|
2
|
+
positional: string[]
|
|
3
|
+
options: Record<string, string>
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function parseArgs(argv: string[]): Args {
|
|
7
|
+
const positional: string[] = []
|
|
8
|
+
const options: Record<string, string> = {}
|
|
9
|
+
|
|
10
|
+
for (let i = 0; i < argv.length; i++) {
|
|
11
|
+
const token = argv[i]!
|
|
12
|
+
if (!token.startsWith("--")) {
|
|
13
|
+
positional.push(token)
|
|
14
|
+
continue
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const key = token.slice(2)
|
|
18
|
+
const eq = key.indexOf("=")
|
|
19
|
+
|
|
20
|
+
if (eq !== -1) {
|
|
21
|
+
options[key.slice(0, eq)] = key.slice(eq + 1)
|
|
22
|
+
continue
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const next = argv[i + 1]
|
|
26
|
+
if (next === undefined || next.startsWith("--")) {
|
|
27
|
+
options[key] = "true"
|
|
28
|
+
} else {
|
|
29
|
+
options[key] = next
|
|
30
|
+
i += 1
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { positional, options }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function requireOption(args: Args, name: string): string {
|
|
38
|
+
const value = args.options[name]
|
|
39
|
+
if (!value) {
|
|
40
|
+
throw new Error(`Missing required --${name}`)
|
|
41
|
+
}
|
|
42
|
+
return value
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function optionalFlag(args: Args, name: string) {
|
|
46
|
+
return args.options[name] === "true"
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function normalizeName(value: string) {
|
|
50
|
+
return value
|
|
51
|
+
.toLowerCase()
|
|
52
|
+
.trim()
|
|
53
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
54
|
+
.replace(/^-+|-+$/g, "")
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function nameOption(args: Args) {
|
|
58
|
+
const value = args.options.name
|
|
59
|
+
if (!value) throw new Error("Missing required --name")
|
|
60
|
+
const name = normalizeName(value)
|
|
61
|
+
if (!name)
|
|
62
|
+
throw new Error("--name must contain at least one letter or number")
|
|
63
|
+
return name
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function descriptionOption(args: Args): string | null {
|
|
67
|
+
return args.options.description ?? null
|
|
68
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises"
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
|
|
5
|
+
import type { Args } from "./args"
|
|
6
|
+
|
|
7
|
+
const CONFIG_FILE = "config.json"
|
|
8
|
+
export const DEFAULT_API_URL = "https://app.onyxresearch.ai"
|
|
9
|
+
|
|
10
|
+
export type CliProfile = {
|
|
11
|
+
apiUrl: string
|
|
12
|
+
apiKey?: string
|
|
13
|
+
apiKeyId?: string
|
|
14
|
+
apiKeyEnv?: string
|
|
15
|
+
teamId: string
|
|
16
|
+
teamName: string
|
|
17
|
+
updatedAt: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type Config = {
|
|
21
|
+
profiles: Record<string, CliProfile>
|
|
22
|
+
currentProfile: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function emptyConfig(): Config {
|
|
26
|
+
return {
|
|
27
|
+
profiles: {},
|
|
28
|
+
currentProfile: "",
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function configDir() {
|
|
33
|
+
return process.env.XDG_CONFIG_HOME
|
|
34
|
+
? join(process.env.XDG_CONFIG_HOME, "onyx")
|
|
35
|
+
: join(homedir(), ".config", "onyx")
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function configPath() {
|
|
39
|
+
return join(configDir(), CONFIG_FILE)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function readConfig(): Promise<Config> {
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(
|
|
45
|
+
await readFile(configPath(), "utf8")
|
|
46
|
+
) as Partial<Config>
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
profiles: parsed.profiles ?? {},
|
|
50
|
+
currentProfile: parsed.currentProfile ?? "",
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
return emptyConfig()
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function writeConfig(config: Config) {
|
|
58
|
+
await mkdir(configDir(), { recursive: true })
|
|
59
|
+
await writeFile(configPath(), `${JSON.stringify(config, null, 2)}\n`, {
|
|
60
|
+
encoding: "utf8",
|
|
61
|
+
mode: 0o600,
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function normalizeProfileName(value: string) {
|
|
66
|
+
return value
|
|
67
|
+
.toLowerCase()
|
|
68
|
+
.trim()
|
|
69
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
70
|
+
.replace(/^-+|-+$/g, "")
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function profileNameFromArgs(args: Args | undefined, config: Config) {
|
|
74
|
+
return args?.options.profile ?? config.currentProfile
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function selectedProfileEntry(args?: Args): Promise<{
|
|
78
|
+
name: string
|
|
79
|
+
profile: CliProfile
|
|
80
|
+
}> {
|
|
81
|
+
const config = await readConfig()
|
|
82
|
+
const profileName = profileNameFromArgs(args, config)
|
|
83
|
+
|
|
84
|
+
if (!profileName) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
"No Onyx CLI profile selected. Run `onyx login` or set ONYX_API_KEY."
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const profile = config.profiles[profileName]
|
|
91
|
+
if (!profile) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`Unknown Onyx CLI profile "${profileName}". Run \`onyx profile list\`.`
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { name: profileName, profile }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function selectedProfile(args?: Args): Promise<CliProfile> {
|
|
101
|
+
return (await selectedProfileEntry(args)).profile
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function apiBaseUrl(
|
|
105
|
+
args?: Args,
|
|
106
|
+
options?: { allowDefault?: boolean }
|
|
107
|
+
) {
|
|
108
|
+
if (args?.options["api-url"]) return args.options["api-url"]
|
|
109
|
+
if (process.env.ONYX_API_URL) return process.env.ONYX_API_URL
|
|
110
|
+
|
|
111
|
+
const config = await readConfig()
|
|
112
|
+
const profileName = profileNameFromArgs(args, config)
|
|
113
|
+
const profile = profileName ? config.profiles[profileName] : undefined
|
|
114
|
+
|
|
115
|
+
if (profile) return profile.apiUrl
|
|
116
|
+
if (process.env.ONYX_API_KEY) return DEFAULT_API_URL
|
|
117
|
+
if (options?.allowDefault) return DEFAULT_API_URL
|
|
118
|
+
|
|
119
|
+
if (profileName) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Unknown Onyx CLI profile "${profileName}". Run \`onyx profile list\`.`
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
throw new Error(
|
|
126
|
+
"No Onyx CLI profile selected. Run `onyx login` or set ONYX_API_URL."
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function apiKey(args?: Args) {
|
|
131
|
+
if (process.env.ONYX_API_KEY) return process.env.ONYX_API_KEY
|
|
132
|
+
|
|
133
|
+
const { name, profile } = await selectedProfileEntry(args)
|
|
134
|
+
if (profile.apiKeyEnv) {
|
|
135
|
+
const value = process.env[profile.apiKeyEnv]
|
|
136
|
+
if (value?.trim()) return value
|
|
137
|
+
|
|
138
|
+
throw new Error(
|
|
139
|
+
`Profile "${name}" expects API key in ${profile.apiKeyEnv}, but that environment variable is not set or empty.`
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (profile.apiKey) return profile.apiKey
|
|
144
|
+
|
|
145
|
+
throw new Error(
|
|
146
|
+
`Profile "${name}" has no API key. Run \`onyx login --refresh\` or \`onyx profile set-api-key-env ${name} <ENV_VAR>\`.`
|
|
147
|
+
)
|
|
148
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { readFile, rename, stat, writeFile } from "node:fs/promises"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
localResearchEventSchema,
|
|
6
|
+
type LocalResearchEvent,
|
|
7
|
+
type LocalResearchEventType,
|
|
8
|
+
} from "../protocol"
|
|
9
|
+
|
|
10
|
+
import { onyxStateDir } from "./outbox"
|
|
11
|
+
|
|
12
|
+
const TRUNCATE_BYTES = 256 * 1024
|
|
13
|
+
const TRUNCATE_KEEP = 500
|
|
14
|
+
|
|
15
|
+
export async function eventsPath(root: string) {
|
|
16
|
+
return join(await onyxStateDir(root), "events.jsonl")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Appends a local activity event for `onyx listen`. Strictly best-effort:
|
|
21
|
+
* never throws, so a broken feed can never fail a command.
|
|
22
|
+
*/
|
|
23
|
+
export async function emitEvent(
|
|
24
|
+
root: string,
|
|
25
|
+
event: {
|
|
26
|
+
type: LocalResearchEventType
|
|
27
|
+
branchName?: string
|
|
28
|
+
commitSha?: string
|
|
29
|
+
message?: string
|
|
30
|
+
}
|
|
31
|
+
): Promise<void> {
|
|
32
|
+
try {
|
|
33
|
+
const validated = localResearchEventSchema.parse({
|
|
34
|
+
schemaVersion: 1,
|
|
35
|
+
ts: new Date().toISOString(),
|
|
36
|
+
...event,
|
|
37
|
+
})
|
|
38
|
+
const path = await eventsPath(root)
|
|
39
|
+
await writeFile(path, `${JSON.stringify(validated)}\n`, {
|
|
40
|
+
encoding: "utf8",
|
|
41
|
+
flag: "a",
|
|
42
|
+
})
|
|
43
|
+
await truncateEvents(root)
|
|
44
|
+
} catch {
|
|
45
|
+
// Activity feed is observational only.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Reads activity events, skipping corrupt lines. `tail` limits parsing to the
|
|
51
|
+
* last N lines so the listener stays cheap as the feed grows.
|
|
52
|
+
*/
|
|
53
|
+
export async function readEvents(
|
|
54
|
+
root: string,
|
|
55
|
+
options: { tail?: number } = {}
|
|
56
|
+
): Promise<LocalResearchEvent[]> {
|
|
57
|
+
let text = ""
|
|
58
|
+
try {
|
|
59
|
+
text = await readFile(await eventsPath(root), "utf8")
|
|
60
|
+
} catch {
|
|
61
|
+
return []
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let lines = text.split("\n")
|
|
65
|
+
if (options.tail !== undefined) lines = lines.slice(-options.tail - 1)
|
|
66
|
+
|
|
67
|
+
const events: LocalResearchEvent[] = []
|
|
68
|
+
for (const line of lines) {
|
|
69
|
+
const trimmed = line.trim()
|
|
70
|
+
if (!trimmed) continue
|
|
71
|
+
try {
|
|
72
|
+
const result = localResearchEventSchema.safeParse(JSON.parse(trimmed))
|
|
73
|
+
if (result.success) events.push(result.data)
|
|
74
|
+
} catch {
|
|
75
|
+
// skip corrupt line
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return events
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Atomically trims the feed to the most recent events once it grows large. */
|
|
82
|
+
export async function truncateEvents(root: string, keep = TRUNCATE_KEEP) {
|
|
83
|
+
const path = await eventsPath(root)
|
|
84
|
+
try {
|
|
85
|
+
const { size } = await stat(path)
|
|
86
|
+
if (size <= TRUNCATE_BYTES) return
|
|
87
|
+
const lines = (await readFile(path, "utf8"))
|
|
88
|
+
.split("\n")
|
|
89
|
+
.filter((line) => line.trim())
|
|
90
|
+
if (lines.length <= keep) return
|
|
91
|
+
const tmp = `${path}.tmp`
|
|
92
|
+
await writeFile(tmp, `${lines.slice(-keep).join("\n")}\n`, "utf8")
|
|
93
|
+
await rename(tmp, path)
|
|
94
|
+
} catch {
|
|
95
|
+
// best-effort
|
|
96
|
+
}
|
|
97
|
+
}
|