@opice/cli 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +33 -0
- package/src/cli.ts +78 -0
- package/src/commands/failures.ts +178 -0
- package/src/commands/init.ts +84 -0
- package/src/commands/install-skills.ts +79 -0
- package/src/commands/test.ts +105 -0
- package/src/commands/users.ts +95 -0
- package/src/config.ts +32 -0
- package/src/dsn.ts +32 -0
- package/src/git.ts +29 -0
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@opice/cli",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "CLI for opice — scaffolds projects and wraps `bun test` to stream E2E results to the reporting platform",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"opice": "src/cli.ts"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./src/cli.ts",
|
|
12
|
+
"default": "./src/cli.ts"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"typecheck": "tsc --noEmit"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"bun-types": "*"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "https://github.com/contember/opice.git",
|
|
28
|
+
"directory": "packages/cli"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { failuresCommand } from './commands/failures'
|
|
3
|
+
import { initCommand } from './commands/init'
|
|
4
|
+
import { installSkillsCommand } from './commands/install-skills'
|
|
5
|
+
import { testCommand } from './commands/test'
|
|
6
|
+
import { usersCommand } from './commands/users'
|
|
7
|
+
|
|
8
|
+
const HELP = `opice — AI-driven E2E browser test harness
|
|
9
|
+
|
|
10
|
+
Usage: opice <command> [options]
|
|
11
|
+
|
|
12
|
+
Commands:
|
|
13
|
+
init [--project=SLUG] [--endpoint=URL] [--with-workflow]
|
|
14
|
+
Scaffold opice.config.json in the current project. Pass
|
|
15
|
+
--with-workflow to also drop a .github/workflows/opice.yml.
|
|
16
|
+
|
|
17
|
+
test [bun test args...]
|
|
18
|
+
Wrapper around 'bun test' that exports OPICE_* env vars from
|
|
19
|
+
opice.config.json + git so the harness reporter streams results
|
|
20
|
+
to the platform. All trailing args pass through to bun test.
|
|
21
|
+
|
|
22
|
+
failures <run-url|run-id> [--json]
|
|
23
|
+
Pull a failed run's details (failed scenarios, the failing step,
|
|
24
|
+
error, screenshot URL, and source files) for the re-eval workflow.
|
|
25
|
+
Token comes from the URL's ?token= or OPICE_READ_TOKEN.
|
|
26
|
+
|
|
27
|
+
users create <email> [--password=...] [--name=...] [--endpoint=URL] [--admin-token=TOKEN]
|
|
28
|
+
Create a dashboard login (every user is admin). Needs the admin
|
|
29
|
+
token (--admin-token or OPICE_ADMIN_TOKEN) and the platform endpoint
|
|
30
|
+
(--endpoint, OPICE_ENDPOINT, or opice.config.json). A password is
|
|
31
|
+
generated and printed if you don't pass one.
|
|
32
|
+
|
|
33
|
+
install-skills [--global] [--ref=BRANCH]
|
|
34
|
+
Install opice's Claude Code skills + author agent into this project's
|
|
35
|
+
.claude/ (or ~/.claude with --global), fetched from GitHub. Restart
|
|
36
|
+
Claude Code afterwards to load them.
|
|
37
|
+
|
|
38
|
+
help
|
|
39
|
+
Show this message.
|
|
40
|
+
`
|
|
41
|
+
|
|
42
|
+
async function main(argv: string[]): Promise<number> {
|
|
43
|
+
const [command, ...rest] = argv
|
|
44
|
+
switch (command) {
|
|
45
|
+
case 'init':
|
|
46
|
+
return initCommand(parseInitFlags(rest))
|
|
47
|
+
case 'test':
|
|
48
|
+
return testCommand(rest)
|
|
49
|
+
case 'failures':
|
|
50
|
+
return failuresCommand(rest)
|
|
51
|
+
case 'users':
|
|
52
|
+
return usersCommand(rest)
|
|
53
|
+
case 'install-skills':
|
|
54
|
+
return installSkillsCommand(rest)
|
|
55
|
+
case 'help':
|
|
56
|
+
case '--help':
|
|
57
|
+
case '-h':
|
|
58
|
+
case undefined:
|
|
59
|
+
console.log(HELP)
|
|
60
|
+
return 0
|
|
61
|
+
default:
|
|
62
|
+
console.error(`Unknown command: ${command}`)
|
|
63
|
+
console.error(HELP)
|
|
64
|
+
return 1
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseInitFlags(args: string[]): { project?: string; endpoint?: string; withWorkflow?: boolean } {
|
|
69
|
+
const flags: { project?: string; endpoint?: string; withWorkflow?: boolean } = {}
|
|
70
|
+
for (const arg of args) {
|
|
71
|
+
if (arg === '--with-workflow') flags.withWorkflow = true
|
|
72
|
+
else if (arg.startsWith('--project=')) flags.project = arg.slice('--project='.length)
|
|
73
|
+
else if (arg.startsWith('--endpoint=')) flags.endpoint = arg.slice('--endpoint='.length)
|
|
74
|
+
}
|
|
75
|
+
return flags
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
process.exit(await main(process.argv.slice(2)))
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `opice failures <run-url|run-id>` — pull a failed run's details from the
|
|
3
|
+
* platform and print a digest the re-eval workflow can act on: which
|
|
4
|
+
* scenarios failed, at which step, the error, the screenshot URL, and the
|
|
5
|
+
* source files (test + scenario.md) that produced them.
|
|
6
|
+
*
|
|
7
|
+
* Reads are token-gated. The token is taken from the URL's `?token=` (when you
|
|
8
|
+
* paste a dashboard link) or from OPICE_READ_TOKEN.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { loadConfig } from '../config'
|
|
12
|
+
import { parseOpiceDsn } from '../dsn'
|
|
13
|
+
|
|
14
|
+
interface Run {
|
|
15
|
+
id: string
|
|
16
|
+
branch: string | null
|
|
17
|
+
commitSha: string | null
|
|
18
|
+
status: string
|
|
19
|
+
totalScenarios: number
|
|
20
|
+
passedScenarios: number
|
|
21
|
+
failedScenarios: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface Scenario {
|
|
25
|
+
id: string
|
|
26
|
+
name: string
|
|
27
|
+
hash: string | null
|
|
28
|
+
testFile: string | null
|
|
29
|
+
scenarioFile: string | null
|
|
30
|
+
status: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface Step {
|
|
34
|
+
id: number
|
|
35
|
+
sequence: number
|
|
36
|
+
name: string
|
|
37
|
+
status: string
|
|
38
|
+
error: string | null
|
|
39
|
+
screenshotUrl: string | null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface Target {
|
|
43
|
+
endpoint: string
|
|
44
|
+
runId: string
|
|
45
|
+
token: string | undefined
|
|
46
|
+
slug?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function failuresCommand(args: string[]): Promise<number> {
|
|
50
|
+
const asJson = args.includes('--json')
|
|
51
|
+
const positional = args.filter((a) => !a.startsWith('--'))
|
|
52
|
+
const ref = positional[0]
|
|
53
|
+
if (!ref) {
|
|
54
|
+
console.error('Usage: opice failures <run-url|run-id> [--json]')
|
|
55
|
+
return 1
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const target = await resolveTarget(ref)
|
|
59
|
+
if (!target) {
|
|
60
|
+
console.error('Could not determine the platform endpoint. Pass a full run URL or run `opice` from a project with opice.config.json.')
|
|
61
|
+
return 1
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let run: Run
|
|
65
|
+
let scenarios: Scenario[]
|
|
66
|
+
try {
|
|
67
|
+
run = await rpc<Run>(target, 'runs.get', { runId: target.runId })
|
|
68
|
+
scenarios = await rpc<Scenario[]>(target, 'runs.scenarios', { runId: target.runId })
|
|
69
|
+
} catch (err) {
|
|
70
|
+
console.error(`[opice] ${(err as Error).message}`)
|
|
71
|
+
return 1
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const failed = scenarios.filter((s) => s.status === 'failed')
|
|
75
|
+
const detailed = await Promise.all(
|
|
76
|
+
failed.map(async (s) => ({
|
|
77
|
+
scenario: s,
|
|
78
|
+
steps: await rpc<Step[]>(target, 'scenarios.steps', { scenarioId: s.id }).catch(() => [] as Step[]),
|
|
79
|
+
})),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if (asJson) {
|
|
83
|
+
console.log(JSON.stringify({ run, failures: detailed.map((d) => digestEntry(d, target)) }, null, 2))
|
|
84
|
+
return 0
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
printDigest(run, detailed, target)
|
|
88
|
+
return 0
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function digestEntry(d: { scenario: Scenario; steps: Step[] }, target: Target) {
|
|
92
|
+
const failedSteps = d.steps.filter((s) => s.status === 'failed')
|
|
93
|
+
return {
|
|
94
|
+
name: d.scenario.name,
|
|
95
|
+
hash: d.scenario.hash,
|
|
96
|
+
testFile: d.scenario.testFile,
|
|
97
|
+
scenarioFile: d.scenario.scenarioFile,
|
|
98
|
+
stepCount: d.steps.length,
|
|
99
|
+
failedSteps: failedSteps.map((s) => ({
|
|
100
|
+
sequence: s.sequence,
|
|
101
|
+
name: s.name,
|
|
102
|
+
error: s.error,
|
|
103
|
+
screenshot: s.screenshotUrl ? absoluteScreenshot(s.screenshotUrl, target) : null,
|
|
104
|
+
})),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function printDigest(run: Run, detailed: { scenario: Scenario; steps: Step[] }[], target: Target): void {
|
|
109
|
+
const out: string[] = []
|
|
110
|
+
out.push(`Run ${run.id} — ${run.status.toUpperCase()}`)
|
|
111
|
+
const meta = [run.branch, run.commitSha ? `commit ${run.commitSha.slice(0, 7)}` : null].filter(Boolean).join(' · ')
|
|
112
|
+
if (meta) out.push(meta)
|
|
113
|
+
out.push(`${run.failedScenarios}/${run.totalScenarios} scenarios failed`)
|
|
114
|
+
out.push('')
|
|
115
|
+
|
|
116
|
+
if (detailed.length === 0) {
|
|
117
|
+
out.push('No failed scenarios recorded for this run.')
|
|
118
|
+
console.log(out.join('\n'))
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const { scenario, steps } of detailed) {
|
|
123
|
+
const failedSteps = steps.filter((s) => s.status === 'failed')
|
|
124
|
+
out.push(`✗ ${scenario.name}${scenario.hash ? ` [#${scenario.hash}]` : ''}`)
|
|
125
|
+
if (scenario.testFile) out.push(` test: ${scenario.testFile}`)
|
|
126
|
+
if (scenario.scenarioFile) out.push(` scenario: ${scenario.scenarioFile}`)
|
|
127
|
+
for (const s of failedSteps) {
|
|
128
|
+
out.push(` failed at step ${s.sequence + 1}/${steps.length}: "${s.name}"`)
|
|
129
|
+
if (s.error) {
|
|
130
|
+
for (const line of s.error.split('\n')) out.push(` ${line}`)
|
|
131
|
+
}
|
|
132
|
+
if (s.screenshotUrl) out.push(` screenshot: ${absoluteScreenshot(s.screenshotUrl, target)}`)
|
|
133
|
+
}
|
|
134
|
+
out.push('')
|
|
135
|
+
}
|
|
136
|
+
console.log(out.join('\n').trimEnd())
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function absoluteScreenshot(relativeOrAbsolute: string, target: Target): string {
|
|
140
|
+
const base = relativeOrAbsolute.startsWith('http') ? relativeOrAbsolute : `${target.endpoint}${relativeOrAbsolute}`
|
|
141
|
+
if (!target.token) return base
|
|
142
|
+
return `${base}${base.includes('?') ? '&' : '?'}token=${target.token}`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function resolveTarget(ref: string): Promise<Target | null> {
|
|
146
|
+
if (/^https?:\/\//.test(ref)) {
|
|
147
|
+
const url = new URL(ref)
|
|
148
|
+
const token = url.searchParams.get('token') ?? process.env['OPICE_READ_TOKEN'] ?? undefined
|
|
149
|
+
const match = url.pathname.match(/\/p\/([^/]+)\/r\/([^/]+)/)
|
|
150
|
+
if (match) {
|
|
151
|
+
return { endpoint: url.origin, runId: decodeURIComponent(match[2]!), token, slug: decodeURIComponent(match[1]!) }
|
|
152
|
+
}
|
|
153
|
+
// Fall back to the last path segment as the run id.
|
|
154
|
+
const segments = url.pathname.split('/').filter(Boolean)
|
|
155
|
+
const runId = segments[segments.length - 1]
|
|
156
|
+
if (runId) return { endpoint: url.origin, runId, token }
|
|
157
|
+
return null
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Bare run id — endpoint from config/env/DSN, token from env.
|
|
161
|
+
const config = await loadConfig()
|
|
162
|
+
const endpoint = process.env['OPICE_ENDPOINT'] ?? config?.endpoint ?? parseOpiceDsn(process.env['OPICE_DSN'])?.endpoint
|
|
163
|
+
if (!endpoint) return null
|
|
164
|
+
return { endpoint, runId: ref, token: process.env['OPICE_READ_TOKEN'] ?? undefined }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function rpc<T>(target: Target, method: string, input: unknown): Promise<T> {
|
|
168
|
+
const url = `${target.endpoint}/rpc${target.token ? `?token=${target.token}` : ''}`
|
|
169
|
+
const response = await fetch(url, {
|
|
170
|
+
method: 'POST',
|
|
171
|
+
headers: { 'content-type': 'application/json' },
|
|
172
|
+
body: JSON.stringify({ method, input }),
|
|
173
|
+
})
|
|
174
|
+
const data = (await response.json().catch(() => null)) as { result?: T; error?: { message?: string } } | null
|
|
175
|
+
if (!data) throw new Error(`${method}: ${response.status} ${response.statusText}`)
|
|
176
|
+
if (data.error) throw new Error(`${method}: ${data.error.message ?? 'request failed'}`)
|
|
177
|
+
return data.result as T
|
|
178
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { loadConfig, writeConfig } from '../config'
|
|
4
|
+
|
|
5
|
+
interface InitOptions {
|
|
6
|
+
project?: string
|
|
7
|
+
endpoint?: string
|
|
8
|
+
withWorkflow?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function initCommand(opts: InitOptions): Promise<number> {
|
|
12
|
+
const cwd = process.cwd()
|
|
13
|
+
const existing = await loadConfig(cwd)
|
|
14
|
+
if (existing) {
|
|
15
|
+
console.error(`opice.config.json already exists in this project (project=${existing.project}).`)
|
|
16
|
+
console.error('Edit it directly, or delete it and re-run init.')
|
|
17
|
+
return 1
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const project = opts.project ?? prompt('Project slug:') ?? ''
|
|
21
|
+
if (!project) {
|
|
22
|
+
console.error('Project slug is required.')
|
|
23
|
+
return 1
|
|
24
|
+
}
|
|
25
|
+
const endpoint = opts.endpoint ?? prompt('Reporter endpoint (e.g. https://opice.example.com):') ?? ''
|
|
26
|
+
if (!endpoint) {
|
|
27
|
+
console.error('Endpoint is required.')
|
|
28
|
+
return 1
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const configPath = await writeConfig(cwd, { project, endpoint })
|
|
32
|
+
console.log(`✓ Wrote ${path.relative(cwd, configPath)}`)
|
|
33
|
+
|
|
34
|
+
if (opts.withWorkflow) {
|
|
35
|
+
const workflowPath = await writeWorkflow(cwd)
|
|
36
|
+
console.log(`✓ Wrote ${path.relative(cwd, workflowPath)}`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
console.log()
|
|
40
|
+
console.log('Next steps:')
|
|
41
|
+
console.log(' 1. Set OPICE_DSN (from the dashboard) in .env locally and as a CI repo secret.')
|
|
42
|
+
console.log(' 2. Run your tests via `opice test <bun-test-args>` to stream results.')
|
|
43
|
+
return 0
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function writeWorkflow(cwd: string): Promise<string> {
|
|
47
|
+
const target = path.join(cwd, '.github', 'workflows', 'opice.yml')
|
|
48
|
+
await fs.mkdir(path.dirname(target), { recursive: true })
|
|
49
|
+
await fs.writeFile(target, WORKFLOW_TEMPLATE, 'utf-8')
|
|
50
|
+
return target
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const WORKFLOW_TEMPLATE = `name: opice browser tests
|
|
54
|
+
|
|
55
|
+
on:
|
|
56
|
+
push:
|
|
57
|
+
branches: [main]
|
|
58
|
+
pull_request:
|
|
59
|
+
|
|
60
|
+
jobs:
|
|
61
|
+
e2e:
|
|
62
|
+
runs-on: ubuntu-latest
|
|
63
|
+
steps:
|
|
64
|
+
- uses: actions/checkout@v4
|
|
65
|
+
- uses: oven-sh/setup-bun@v2
|
|
66
|
+
- run: bun install
|
|
67
|
+
- name: Install agent-browser
|
|
68
|
+
run: bun add -g agent-browser
|
|
69
|
+
- name: Start playground (background)
|
|
70
|
+
run: bun run dev &
|
|
71
|
+
env:
|
|
72
|
+
NODE_ENV: development
|
|
73
|
+
- name: Wait for playground
|
|
74
|
+
run: |
|
|
75
|
+
for i in $(seq 1 30); do
|
|
76
|
+
if curl -sf http://localhost:5173 > /dev/null; then break; fi
|
|
77
|
+
sleep 1
|
|
78
|
+
done
|
|
79
|
+
- name: Run opice browser tests
|
|
80
|
+
run: bunx opice test tests/browser/
|
|
81
|
+
env:
|
|
82
|
+
OPICE_DSN: \${{ secrets.OPICE_DSN }}
|
|
83
|
+
PLAYGROUND_URL: http://localhost:5173
|
|
84
|
+
`
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `opice install-skills [--global] [--ref=<branch>]` — install opice's Claude
|
|
3
|
+
* Code extensions into a project (or `~/.claude` with --global).
|
|
4
|
+
*
|
|
5
|
+
* skills/* → <target>/.claude/skills/*
|
|
6
|
+
* agents/* → <target>/.claude/agents/*
|
|
7
|
+
*
|
|
8
|
+
* Files are pulled from the public GitHub repo (no auth, no publish needed),
|
|
9
|
+
* so a freshly-onboarded project can `bunx opice install-skills` and pick up
|
|
10
|
+
* opice-author / opice-plan / opice-batch / opice-reeval + the author agent.
|
|
11
|
+
*
|
|
12
|
+
* Project-local is the default so the extensions can be committed and shared
|
|
13
|
+
* with the team; restart Claude Code afterwards to load them.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { promises as fs } from 'node:fs'
|
|
17
|
+
import { homedir } from 'node:os'
|
|
18
|
+
import path from 'node:path'
|
|
19
|
+
|
|
20
|
+
const REPO = 'contember/opice'
|
|
21
|
+
|
|
22
|
+
interface TreeEntry {
|
|
23
|
+
path: string
|
|
24
|
+
type: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function installSkillsCommand(args: string[]): Promise<number> {
|
|
28
|
+
const global = args.includes('--global')
|
|
29
|
+
const ref = args.find((a) => a.startsWith('--ref='))?.slice('--ref='.length) ?? process.env['OPICE_SKILLS_REF'] ?? 'main'
|
|
30
|
+
const base = path.join(global ? homedir() : process.cwd(), '.claude')
|
|
31
|
+
|
|
32
|
+
const headers = { 'user-agent': 'opice-cli', accept: 'application/vnd.github+json' }
|
|
33
|
+
let tree: TreeEntry[]
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetch(`https://api.github.com/repos/${REPO}/git/trees/${ref}?recursive=1`, { headers })
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
console.error(`[opice] could not list ${REPO}@${ref}: ${res.status} ${res.statusText}`)
|
|
38
|
+
return 1
|
|
39
|
+
}
|
|
40
|
+
tree = ((await res.json()) as { tree?: TreeEntry[] }).tree ?? []
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.error(`[opice] request failed: ${(err as Error).message}`)
|
|
43
|
+
return 1
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const files = tree.filter(
|
|
47
|
+
(t) => t.type === 'blob' && (t.path.startsWith('skills/') || (t.path.startsWith('agents/') && t.path.endsWith('.md'))),
|
|
48
|
+
)
|
|
49
|
+
if (files.length === 0) {
|
|
50
|
+
console.error(`[opice] no skills/agents found in ${REPO}@${ref}`)
|
|
51
|
+
return 1
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let written = 0
|
|
55
|
+
for (const file of files) {
|
|
56
|
+
try {
|
|
57
|
+
const res = await fetch(`https://raw.githubusercontent.com/${REPO}/${ref}/${file.path}`, {
|
|
58
|
+
headers: { 'user-agent': 'opice-cli' },
|
|
59
|
+
})
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
console.error(`[opice] skip ${file.path}: ${res.status}`)
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
const body = Buffer.from(await res.arrayBuffer())
|
|
65
|
+
const dst = path.join(base, file.path) // file.path is already `skills/…` / `agents/…`
|
|
66
|
+
await fs.mkdir(path.dirname(dst), { recursive: true })
|
|
67
|
+
await fs.writeFile(dst, body)
|
|
68
|
+
written++
|
|
69
|
+
} catch (err) {
|
|
70
|
+
console.error(`[opice] skip ${file.path}: ${(err as Error).message}`)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const skillNames = new Set(files.filter((f) => f.path.startsWith('skills/')).map((f) => f.path.split('/')[1]))
|
|
75
|
+
console.log(`✓ Installed ${written} file(s) into ${base}`)
|
|
76
|
+
console.log(` skills: ${[...skillNames].join(', ') || '—'}`)
|
|
77
|
+
console.log(' Restart Claude Code to load the extensions.')
|
|
78
|
+
return 0
|
|
79
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { promises as fs } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { loadConfig } from '../config'
|
|
6
|
+
import { parseOpiceDsn } from '../dsn'
|
|
7
|
+
import { detectGitMeta } from '../git'
|
|
8
|
+
|
|
9
|
+
const HANDOFF_DIR = path.join(tmpdir(), 'opice-handoffs')
|
|
10
|
+
|
|
11
|
+
interface Handoff {
|
|
12
|
+
endpoint: string
|
|
13
|
+
apiKey: string
|
|
14
|
+
runId: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function testCommand(args: string[]): Promise<number> {
|
|
18
|
+
const config = await loadConfig()
|
|
19
|
+
const dsn = parseOpiceDsn(process.env['OPICE_DSN'])
|
|
20
|
+
const project = process.env['OPICE_PROJECT'] ?? config?.project ?? dsn?.project
|
|
21
|
+
const endpoint = process.env['OPICE_ENDPOINT'] ?? config?.endpoint ?? dsn?.endpoint
|
|
22
|
+
const apiKey = process.env['OPICE_API_KEY'] ?? dsn?.apiKey
|
|
23
|
+
|
|
24
|
+
if (!project) {
|
|
25
|
+
warn('OPICE_PROJECT not set and no opice.config.json found. Run `opice init` or set the env var.')
|
|
26
|
+
}
|
|
27
|
+
if (!endpoint) {
|
|
28
|
+
warn('OPICE_ENDPOINT not set and no opice.config.json found. Tests will run without reporting.')
|
|
29
|
+
}
|
|
30
|
+
if (!apiKey) {
|
|
31
|
+
warn('OPICE_API_KEY not set. Tests will run without reporting.')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const git = detectGitMeta()
|
|
35
|
+
const env: NodeJS.ProcessEnv = {
|
|
36
|
+
...process.env,
|
|
37
|
+
...(project ? { OPICE_PROJECT: project } : {}),
|
|
38
|
+
...(endpoint ? { OPICE_ENDPOINT: endpoint } : {}),
|
|
39
|
+
// Resolve the api key (incl. from a DSN) into the explicit var the
|
|
40
|
+
// harness reporter reads, so a bare OPICE_DSN is enough to report.
|
|
41
|
+
...(apiKey ? { OPICE_API_KEY: apiKey } : {}),
|
|
42
|
+
...(git.branch ? { OPICE_BRANCH: git.branch } : {}),
|
|
43
|
+
...(git.commit ? { OPICE_COMMIT: git.commit } : {}),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Always invoke `bun test`; any user-passed args go after.
|
|
47
|
+
const child = spawn('bun', ['test', ...args], { stdio: 'inherit', env })
|
|
48
|
+
|
|
49
|
+
const exitCode = await new Promise<number>((resolve) => {
|
|
50
|
+
child.on('exit', (code) => resolve(code ?? 1))
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// After bun test exits, look for handoff files the reporter wrote and
|
|
54
|
+
// POST /finish for each run so it leaves "running" state.
|
|
55
|
+
await finalizeHandoffs(child.pid, project, process.env['OPICE_READ_TOKEN'])
|
|
56
|
+
|
|
57
|
+
return exitCode
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function finalizeHandoffs(childPid: number | undefined, slug: string | undefined, readToken: string | undefined): Promise<void> {
|
|
61
|
+
let files: string[]
|
|
62
|
+
try {
|
|
63
|
+
files = await fs.readdir(HANDOFF_DIR)
|
|
64
|
+
} catch {
|
|
65
|
+
return // no handoff dir → no runs reported, nothing to finalize
|
|
66
|
+
}
|
|
67
|
+
const matching = childPid ? files.filter((f) => f === `${childPid}.json`) : files
|
|
68
|
+
for (const file of matching) {
|
|
69
|
+
const full = path.join(HANDOFF_DIR, file)
|
|
70
|
+
try {
|
|
71
|
+
const handoff = JSON.parse(await fs.readFile(full, 'utf-8')) as Handoff
|
|
72
|
+
await finishRun(handoff)
|
|
73
|
+
printRunUrl(handoff, slug, readToken)
|
|
74
|
+
} catch (err) {
|
|
75
|
+
warn(`Failed to finalize run from ${file}: ${(err as Error).message}`)
|
|
76
|
+
} finally {
|
|
77
|
+
await fs.unlink(full).catch(() => {})
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function printRunUrl(handoff: Handoff, slug: string | undefined, readToken: string | undefined): void {
|
|
83
|
+
if (!slug) return
|
|
84
|
+
const query = readToken ? `?token=${readToken}` : ''
|
|
85
|
+
const url = `${handoff.endpoint}/p/${slug}/r/${handoff.runId}${query}`
|
|
86
|
+
console.error(`[opice] View run: ${url}`)
|
|
87
|
+
if (!readToken) {
|
|
88
|
+
console.error('[opice] (open the dashboard for the tokened read-only link, or set OPICE_READ_TOKEN to embed it here)')
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function finishRun(handoff: Handoff): Promise<void> {
|
|
93
|
+
const url = `${handoff.endpoint}/api/v1/runs/${handoff.runId}/finish`
|
|
94
|
+
const response = await fetch(url, {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
headers: { authorization: `Bearer ${handoff.apiKey}` },
|
|
97
|
+
})
|
|
98
|
+
if (!response.ok) {
|
|
99
|
+
throw new Error(`${response.status} ${await response.text()}`)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function warn(message: string): void {
|
|
104
|
+
console.error(`[opice] warning: ${message}`)
|
|
105
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `opice users create <email>` — create a dashboard login.
|
|
3
|
+
*
|
|
4
|
+
* Self-service signup is disabled on the platform; this is the sanctioned way
|
|
5
|
+
* to mint an account. It calls the admin endpoint, so it needs the admin token
|
|
6
|
+
* (--admin-token or OPICE_ADMIN_TOKEN) and the platform endpoint (--endpoint,
|
|
7
|
+
* OPICE_ENDPOINT, or opice.config.json). Every account is a full admin.
|
|
8
|
+
*
|
|
9
|
+
* If no password is given one is generated and printed once.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { loadConfig } from '../config'
|
|
13
|
+
import { parseOpiceDsn } from '../dsn'
|
|
14
|
+
|
|
15
|
+
interface CreateUserFlags {
|
|
16
|
+
email?: string
|
|
17
|
+
password?: string
|
|
18
|
+
name?: string
|
|
19
|
+
endpoint?: string
|
|
20
|
+
adminToken?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function usersCommand(args: string[]): Promise<number> {
|
|
24
|
+
const [sub, ...rest] = args
|
|
25
|
+
if (sub !== 'create') {
|
|
26
|
+
console.error('Usage: opice users create <email> [--password=...] [--name=...] [--endpoint=URL] [--admin-token=TOKEN]')
|
|
27
|
+
return 1
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const flags = parseFlags(rest)
|
|
31
|
+
if (!flags.email) {
|
|
32
|
+
console.error('Usage: opice users create <email> [--password=...] [--name=...] [--endpoint=URL] [--admin-token=TOKEN]')
|
|
33
|
+
return 1
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const endpoint = flags.endpoint ?? process.env['OPICE_ENDPOINT'] ?? (await loadConfig())?.endpoint ?? parseOpiceDsn(process.env['OPICE_DSN'])?.endpoint
|
|
37
|
+
if (!endpoint) {
|
|
38
|
+
console.error('Could not determine the platform endpoint. Pass --endpoint=URL, set OPICE_ENDPOINT, or run from a project with opice.config.json.')
|
|
39
|
+
return 1
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const adminToken = flags.adminToken ?? process.env['OPICE_ADMIN_TOKEN']
|
|
43
|
+
if (!adminToken) {
|
|
44
|
+
console.error('Missing admin token. Pass --admin-token=TOKEN or set OPICE_ADMIN_TOKEN.')
|
|
45
|
+
return 1
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const generated = !flags.password
|
|
49
|
+
const password = flags.password ?? generatePassword()
|
|
50
|
+
|
|
51
|
+
let response: Response
|
|
52
|
+
try {
|
|
53
|
+
response = await fetch(`${endpoint.replace(/\/$/, '')}/api/v1/admin/users`, {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: { 'content-type': 'application/json', 'x-admin-token': adminToken },
|
|
56
|
+
body: JSON.stringify({ email: flags.email, password, ...(flags.name ? { name: flags.name } : {}) }),
|
|
57
|
+
})
|
|
58
|
+
} catch (err) {
|
|
59
|
+
console.error(`[opice] request failed: ${(err as Error).message}`)
|
|
60
|
+
return 1
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const data = (await response.json().catch(() => null)) as { email?: string; error?: { message?: string } } | null
|
|
64
|
+
if (!response.ok || !data || data.error) {
|
|
65
|
+
const message = data?.error?.message ?? `${response.status} ${response.statusText}`
|
|
66
|
+
console.error(`[opice] could not create user: ${message}`)
|
|
67
|
+
return 1
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
console.log(`✓ Created user ${data.email ?? flags.email}`)
|
|
71
|
+
if (generated) {
|
|
72
|
+
console.log(` password: ${password}`)
|
|
73
|
+
console.log(' (shown once — store it in your password manager now)')
|
|
74
|
+
}
|
|
75
|
+
return 0
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseFlags(args: string[]): CreateUserFlags {
|
|
79
|
+
const flags: CreateUserFlags = {}
|
|
80
|
+
for (const arg of args) {
|
|
81
|
+
if (arg.startsWith('--password=')) flags.password = arg.slice('--password='.length)
|
|
82
|
+
else if (arg.startsWith('--name=')) flags.name = arg.slice('--name='.length)
|
|
83
|
+
else if (arg.startsWith('--endpoint=')) flags.endpoint = arg.slice('--endpoint='.length)
|
|
84
|
+
else if (arg.startsWith('--admin-token=')) flags.adminToken = arg.slice('--admin-token='.length)
|
|
85
|
+
else if (!arg.startsWith('--') && !flags.email) flags.email = arg
|
|
86
|
+
}
|
|
87
|
+
return flags
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A 20-char base64url password — comfortably over the 10-char server minimum. */
|
|
91
|
+
function generatePassword(): string {
|
|
92
|
+
const bytes = new Uint8Array(15)
|
|
93
|
+
crypto.getRandomValues(bytes)
|
|
94
|
+
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
95
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
export interface OpiceConfig {
|
|
5
|
+
project: string
|
|
6
|
+
endpoint: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const CONFIG_NAME = 'opice.config.json'
|
|
10
|
+
|
|
11
|
+
export async function loadConfig(cwd: string = process.cwd()): Promise<OpiceConfig | null> {
|
|
12
|
+
// Walk up from cwd until we find opice.config.json or hit the root.
|
|
13
|
+
let dir = path.resolve(cwd)
|
|
14
|
+
while (true) {
|
|
15
|
+
const candidate = path.join(dir, CONFIG_NAME)
|
|
16
|
+
try {
|
|
17
|
+
const text = await fs.readFile(candidate, 'utf-8')
|
|
18
|
+
return JSON.parse(text) as OpiceConfig
|
|
19
|
+
} catch {
|
|
20
|
+
// continue up
|
|
21
|
+
}
|
|
22
|
+
const parent = path.dirname(dir)
|
|
23
|
+
if (parent === dir) return null
|
|
24
|
+
dir = parent
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function writeConfig(cwd: string, config: OpiceConfig): Promise<string> {
|
|
29
|
+
const target = path.join(cwd, CONFIG_NAME)
|
|
30
|
+
await fs.writeFile(target, JSON.stringify(config, null, '\t') + '\n', 'utf-8')
|
|
31
|
+
return target
|
|
32
|
+
}
|
package/src/dsn.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An opice DSN packs everything a project needs to report into one string:
|
|
3
|
+
*
|
|
4
|
+
* OPICE_DSN=https://<apiKey>@<host>/<slug>
|
|
5
|
+
*
|
|
6
|
+
* The api key rides in the userinfo, the host is the platform endpoint, and
|
|
7
|
+
* the first path segment is the project slug. The individual `OPICE_*` vars
|
|
8
|
+
* (and opice.config.json) still win when present — the DSN is a convenience
|
|
9
|
+
* fallback so the dashboard can hand out a single value to drop into `.env`.
|
|
10
|
+
*
|
|
11
|
+
* Kept in sync with `@opice/harness`'s copy; duplicated to avoid a CLI→harness
|
|
12
|
+
* dependency.
|
|
13
|
+
*/
|
|
14
|
+
export interface OpiceDsn {
|
|
15
|
+
apiKey: string
|
|
16
|
+
endpoint: string
|
|
17
|
+
project: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function parseOpiceDsn(raw: string | undefined | null): OpiceDsn | null {
|
|
21
|
+
if (!raw) return null
|
|
22
|
+
let url: URL
|
|
23
|
+
try {
|
|
24
|
+
url = new URL(raw)
|
|
25
|
+
} catch {
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
const apiKey = decodeURIComponent(url.username)
|
|
29
|
+
const project = url.pathname.replace(/^\/+/, '').split('/')[0] ?? ''
|
|
30
|
+
if (!apiKey || !project) return null
|
|
31
|
+
return { apiKey, endpoint: `${url.protocol}//${url.host}`, project }
|
|
32
|
+
}
|
package/src/git.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Best-effort git metadata for the current working tree. Returns the values
|
|
5
|
+
* configured in opice runs (branch, commit). Falls back to env vars commonly
|
|
6
|
+
* set by CI (GITHUB_REF_NAME, GITHUB_SHA) when not in a git checkout.
|
|
7
|
+
*/
|
|
8
|
+
export function detectGitMeta(): { branch?: string; commit?: string } {
|
|
9
|
+
const fromEnv = {
|
|
10
|
+
branch: process.env['OPICE_BRANCH'] ?? process.env['GITHUB_REF_NAME'],
|
|
11
|
+
commit: process.env['OPICE_COMMIT'] ?? process.env['GITHUB_SHA'],
|
|
12
|
+
}
|
|
13
|
+
if (fromEnv.branch && fromEnv.commit) return fromEnv
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const branch = run('git rev-parse --abbrev-ref HEAD')
|
|
17
|
+
const commit = run('git rev-parse HEAD')
|
|
18
|
+
return {
|
|
19
|
+
branch: fromEnv.branch ?? branch,
|
|
20
|
+
commit: fromEnv.commit ?? commit,
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
return fromEnv
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function run(cmd: string): string {
|
|
28
|
+
return execSync(cmd, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
|
|
29
|
+
}
|