Skip to content

Commit c741f04

Browse files
Sync from monorepo (ed64db5)
0 parents  commit c741f04

9 files changed

Lines changed: 413 additions & 0 deletions

File tree

.github/workflows/publish.yml

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
name: Publish to npm
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
version:
7+
description: 'Version bump (patch, minor, major)'
8+
required: true
9+
default: 'patch'
10+
type: choice
11+
options:
12+
- patch
13+
- minor
14+
- major
15+
16+
jobs:
17+
publish:
18+
runs-on: ubuntu-latest
19+
permissions:
20+
contents: write
21+
id-token: write
22+
steps:
23+
- name: Checkout
24+
uses: actions/checkout@v4
25+
26+
- name: Setup Node
27+
uses: actions/setup-node@v4
28+
with:
29+
node-version: 22
30+
registry-url: https://registry.npmjs.org
31+
32+
- name: Setup pnpm
33+
uses: pnpm/action-setup@v4
34+
with:
35+
version: 10
36+
37+
- name: Install dependencies
38+
run: pnpm install --no-frozen-lockfile
39+
40+
- name: Build
41+
run: pnpm build
42+
43+
- name: Bump version
44+
env:
45+
BUMP: ${{ inputs.version }}
46+
run: npm version "$BUMP" --no-git-tag-version
47+
48+
- name: Publish with provenance
49+
env:
50+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
51+
run: npm publish --access public --provenance
52+
53+
- name: Commit version bump
54+
run: |
55+
NEW_VERSION=$(node -p "require('./package.json').version")
56+
git config user.name "github-actions[bot]"
57+
git config user.email "github-actions[bot]@users.noreply.github.com"
58+
git add package.json
59+
git commit -m "Release @bonnard/agentops v${NEW_VERSION}"
60+
git tag "v${NEW_VERSION}"
61+
git push origin main --tags

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
node_modules/
2+
dist/
3+
.DS_Store

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2025-present Bonnard (bonnard-data)
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

package.json

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"name": "@bonnard/agentops",
3+
"version": "0.1.0",
4+
"type": "module",
5+
"bin": {
6+
"agentops": "./dist/bin/agentops.mjs"
7+
},
8+
"files": ["dist"],
9+
"repository": {
10+
"type": "git",
11+
"url": "https://github.com/bonnard-data/agentops-cli"
12+
},
13+
"engines": {
14+
"node": ">=20.0.0"
15+
},
16+
"scripts": {
17+
"dev": "tsx src/bin/agentops.ts",
18+
"build": "tsdown src/bin/agentops.ts --format esm --out-dir dist/bin",
19+
"typecheck": "tsc --noEmit",
20+
"lint": "eslint ."
21+
},
22+
"dependencies": {
23+
"commander": "^14.0.3",
24+
"open": "^11.0.0",
25+
"picocolors": "^1.1.1"
26+
},
27+
"devDependencies": {
28+
"@eslint/js": "^10.0.1",
29+
"@types/node": "^22.15.0",
30+
"eslint": "^10.0.1",
31+
"tsdown": "^0.21.4",
32+
"tsx": "^4.21.0",
33+
"typescript": "^6.0.2",
34+
"typescript-eslint": "^8.57.2"
35+
}
36+
}

src/bin/agentops.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#!/usr/bin/env node
2+
import { Command } from 'commander'
3+
import pc from 'picocolors'
4+
import { loginCommand } from '../commands/login.js'
5+
import { loadCredentials, clearCredentials } from '../lib/credentials.js'
6+
7+
const program = new Command()
8+
9+
program
10+
.name('agentops')
11+
.description('AgentOps CLI — setup and manage your AI agent skills')
12+
.version('0.1.0')
13+
14+
program
15+
.command('login')
16+
.description('Authenticate with AgentOps via your browser')
17+
.option('--url <url>', 'AgentOps server URL')
18+
.action(loginCommand)
19+
20+
program
21+
.command('logout')
22+
.description('Clear saved credentials')
23+
.action(() => {
24+
clearCredentials()
25+
console.log(pc.green('✓ Logged out'))
26+
})
27+
28+
program
29+
.command('whoami')
30+
.description('Show current logged-in user')
31+
.action(() => {
32+
const creds = loadCredentials()
33+
if (!creds) {
34+
console.log(pc.yellow('Not logged in. Run: agentops login'))
35+
process.exit(1)
36+
return
37+
}
38+
console.log(`${pc.bold(creds.user.email)} (${creds.org.name})`)
39+
console.log(`Role: ${creds.user.role}`)
40+
})
41+
42+
program.parse()

src/commands/login.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import http from 'node:http'
2+
import pc from 'picocolors'
3+
import open from 'open'
4+
import { getBaseUrl, post } from '../lib/api.js'
5+
import { saveCredentials, saveConfig, type Credentials } from '../lib/credentials.js'
6+
7+
const CALLBACK_PORT = 9876
8+
9+
export async function loginCommand(options: { url?: string }) {
10+
const baseUrl = getBaseUrl(options.url)
11+
12+
console.log(pc.dim(`Server: ${baseUrl}`))
13+
14+
// 1. Get the WorkOS auth URL from the server
15+
let authRes: Response
16+
try {
17+
authRes = await fetch(
18+
`${baseUrl}/auth/url?redirect_uri=${encodeURIComponent(`http://localhost:${CALLBACK_PORT}/callback`)}`,
19+
)
20+
} catch {
21+
console.error(pc.red(`Cannot reach server at ${baseUrl}`))
22+
console.error(pc.dim('Check the URL and ensure the server is running.'))
23+
process.exit(1)
24+
return
25+
}
26+
if (!authRes.ok) {
27+
console.error(pc.red(`Failed to get auth URL: ${authRes.status} ${await authRes.text()}`))
28+
process.exit(1)
29+
}
30+
const { url: authUrl } = (await authRes.json()) as { url: string }
31+
32+
// 2. Start local callback server
33+
const { code, state } = await captureCallback(authUrl)
34+
35+
// 3. Exchange code for tokens via the server
36+
console.log(pc.dim('Exchanging code for tokens...'))
37+
const callbackRes = await post('/auth/callback', { code, state }, baseUrl)
38+
if (!callbackRes.ok) {
39+
const body = await callbackRes.text()
40+
console.error(pc.red(`Authentication failed: ${body}`))
41+
process.exit(1)
42+
}
43+
44+
const data = (await callbackRes.json()) as Credentials
45+
46+
// 4. Save credentials and config
47+
saveCredentials(data)
48+
saveConfig({ url: baseUrl })
49+
50+
console.log()
51+
console.log(pc.green('✓ Logged in successfully'))
52+
console.log(` ${pc.bold(data.user.email)} (${data.org.name})`)
53+
console.log(` Role: ${data.user.role}`)
54+
console.log()
55+
}
56+
57+
function captureCallback(authUrl: string): Promise<{ code: string; state: string }> {
58+
return new Promise((resolve, reject) => {
59+
const server = http.createServer((req, res) => {
60+
const url = new URL(req.url ?? '/', `http://localhost:${CALLBACK_PORT}`)
61+
62+
if (url.pathname !== '/callback') {
63+
res.writeHead(404)
64+
res.end('Not found')
65+
return
66+
}
67+
68+
const code = url.searchParams.get('code')
69+
const state = url.searchParams.get('state')
70+
71+
if (!code) {
72+
const error = url.searchParams.get('error') ?? 'No code received'
73+
res.writeHead(400, { 'Content-Type': 'text/html' })
74+
res.end(errorPage(error))
75+
server.close()
76+
reject(new Error(error))
77+
return
78+
}
79+
80+
res.writeHead(200, { 'Content-Type': 'text/html' })
81+
res.end(successPage())
82+
server.close()
83+
resolve({ code, state: state ?? '' })
84+
})
85+
86+
server.listen(CALLBACK_PORT, () => {
87+
console.log(pc.dim(`Listening on http://localhost:${CALLBACK_PORT}`))
88+
console.log('Opening browser for authentication...')
89+
console.log()
90+
open(authUrl)
91+
})
92+
93+
server.on('error', (err) => {
94+
if ((err as NodeJS.ErrnoException).code === 'EADDRINUSE') {
95+
reject(new Error(`Port ${CALLBACK_PORT} is in use. Close the process using it and try again.`))
96+
} else {
97+
reject(err)
98+
}
99+
})
100+
101+
// Timeout after 5 minutes
102+
setTimeout(() => {
103+
server.close()
104+
reject(new Error('Login timed out after 5 minutes'))
105+
}, 5 * 60 * 1000)
106+
})
107+
}
108+
109+
function successPage(): string {
110+
return `<!DOCTYPE html>
111+
<html><head><title>AgentOps</title></head>
112+
<body style="font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#0a0a0a;color:#fafafa">
113+
<div style="text-align:center">
114+
<h1>Authenticated</h1>
115+
<p>You can close this tab and return to the terminal.</p>
116+
</div>
117+
</body></html>`
118+
}
119+
120+
function errorPage(error: string): string {
121+
return `<!DOCTYPE html>
122+
<html><head><title>AgentOps</title></head>
123+
<body style="font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#0a0a0a;color:#fafafa">
124+
<div style="text-align:center">
125+
<h1>Authentication Failed</h1>
126+
<p>${error}</p>
127+
</div>
128+
</body></html>`
129+
}

src/lib/api.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { loadCredentials, loadConfig } from './credentials.js'
2+
3+
export function getBaseUrl(urlOverride?: string): string {
4+
if (urlOverride) return urlOverride.replace(/\/$/, '')
5+
const config = loadConfig()
6+
if (config?.url) return config.url.replace(/\/$/, '')
7+
return 'https://agentops.bonnard.ai'
8+
}
9+
10+
export async function get(path: string, baseUrl: string): Promise<Response> {
11+
return fetch(`${baseUrl}${path}`, {
12+
headers: getHeaders(),
13+
})
14+
}
15+
16+
export async function post(path: string, body: unknown, baseUrl: string): Promise<Response> {
17+
return fetch(`${baseUrl}${path}`, {
18+
method: 'POST',
19+
headers: { ...getHeaders(), 'Content-Type': 'application/json' },
20+
body: JSON.stringify(body),
21+
})
22+
}
23+
24+
function getHeaders(): Record<string, string> {
25+
const headers: Record<string, string> = {}
26+
const creds = loadCredentials()
27+
if (creds) {
28+
headers['Authorization'] = `Bearer ${creds.accessToken}`
29+
}
30+
return headers
31+
}

src/lib/credentials.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import fs from 'node:fs'
2+
import path from 'node:path'
3+
import os from 'node:os'
4+
5+
const AGENTOPS_DIR = path.join(os.homedir(), '.agentops')
6+
const CREDENTIALS_PATH = path.join(AGENTOPS_DIR, 'credentials.json')
7+
const CONFIG_PATH = path.join(AGENTOPS_DIR, 'config.json')
8+
9+
export interface Credentials {
10+
accessToken: string
11+
refreshToken: string | null
12+
user: {
13+
id: number
14+
email: string
15+
name: string
16+
role: string
17+
}
18+
org: {
19+
id: number
20+
slug: string
21+
name: string
22+
}
23+
}
24+
25+
export interface Config {
26+
url: string
27+
}
28+
29+
function ensureDir() {
30+
if (!fs.existsSync(AGENTOPS_DIR)) {
31+
fs.mkdirSync(AGENTOPS_DIR, { recursive: true })
32+
}
33+
}
34+
35+
export function saveCredentials(creds: Credentials): void {
36+
ensureDir()
37+
fs.writeFileSync(CREDENTIALS_PATH, JSON.stringify(creds, null, 2), { mode: 0o600 })
38+
}
39+
40+
export function loadCredentials(): Credentials | null {
41+
try {
42+
const raw = fs.readFileSync(CREDENTIALS_PATH, 'utf-8')
43+
return JSON.parse(raw) as Credentials
44+
} catch {
45+
return null
46+
}
47+
}
48+
49+
export function clearCredentials(): void {
50+
try {
51+
fs.unlinkSync(CREDENTIALS_PATH)
52+
} catch {
53+
// Already gone
54+
}
55+
}
56+
57+
export function saveConfig(config: Config): void {
58+
ensureDir()
59+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2))
60+
}
61+
62+
export function loadConfig(): Config | null {
63+
try {
64+
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8')
65+
return JSON.parse(raw) as Config
66+
} catch {
67+
return null
68+
}
69+
}

0 commit comments

Comments
 (0)