Skip to content

Commit ebd17e8

Browse files
authored
feat(cli): add cli package (#138)
* feat(cli): add TypeScript CLI for Basilic API - Add packages/cli with Commander, API key auth, config persistence - Exclude auth endpoints; mirror core API nesting - Generate commands from OpenAPI (scripts/generate-cli.mjs) - Config: API_KEY/BASILIC_API_KEY env or ~/.config/basilic/config.json - Docs: README, development/cli.mdx, agentic integrations note * fix(cli): resolve dead link, improve security and validation - fix cli.mdx dead link (your-org -> blockmatic) - fix README: use $XDG_CONFIG_HOME notation, remove global install - add restrictive permissions for config (0o700 dir, 0o600 file) - suppress API key echo in promptApiKey - use optsWithGlobals for nested command options - validate stream/tools/temperature in run-command - narrow biome packages/cli carve-out to cli.ts and gen/
1 parent 2680514 commit ebd17e8

File tree

16 files changed

+1106
-15
lines changed

16 files changed

+1106
-15
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ Fastify • OpenAPI • Next.js • Expo — one stack, multiple platforms.
3636
## Packages
3737

3838
- **[@repo/core](packages/core/README.md)** — Runtime-agnostic API client and types generated from OpenAPI specs
39+
- **[@repo/cli](packages/cli/README.md)** — TypeScript CLI for API (API key auth; ideal for agentic integrations)
3940
- **[@repo/react](packages/react/README.md)** — React Query hooks for `@repo/core` API functions
4041
- **[@repo/ui](packages/ui/README.md)** — Shared UI component library (Shadcn/ui, Tailwind)
4142
- **[@repo/utils](packages/utils/README.md)** — Shared utilities (async, data, debug, error, logger, web3)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
title: "CLI Package"
3+
description: "TypeScript CLI to interact with the Basilic API via API key. Ideal for agentic integrations."
4+
---
5+
6+
The `@repo/cli` package provides a command-line interface to the Basilic Fastify API. It uses `@repo/core` for all requests and supports only API key auth. Auth endpoints (magic link, OAuth, passkey, etc.) are excluded.
7+
8+
## Auth
9+
10+
1. Env var: `API_KEY` or `BASILIC_API_KEY`
11+
2. Config file: `~/.config/basilic/config.json`
12+
3. Interactive prompt (saves to config)
13+
14+
```bash
15+
basilic config set-api-key bask_xxx_yyy
16+
```
17+
18+
## Commands
19+
20+
Commands mirror core API nesting: `health-check`, `account apikeys create`, `account apikeys list`, `ai chat`, etc. Use `--help` for OpenAPI-derived descriptions.
21+
22+
```bash
23+
basilic health-check
24+
basilic account apikeys list
25+
basilic ai chat --body '{"messages":[{"role":"user","content":"Hello"}]}'
26+
```
27+
28+
## Local testing
29+
30+
1. Start API: `pnpm dev`
31+
2. Create API key via dashboard or `POST /account/apikeys/` with JWT
32+
3. Run: `API_KEY=bask_xxx node packages/cli/dist/cli.js health-check`
33+
34+
See [packages/cli/README.md](https://github.com/blockmatic/basilic/blob/main/packages/cli/README.md) for full instructions.
35+
36+
## Agentic integrations
37+
38+
The CLI is suited for AI agents (OpenClaw, Cursor Composer, etc.) as a simpler alternative to MCP:
39+
40+
- Standard stdout/JSON
41+
- No server setup
42+
- Easy to wrap in scripts: `result=$(basilic ai chat --body '...')`
43+
44+
Agents can shell out to the CLI and parse JSON output without running an MCP server.

apps/docu/content/docs/development/packages.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ Examples:
3737
| Package | What it is | Entrypoints |
3838
| --- | --- | --- |
3939
| `@repo/core` | Generated OpenAPI client + types | `@repo/core` |
40+
| `@repo/cli` | CLI for Basilic API (API key only) | `basilic` bin |
4041
| `@repo/react` | React Query hooks + React utilities | `@repo/react` |
4142
| `@repo/utils` | Cross-runtime utilities | `@repo/utils/*` (prefer subpaths) |
4243
| `@repo/ui` | Shared shadcn/ui components | `@repo/ui/components/*`, `@repo/ui/lib/*`, `@repo/ui/radix` |

biome.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,15 @@
146146
}
147147
}
148148
},
149+
{
150+
"includes": ["**/packages/cli/src/cli.ts", "**/packages/cli/src/gen/**"],
151+
"linter": {
152+
"rules": {
153+
"suspicious": { "noConsole": "off" },
154+
"style": { "noNonNullAssertion": "off" }
155+
}
156+
}
157+
},
149158
{
150159
"includes": [
151160
"**/mdx-components.tsx",

packages/cli/README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# @repo/cli
2+
3+
TypeScript CLI to interact with the Basilic Fastify API via `@repo/core`. API key auth only; auth endpoints excluded. Ideal for agentic integrations (e.g. OpenClaw) as a simpler alternative to MCP.
4+
5+
## Usage
6+
7+
```bash
8+
pnpm --filter @repo/cli build
9+
node packages/cli/dist/cli.js --help
10+
```
11+
12+
## Auth
13+
14+
Requires an API key. Resolved in order:
15+
16+
1. `API_KEY` or `BASILIC_API_KEY` env var
17+
2. Config file (`~/.config/basilic/config.json` or `$XDG_CONFIG_HOME/basilic/config.json`)
18+
3. Interactive prompt (saves to config)
19+
20+
```bash
21+
# Set via env
22+
export API_KEY=bask_xxx_yyy
23+
24+
# Or save to config
25+
basilic config set-api-key bask_xxx_yyy
26+
```
27+
28+
## Commands
29+
30+
Commands mirror the core API nesting (excluding auth endpoints): `health-check`, `account apikeys create`, `account apikeys list`, `ai chat`, etc. Use `--help` on any command for OpenAPI-derived descriptions.
31+
32+
## Local testing
33+
34+
1. Start API: `pnpm dev`
35+
2. Create API key via dashboard or `POST /account/apikeys/` with JWT
36+
3. Run:
37+
38+
```bash
39+
API_KEY=bask_xxx node packages/cli/dist/cli.js health-check
40+
API_KEY=bask_xxx node packages/cli/dist/cli.js account apikeys list
41+
```
42+
43+
4. Or build and run:
44+
45+
```bash
46+
pnpm --filter @repo/cli build
47+
pnpm --filter @repo/cli exec basilic health-check
48+
```
49+
50+
## Agentic integrations
51+
52+
The CLI is designed for AI agents (OpenClaw, Cursor Composer, etc.) as a lightweight alternative to MCP: standard stdout/JSON, no server setup, easy to wrap in scripts.

packages/cli/eslint.config.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { config } from '@repo/eslint-config/library'
2+
3+
export default [
4+
...config,
5+
{
6+
ignores: ['**/gen/**', '**/*.gen.ts', '**/*.gen.js'],
7+
},
8+
{
9+
files: ['src/config.ts'],
10+
rules: {
11+
'no-restricted-properties': 'off',
12+
'turbo/no-undeclared-env-vars': 'off',
13+
},
14+
},
15+
{
16+
files: ['scripts/**/*.mjs'],
17+
languageOptions: {
18+
globals: {
19+
console: 'readonly',
20+
process: 'readonly',
21+
},
22+
},
23+
},
24+
]

packages/cli/package.json

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"name": "@repo/cli",
3+
"version": "0.0.0",
4+
"type": "module",
5+
"private": true,
6+
"description": "TypeScript CLI to interact with Basilic Fastify API via API key",
7+
"bin": {
8+
"basilic": "./dist/cli.js"
9+
},
10+
"exports": {
11+
".": {
12+
"types": "./src/cli.ts",
13+
"import": "./dist/cli.js",
14+
"default": "./dist/cli.js"
15+
}
16+
},
17+
"scripts": {
18+
"build": "tsup",
19+
"checktypes": "tsgo --noEmit",
20+
"generate": "node --import tsx scripts/generate-cli.mjs",
21+
"lint:eslint": "eslint . --max-warnings 0",
22+
"lint:eslint:fix": "eslint . --fix --max-warnings 0",
23+
"lint:biome": "biome check .",
24+
"lint:biome:fix": "biome check . --write"
25+
},
26+
"dependencies": {
27+
"@repo/core": "workspace:*",
28+
"commander": "^13.1.0"
29+
},
30+
"devDependencies": {
31+
"@repo/eslint-config": "workspace:*",
32+
"@repo/typescript-config": "workspace:*",
33+
"tsup": "^8.5.1",
34+
"tsx": "^4.21.0"
35+
}
36+
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2+
import { dirname, join } from 'node:path'
3+
import { fileURLToPath } from 'node:url'
4+
5+
const scriptFile = fileURLToPath(import.meta.url)
6+
const scriptDir = dirname(scriptFile)
7+
const openapiPath = join(scriptDir, '../../../apps/fastify/openapi/openapi.json')
8+
const outputDir = join(scriptDir, '../src/gen')
9+
const outputPath = join(outputDir, 'commands.gen.ts')
10+
11+
// Convert string to camelCase; strip {...} from path params for valid keys
12+
function toCamelCase(str) {
13+
const cleaned = str.replace(/^\{|\}$/g, '')
14+
return cleaned.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
15+
}
16+
17+
// camelCase to kebab-case for CLI
18+
function toKebabCase(str) {
19+
return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
20+
}
21+
22+
// Extract action key from operationId (e.g. accountApikeysCreate -> create)
23+
function toActionKey(operationId) {
24+
const match = operationId.match(/[A-Z][a-z]+$/)
25+
return match ? match[0].toLowerCase() : operationId
26+
}
27+
28+
// Build nested object structure from path segments (same as core generate-wrapper)
29+
function buildNestedObject(obj, segments, operationId) {
30+
if (segments.length === 0) return operationId
31+
32+
const [first, ...rest] = segments
33+
const key = toCamelCase(first)
34+
35+
if (rest.length === 0) {
36+
const existing = obj[key]
37+
if (typeof existing === 'string')
38+
obj[key] = { [toActionKey(existing)]: existing, [toActionKey(operationId)]: operationId }
39+
else if (existing && typeof existing === 'object' && !Array.isArray(existing))
40+
obj[key][toActionKey(operationId)] = operationId
41+
else obj[key] = operationId
42+
43+
return obj
44+
}
45+
46+
const existing = obj[key]
47+
if (typeof existing === 'string') obj[key] = { [toActionKey(existing)]: existing }
48+
49+
if (!obj[key] || typeof obj[key] === 'string') obj[key] = {}
50+
51+
buildNestedObject(obj[key], rest, operationId)
52+
return obj
53+
}
54+
55+
// Traverse nested structure and collect { path, operationId } for each leaf
56+
function collectCommandSpecs(obj, pathPrefix = [], acc = []) {
57+
for (const [key, value] of Object.entries(obj)) {
58+
const path = [...pathPrefix, key]
59+
if (typeof value === 'string') acc.push({ path, operationId: value })
60+
else collectCommandSpecs(value, path, acc)
61+
}
62+
return acc
63+
}
64+
65+
// Extract path and body params from OpenAPI operation
66+
function getParams(operation) {
67+
const pathParams = []
68+
const bodyParams = []
69+
const params = operation.parameters ?? []
70+
for (const p of params) if (p.in === 'path' && p.name) pathParams.push({ name: p.name })
71+
72+
const body = operation.requestBody?.content?.['application/json']?.schema
73+
if (body?.properties) for (const name of Object.keys(body.properties)) bodyParams.push({ name })
74+
75+
return { pathParams, bodyParams }
76+
}
77+
78+
// Read OpenAPI spec
79+
const openapiSpec = JSON.parse(readFileSync(openapiPath, 'utf-8'))
80+
const paths = openapiSpec.paths || {}
81+
const nestedStructure = {}
82+
83+
for (const [path, methods] of Object.entries(paths)) {
84+
if (path.startsWith('/auth')) continue
85+
if (typeof methods !== 'object' || methods === null) continue
86+
87+
for (const [method, operation] of Object.entries(methods)) {
88+
if (!['get', 'post', 'put', 'patch', 'delete', 'options', 'head'].includes(method)) continue
89+
if (typeof operation !== 'object' || operation === null) continue
90+
91+
let operationId = operation.operationId
92+
if (!operationId) operationId = method.toLowerCase()
93+
94+
const pathSegments = path.split('/').filter(Boolean)
95+
if (path === '/') continue
96+
97+
if (pathSegments.length === 1) {
98+
nestedStructure[operationId] = operationId
99+
continue
100+
}
101+
102+
buildNestedObject(nestedStructure, pathSegments, operationId)
103+
}
104+
}
105+
106+
const rawSpecs = collectCommandSpecs(nestedStructure)
107+
const operationMeta = {}
108+
const commandSpecs = []
109+
110+
for (const { path, operationId } of rawSpecs) {
111+
let op
112+
for (const [p, methods] of Object.entries(paths)) {
113+
if (p.startsWith('/auth')) continue
114+
for (const [, operation] of Object.entries(methods ?? {}))
115+
if (operation?.operationId === operationId) {
116+
op = operation
117+
break
118+
}
119+
}
120+
const { pathParams, bodyParams } = op ? getParams(op) : { pathParams: [], bodyParams: [] }
121+
const summary = op?.summary ?? operationId
122+
const description = op?.description ?? summary
123+
124+
operationMeta[operationId] = { summary, description, pathParams, bodyParams }
125+
const cliPath = path.map(s => (s.includes('-') ? s : toKebabCase(s)))
126+
commandSpecs.push({ path: cliPath, operationId })
127+
}
128+
129+
const output = `// This file is auto-generated. Do not edit manually.
130+
131+
export const operationMeta = ${JSON.stringify(operationMeta, null, 2)} as const
132+
133+
export const commandSpecs = ${JSON.stringify(commandSpecs, null, 2)} as const
134+
`
135+
136+
mkdirSync(outputDir, { recursive: true })
137+
writeFileSync(outputPath, output, 'utf-8')

0 commit comments

Comments
 (0)