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
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,17 @@ norbix hub email templates get # email/sms/push route into notif
```

The first positional value becomes `id`; `--field value` flags become request
fields. Destructive verbs (delete, remove, rotate, ...) ask for confirmation
unless `--yes`. Add `--dry-run` to preview the exact SDK call.
| `norbix autocomplete` | Set up shell tab-completion (bash/zsh) |
fields, typed from the SDK's request type (`norbix hub <module> --help` lists
every method with its fields; `--json` returns them as data). Force a type
with `--field:str`, `--field:num`, `--field:bool` or `--field:json`, or pass
the whole request with `--body '<json>'`. Destructive verbs (delete, remove,
stop, disable, block, regenerate, rotate) ask for confirmation unless
`--yes` — and exit 3 without a terminal. Add `--dry-run` to preview the exact
HTTP request.

`norbix schema --json` describes every command (args, flags, examples,
destructive, dry-run) for scripts and coding agents; `norbix autocomplete`
sets up shell tab-completion (bash/zsh).

Run `norbix <topic> --help` for flags and examples.

Expand Down
23 changes: 21 additions & 2 deletions docs/tasks/agent-native-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ code (parallel work), colour, new runtime dependencies.
`login`/`configure` guarded, "Cancelled" → exit 9, tests — **done**
2. PR 2 — discovery: `norbix schema`, `--dry-run` on every mutating command,
realistic `--help` examples, hub/api parser fixes (`--body`, typed
fields, boolean traps) — todo
fields, boolean traps) — **done**
3. PR 3 — startup (`oclif manifest` in build, lazy prompts), docs
(`agent-contract.md`, `AGENTS.md`), `norbix ai init`, README section — todo

Expand All @@ -389,6 +389,14 @@ code (parallel work), colour, new runtime dependencies.
| `package.json` | `oclif.hooks` | 1 |
| `tests/_cli.ts`, `tests/agent-mode.test.ts`, `tests/cli-error.test.ts` | new: child-process tests against a fake gateway; envelope unit tests | 1 |
| `README.md` | "Errors and exit codes" section | 1 |
| `scripts/gen-request-fields.mjs`, `src/generated/request-fields.json` | new: request-field map generated from the installed SDK (578 methods: route, DTO, fields with kind/required); `npm run gen:fields`; copied into `dist/` by the build | 2 |
| `src/lib/request-fields.ts` | new: lazy loader + helpers for the map (no oclif import) | 2 |
| `src/lib/dispatch.ts` | two-step typing (`parseArgv` → `typeFields` with the method's kinds), `--field:str/:num/:bool/:json`, boolean flags give the next token back, disable/block destructive | 2 |
| `src/lib/namespace-command.ts`, `hub.ts`, `api.ts` | `--body` (rejects mixing with `--field`), fields in scope help and in `--json` listings, value rules text | 2 |
| `src/lib/schema.ts`, `src/commands/schema.ts` | new: `norbix schema [command] --json` (lib has no oclif import) | 2 |
| 35 mutating command files | `...BaseCommand.dryRunFlags`; explicit dry runs in `config set/unset`, `env use`, `logout`, `raw`, `files publish/unpublish` (`publicFilesRequest`) | 2 |
| every command file | realistic example values; a `--dry-run` example on every mutating command, `--yes` on every destructive one | 2 |
| `tests/schema.test.ts`, `tests/dispatch.test.ts`, `tests/agent-mode.test.ts` | schema snapshot + completeness, parser unit tests, dry-run on non-destructive / local / raw-fetch commands, `--body` | 2 |

### Findings

Expand All @@ -415,9 +423,20 @@ code (parallel work), colour, new runtime dependencies.
(heavy load); the before/after numbers in the report use the best of
several runs.

- The SDK's `hub2.dtos.d.ts` names some request DTOs without the `Request`
suffix (`DeleteSchedulerTask`) — the generator keys on the JSDoc
"Request DTO:" line in `dist/index.js`, so it does not matter.
- `files upload` runs three calls (signed URL, PUT, commit); `--dry-run`
stops at the first SDK call and shows that one.
- `looksLikeId`: "v2", "PROD", "SMS" are still values, not words. Documented
in the scope help ("words are lowercase letters") instead of changed —
every current method is reachable with lowercase words.

### Rejected / moved out

- (none yet)
- Parser: changing `looksLikeId` (uppercase = value) was part of the ask
(§2.5); kept and documented, because an uppercase token is almost always
an environment name or an id, and lowercase words reach every method.

### Needs you

Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,15 @@
],
"scripts": {
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
"build": "npm run clean && tsc",
"build": "npm run clean && tsc && node -e \"require('node:fs').cpSync('src/generated','dist/generated',{recursive:true})\"",
"typecheck": "tsc --noEmit",
"dev": "npm run build && node ./bin/run.js",
"prepack": "npm run build && oclif manifest",
"postpack": "node -e \"require('node:fs').rmSync('oclif.manifest.json',{force:true})\"",
"test": "vitest run",
"test:watch": "vitest",
"pretest": "npm run build"
"pretest": "npm run build",
"gen:fields": "node scripts/gen-request-fields.mjs"
},
"dependencies": {
"@inquirer/prompts": "^8.7.2",
Expand Down
180 changes: 180 additions & 0 deletions scripts/gen-request-fields.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
#!/usr/bin/env node
/**
* Generates src/generated/request-fields.json from the installed
* `@norbix.ai/ts`: for every SDK method of `api.*` and `hub.*`, the HTTP
* method, the path, the request DTO and its fields (name, type, required).
*
* `norbix hub <module> --help` lists the fields from it, and the plain-word
* parser uses it to type values (a string field stays "0042", a boolean flag
* does not swallow the next word). Run `npm run gen:fields` after bumping the
* SDK and commit the result.
*
* Sources (no runtime cost — pure text parsing at build time):
* dist/index.js method → {target, path, method, dto} (JSDoc + send call)
* dist/types/*.dtos.d.ts dto → fields (class bodies)
*/
import {readFileSync, writeFileSync, mkdirSync} from 'node:fs'
import {createRequire} from 'node:module'
import {dirname, join} from 'node:path'
import {fileURLToPath} from 'node:url'

const require = createRequire(import.meta.url)
const root = dirname(dirname(fileURLToPath(import.meta.url)))
// The package exports map hides package.json; resolve the entry point and walk up.
const sdkDir = dirname(dirname(require.resolve('@norbix.ai/ts')))
const sdkVersion = JSON.parse(readFileSync(join(sdkDir, 'package.json'), 'utf8')).version
const indexJs = readFileSync(join(sdkDir, 'dist/index.js'), 'utf8')

// ---- 1. module classes → methods -------------------------------------------

/** `var FooModule = class {` … up to the next top-level `var X = class`. */
function classBlocks(source) {
const blocks = new Map()
const re = /^var (\w+) = class \{$/gm
const starts = []
let m
while ((m = re.exec(source))) starts.push({name: m[1], at: m.index})
for (let i = 0; i < starts.length; i++) {
const end = i + 1 < starts.length ? starts[i + 1].at : source.length
blocks.set(starts[i].name, source.slice(starts[i].at, end))
}

return blocks
}

const METHOD_RE =
/\/\*\*\s*\n\s*\* (GET|POST|PUT|PATCH|DELETE) (\S+)\s*\n\s*\* Request DTO: (\w+)\s*\n\s*\*\/\s*\n\s*(\w+) = \(request = \{\}, options = \{\}\) => \{\s*\n\s*return this\.transport\.send\(\{\s*\n\s*target: "(api|hub)",/g

function methodsOf(block) {
const out = {}
let m
while ((m = METHOD_RE.exec(block))) {
const [, http, path, dto, name, target] = m
out[name] = {http, path, dto, target}
}

return out
}

/** `this.membership = new MembershipModule(transport)` inside a namespace class. */
function modulesOf(block) {
const out = {}
const re = /this\.(\w+) = new (\w+)\(transport\)/g
let m
while ((m = re.exec(block))) out[m[1]] = m[2]
return out
}

const blocks = classBlocks(indexJs)
const namespaces = {api: modulesOf(blocks.get('ApiNamespace')), hub: modulesOf(blocks.get('HubNamespace'))}

// ---- 2. DTO declarations → fields -------------------------------------------

function parseDts(file) {
const text = readFileSync(file, 'utf8')
const lines = text.split('\n')
const classes = new Map()
const enums = new Map()
for (let i = 0; i < lines.length; i++) {
const cls = lines[i].match(/^\s+class (\w+)(?: extends (\w+))?/)
if (cls) {
const fields = []
for (let j = i + 1; j < lines.length; j++) {
const line = lines[j]
if (/^\s+constructor\(|^\s+\}/.test(line)) break
const f = line.match(/^\s+(\w+)(\?)?: ([^;]+);$/)
if (f) fields.push({name: f[1], type: f[3].trim(), required: f[2] === undefined})
}

classes.set(cls[1], {extends: cls[2], fields})
continue
}

const en = lines[i].match(/^\s+enum (\w+)/)
if (en) {
const values = []
for (let j = i + 1; j < lines.length && !/^\s+\}/.test(lines[j]); j++) {
const v = lines[j].match(/=\s*"([^"]*)"/)
if (v) values.push(v[1])
}

enums.set(en[1], values)
}
}

return {classes, enums}
}

/** Request base classes carry headers / path tokens the CLI fills itself. */
const HIDDEN_FIELDS = new Set(['cultureCode', 'timeZoneId', 'version', 'correlationId', 'projectId', 'env', 'resolvedEnv'])

function kindOf(type, enums, classes) {
const array = type.endsWith('[]')
const base = array ? type.slice(0, -2) : type
let kind
let options
if (base === 'string') kind = 'string'
else if (base === 'number') kind = 'number'
else if (base === 'boolean') kind = 'boolean'
else if (enums.has(base)) {
kind = 'string'
options = enums.get(base)
} else if (/^(string|number|boolean) \| /.test(type)) kind = 'json'
else if (/Id$/.test(base) && classes.has(base)) kind = 'string'
else kind = 'json'
return {kind: array ? `${kind}[]` : kind, options}
}

function fieldsOf(dto, {classes, enums}) {
const seen = new Map()
let name = dto
const chain = []
while (name && classes.has(name)) {
chain.unshift(classes.get(name))
name = classes.get(name).extends
}

for (const cls of chain) {
for (const f of cls.fields) {
if (HIDDEN_FIELDS.has(f.name)) continue
const {kind, options} = kindOf(f.type, enums, classes)
const entry = {name: f.name, type: f.type, kind, required: f.required}
if (options) entry.options = options
seen.set(f.name, entry)
}
}

return [...seen.values()]
}

const dts = {
api: parseDts(join(sdkDir, 'dist/types/api2.dtos.d.ts')),
hub: parseDts(join(sdkDir, 'dist/types/hub2.dtos.d.ts')),
}

// ---- 3. assemble --------------------------------------------------------------

const out = {sdk: `@norbix.ai/ts@${sdkVersion}`, api: {}, hub: {}}
let methodCount = 0
let missingDto = 0
for (const target of ['api', 'hub']) {
for (const [moduleName, className] of Object.entries(namespaces[target])) {
const block = blocks.get(className)
if (!block) continue
const methods = methodsOf(block)
const entry = {}
for (const [method, info] of Object.entries(methods)) {
const fields = dts[target].classes.has(info.dto) ? fieldsOf(info.dto, dts[target]) : null
if (!fields) missingDto++
entry[method] = {http: info.http, path: info.path, dto: info.dto, fields: fields ?? []}
methodCount++
}

out[target][moduleName] = entry
}
}

const target = join(root, 'src/generated/request-fields.json')
mkdirSync(dirname(target), {recursive: true})
writeFileSync(target, JSON.stringify(out) + '\n')
console.log(`wrote ${target}: ${methodCount} methods (${missingDto} without a DTO declaration), sdk ${sdkVersion}`)
2 changes: 1 addition & 1 deletion src/commands/ai/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {BaseCommand} from '../../base.js'
export default class AiLlm extends BaseCommand {
static description = 'Show one LLM integration'

static examples = ['<%= config.bin %> ai llm 66b2f0a1...']
static examples = ['<%= config.bin %> ai llm 66b2f0a1c3d4e5f6a7b8c9d0']

static args = {
id: Args.string({required: true, description: 'Integration ID'}),
Expand Down
9 changes: 8 additions & 1 deletion src/commands/ai/llm/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,19 @@ import {BaseCommand} from '../../../base.js'
export default class AiLlmDefault extends BaseCommand {
static description = 'Make an LLM integration the project default'

static examples = ['<%= config.bin %> ai llm default 66b2f0a1...']
static examples = [
'<%= config.bin %> ai llm default 66b2f0a1c3d4e5f6a7b8c9d0',
'<%= config.bin %> ai llm default 66b2f0a1c3d4e5f6a7b8c9d0 --dry-run',
]

static args = {
id: Args.string({required: true, description: 'Integration ID'}),
}

static flags = {
...BaseCommand.dryRunFlags,
}

async run(): Promise<unknown> {
const {args, flags} = await this.parse(AiLlmDefault)
const client = this.client(flags)
Expand Down
15 changes: 7 additions & 8 deletions src/commands/ai/llm/delete.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,28 @@
import {confirm} from '@inquirer/prompts'
import {Args, Flags} from '@oclif/core'
import {Args} from '@oclif/core'

import {BaseCommand} from '../../../base.js'

export default class AiLlmDelete extends BaseCommand {
static description = 'Delete an LLM integration'

static examples = ['<%= config.bin %> ai llm delete 66b2f0a1... --yes']
static examples = [
'<%= config.bin %> ai llm delete 66b2f0a1c3d4e5f6a7b8c9d0 --yes',
'<%= config.bin %> ai llm delete 66b2f0a1c3d4e5f6a7b8c9d0 --dry-run',
]

static args = {
id: Args.string({required: true, description: 'Integration ID'}),
}

static flags = {
yes: Flags.boolean({char: 'y', description: 'Skip the confirmation prompt', default: false}),
...BaseCommand.mutatingFlags,
}

async run(): Promise<unknown> {
const {args, flags} = await this.parse(AiLlmDelete)
const client = this.client(flags)

if (!flags.yes && process.stdout.isTTY) {
const ok = await confirm({message: `Delete LLM integration ${args.id}?`, default: false})
if (!ok) return this.print('Cancelled.')
}
await this.confirmOrFail(`Delete LLM integration ${args.id}?`, flags)

const res = await client.hub.ai.deleteLlmIntegration({id: args.id})

Expand Down
10 changes: 9 additions & 1 deletion src/commands/ai/llm/disable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,23 @@ import {BaseCommand} from '../../../base.js'
export default class AiLlmDisable extends BaseCommand {
static description = 'Turn off an LLM integration'

static examples = ['<%= config.bin %> ai llm disable 66b2f0a1...']
static examples = [
'<%= config.bin %> ai llm disable 66b2f0a1c3d4e5f6a7b8c9d0 --yes',
'<%= config.bin %> ai llm disable 66b2f0a1c3d4e5f6a7b8c9d0 --dry-run',
]

static args = {
id: Args.string({required: true, description: 'Integration ID'}),
}

static flags = {
...BaseCommand.mutatingFlags,
}

async run(): Promise<unknown> {
const {args, flags} = await this.parse(AiLlmDisable)
const client = this.client(flags)
await this.confirmOrFail(`Turn off LLM integration ${args.id}?`, flags)

const res = await client.hub.ai.disableLlmIntegration({id: args.id})

Expand Down
9 changes: 8 additions & 1 deletion src/commands/ai/llm/enable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,19 @@ import {BaseCommand} from '../../../base.js'
export default class AiLlmEnable extends BaseCommand {
static description = 'Turn on an LLM integration'

static examples = ['<%= config.bin %> ai llm enable 66b2f0a1...']
static examples = [
'<%= config.bin %> ai llm enable 66b2f0a1c3d4e5f6a7b8c9d0',
'<%= config.bin %> ai llm enable 66b2f0a1c3d4e5f6a7b8c9d0 --dry-run',
]

static args = {
id: Args.string({required: true, description: 'Integration ID'}),
}

static flags = {
...BaseCommand.dryRunFlags,
}

async run(): Promise<unknown> {
const {args, flags} = await this.parse(AiLlmEnable)
const client = this.client(flags)
Expand Down
2 changes: 2 additions & 0 deletions src/commands/ai/llm/save.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ update an existing integration.`
static examples = [
'<%= config.bin %> ai llm save --provider OpenAI --name OpenAI --model gpt-4o-mini --config @openai.json',
'<%= config.bin %> ai llm save --provider Ollama --name Local --endpoint http://localhost:11434 --model llama3',
'<%= config.bin %> ai llm save --provider OpenAI --name OpenAI --model gpt-4o-mini --config @openai.json --dry-run',
]

static flags = {
...BaseCommand.dryRunFlags,
provider: Flags.string({required: true, description: 'LLM provider', options: PROVIDERS}),
name: Flags.string({required: true, description: 'Integration name'}),
id: Flags.string({description: 'Integration ID — set it to update instead of create'}),
Expand Down
Loading
Loading