Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Fastify • OpenAPI • Next.js • Expo — one stack, multiple platforms.
## Packages

- **[@repo/core](packages/core/README.md)** — Runtime-agnostic API client and types generated from OpenAPI specs
- **[@repo/cli](packages/cli/README.md)** — TypeScript CLI for API (API key auth; ideal for agentic integrations)
- **[@repo/react](packages/react/README.md)** — React Query hooks for `@repo/core` API functions
- **[@repo/ui](packages/ui/README.md)** — Shared UI component library (Shadcn/ui, Tailwind)
- **[@repo/utils](packages/utils/README.md)** — Shared utilities (async, data, debug, error, logger, web3)
Expand Down
44 changes: 44 additions & 0 deletions apps/docu/content/docs/development/cli.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
title: "CLI Package"
description: "TypeScript CLI to interact with the Basilic API via API key. Ideal for agentic integrations."
---

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.

## Auth

1. Env var: `API_KEY` or `BASILIC_API_KEY`
2. Config file: `~/.config/basilic/config.json`
3. Interactive prompt (saves to config)

```bash
basilic config set-api-key bask_xxx_yyy
```

## Commands

Commands mirror core API nesting: `health-check`, `account apikeys create`, `account apikeys list`, `ai chat`, etc. Use `--help` for OpenAPI-derived descriptions.

```bash
basilic health-check
basilic account apikeys list
basilic ai chat --body '{"messages":[{"role":"user","content":"Hello"}]}'
```

## Local testing

1. Start API: `pnpm dev`
2. Create API key via dashboard or `POST /account/apikeys/` with JWT
3. Run: `API_KEY=bask_xxx node packages/cli/dist/cli.js health-check`

See [packages/cli/README.md](https://github.com/blockmatic/basilic/blob/main/packages/cli/README.md) for full instructions.

## Agentic integrations

The CLI is suited for AI agents (OpenClaw, Cursor Composer, etc.) as a simpler alternative to MCP:

- Standard stdout/JSON
- No server setup
- Easy to wrap in scripts: `result=$(basilic ai chat --body '...')`

Agents can shell out to the CLI and parse JSON output without running an MCP server.
1 change: 1 addition & 0 deletions apps/docu/content/docs/development/packages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Examples:
| Package | What it is | Entrypoints |
| --- | --- | --- |
| `@repo/core` | Generated OpenAPI client + types | `@repo/core` |
| `@repo/cli` | CLI for Basilic API (API key only) | `basilic` bin |
| `@repo/react` | React Query hooks + React utilities | `@repo/react` |
| `@repo/utils` | Cross-runtime utilities | `@repo/utils/*` (prefer subpaths) |
| `@repo/ui` | Shared shadcn/ui components | `@repo/ui/components/*`, `@repo/ui/lib/*`, `@repo/ui/radix` |
Expand Down
9 changes: 9 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,15 @@
}
}
},
{
"includes": ["**/packages/cli/src/cli.ts", "**/packages/cli/src/gen/**"],
"linter": {
"rules": {
"suspicious": { "noConsole": "off" },
"style": { "noNonNullAssertion": "off" }
}
}
},
{
"includes": [
"**/mdx-components.tsx",
Expand Down
52 changes: 52 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# @repo/cli

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.

## Usage

```bash
pnpm --filter @repo/cli build
node packages/cli/dist/cli.js --help
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Auth

Requires an API key. Resolved in order:

1. `API_KEY` or `BASILIC_API_KEY` env var
2. Config file (`~/.config/basilic/config.json` or `$XDG_CONFIG_HOME/basilic/config.json`)
3. Interactive prompt (saves to config)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
```bash
# Set via env
export API_KEY=bask_xxx_yyy

# Or save to config
basilic config set-api-key bask_xxx_yyy
```

## Commands

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.

## Local testing

1. Start API: `pnpm dev`
2. Create API key via dashboard or `POST /account/apikeys/` with JWT
3. Run:

```bash
API_KEY=bask_xxx node packages/cli/dist/cli.js health-check
API_KEY=bask_xxx node packages/cli/dist/cli.js account apikeys list
```

4. Or build and run:

```bash
pnpm --filter @repo/cli build
pnpm --filter @repo/cli exec basilic health-check
```

## Agentic integrations

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.
24 changes: 24 additions & 0 deletions packages/cli/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { config } from '@repo/eslint-config/library'

export default [
...config,
{
ignores: ['**/gen/**', '**/*.gen.ts', '**/*.gen.js'],
},
{
files: ['src/config.ts'],
rules: {
'no-restricted-properties': 'off',
'turbo/no-undeclared-env-vars': 'off',
},
},
{
files: ['scripts/**/*.mjs'],
languageOptions: {
globals: {
console: 'readonly',
process: 'readonly',
},
},
},
]
36 changes: 36 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"name": "@repo/cli",
"version": "0.0.0",
"type": "module",
"private": true,
"description": "TypeScript CLI to interact with Basilic Fastify API via API key",
"bin": {
"basilic": "./dist/cli.js"
},
"exports": {
".": {
"types": "./src/cli.ts",
"import": "./dist/cli.js",
"default": "./dist/cli.js"
}
},
"scripts": {
"build": "tsup",
"checktypes": "tsgo --noEmit",
"generate": "node --import tsx scripts/generate-cli.mjs",
"lint:eslint": "eslint . --max-warnings 0",
"lint:eslint:fix": "eslint . --fix --max-warnings 0",
"lint:biome": "biome check .",
"lint:biome:fix": "biome check . --write"
},
"dependencies": {
"@repo/core": "workspace:*",
"commander": "^13.1.0"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"tsup": "^8.5.1",
"tsx": "^4.21.0"
}
}
137 changes: 137 additions & 0 deletions packages/cli/scripts/generate-cli.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'

const scriptFile = fileURLToPath(import.meta.url)
const scriptDir = dirname(scriptFile)
const openapiPath = join(scriptDir, '../../../apps/fastify/openapi/openapi.json')
const outputDir = join(scriptDir, '../src/gen')
const outputPath = join(outputDir, 'commands.gen.ts')

// Convert string to camelCase; strip {...} from path params for valid keys
function toCamelCase(str) {
const cleaned = str.replace(/^\{|\}$/g, '')
return cleaned.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
}

// camelCase to kebab-case for CLI
function toKebabCase(str) {
return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
}

// Extract action key from operationId (e.g. accountApikeysCreate -> create)
function toActionKey(operationId) {
const match = operationId.match(/[A-Z][a-z]+$/)
return match ? match[0].toLowerCase() : operationId
}

// Build nested object structure from path segments (same as core generate-wrapper)
function buildNestedObject(obj, segments, operationId) {
if (segments.length === 0) return operationId

const [first, ...rest] = segments
const key = toCamelCase(first)

if (rest.length === 0) {
const existing = obj[key]
if (typeof existing === 'string')
obj[key] = { [toActionKey(existing)]: existing, [toActionKey(operationId)]: operationId }
else if (existing && typeof existing === 'object' && !Array.isArray(existing))
obj[key][toActionKey(operationId)] = operationId
else obj[key] = operationId

return obj
}

const existing = obj[key]
if (typeof existing === 'string') obj[key] = { [toActionKey(existing)]: existing }

if (!obj[key] || typeof obj[key] === 'string') obj[key] = {}

buildNestedObject(obj[key], rest, operationId)
return obj
}

// Traverse nested structure and collect { path, operationId } for each leaf
function collectCommandSpecs(obj, pathPrefix = [], acc = []) {
for (const [key, value] of Object.entries(obj)) {
const path = [...pathPrefix, key]
if (typeof value === 'string') acc.push({ path, operationId: value })
else collectCommandSpecs(value, path, acc)
}
return acc
}

// Extract path and body params from OpenAPI operation
function getParams(operation) {
const pathParams = []
const bodyParams = []
const params = operation.parameters ?? []
for (const p of params) if (p.in === 'path' && p.name) pathParams.push({ name: p.name })

const body = operation.requestBody?.content?.['application/json']?.schema
if (body?.properties) for (const name of Object.keys(body.properties)) bodyParams.push({ name })

return { pathParams, bodyParams }
}

// Read OpenAPI spec
const openapiSpec = JSON.parse(readFileSync(openapiPath, 'utf-8'))
const paths = openapiSpec.paths || {}
const nestedStructure = {}

for (const [path, methods] of Object.entries(paths)) {
if (path.startsWith('/auth')) continue
if (typeof methods !== 'object' || methods === null) continue

for (const [method, operation] of Object.entries(methods)) {
if (!['get', 'post', 'put', 'patch', 'delete', 'options', 'head'].includes(method)) continue
if (typeof operation !== 'object' || operation === null) continue

let operationId = operation.operationId
if (!operationId) operationId = method.toLowerCase()

const pathSegments = path.split('/').filter(Boolean)
if (path === '/') continue

if (pathSegments.length === 1) {
nestedStructure[operationId] = operationId
continue
}

buildNestedObject(nestedStructure, pathSegments, operationId)
}
}

const rawSpecs = collectCommandSpecs(nestedStructure)
const operationMeta = {}
const commandSpecs = []

for (const { path, operationId } of rawSpecs) {
let op
for (const [p, methods] of Object.entries(paths)) {
if (p.startsWith('/auth')) continue
for (const [, operation] of Object.entries(methods ?? {}))
if (operation?.operationId === operationId) {
op = operation
break
}
}
const { pathParams, bodyParams } = op ? getParams(op) : { pathParams: [], bodyParams: [] }
const summary = op?.summary ?? operationId
const description = op?.description ?? summary

operationMeta[operationId] = { summary, description, pathParams, bodyParams }
const cliPath = path.map(s => (s.includes('-') ? s : toKebabCase(s)))
commandSpecs.push({ path: cliPath, operationId })
}

const output = `// This file is auto-generated. Do not edit manually.

export const operationMeta = ${JSON.stringify(operationMeta, null, 2)} as const

export const commandSpecs = ${JSON.stringify(commandSpecs, null, 2)} as const
`

mkdirSync(outputDir, { recursive: true })
writeFileSync(outputPath, output, 'utf-8')
Loading
Loading