@janux/cli 0.1.0 → 0.2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@janux/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Janux CLI: dev (Vite), build (client bundle) and start (production server on Bun).",
5
5
  "repository": {
6
6
  "type": "git",
@@ -20,10 +20,10 @@
20
20
  ".": "./src/index.ts"
21
21
  },
22
22
  "dependencies": {
23
- "janux": "0.1.0",
24
- "@janux/server": "0.1.0",
25
- "@janux/agent": "0.1.0",
26
- "@janux/vite": "0.1.0",
23
+ "janux": "0.2.1",
24
+ "@janux/server": "0.2.1",
25
+ "@janux/agent": "0.2.1",
26
+ "@janux/vite": "0.2.1",
27
27
  "vite": "^7.3.0"
28
28
  },
29
29
  "homepage": "https://github.com/aralroca/Janux#readme",
package/src/args.test.ts CHANGED
@@ -7,9 +7,27 @@ describe('janux CLI args', () => {
7
7
  command: 'dev',
8
8
  port: 4000,
9
9
  root: '/app',
10
+ files: [],
11
+ url: 'http://localhost:3000',
12
+ startCommand: undefined,
13
+ json: false,
10
14
  });
11
15
  expect(parseArgs(['build'], '/app').command).toBe('build');
12
16
  expect(parseArgs(['start'], '/app').command).toBe('start');
17
+ expect(parseArgs(['verify'], '/app').command).toBe('verify');
18
+ });
19
+
20
+ it('parses eval files and flags', () => {
21
+ const parsed = parseArgs(
22
+ ['eval', 'evals/a.eval.json', '--url', 'http://localhost:4000', '--start', 'janux start', '--json'],
23
+ '/app',
24
+ );
25
+
26
+ expect(parsed.command).toBe('eval');
27
+ expect(parsed.files).toEqual(['evals/a.eval.json']);
28
+ expect(parsed.url).toBe('http://localhost:4000');
29
+ expect(parsed.startCommand).toBe('janux start');
30
+ expect(parsed.json).toBe(true);
13
31
  });
14
32
 
15
33
  it('falls back to help on unknown commands', () => {
@@ -22,3 +40,11 @@ describe('janux CLI args', () => {
22
40
  expect(() => parseArgs(['dev', '--port', 'abc'], '/app')).toThrow(/--port/);
23
41
  });
24
42
  });
43
+
44
+ describe('tailwind auto-detection', () => {
45
+ it('returns undefined when @janux/tailwind is not installed', async () => {
46
+ const { loadTailwindPlugin } = await import('./commands');
47
+
48
+ expect(await loadTailwindPlugin('/tmp/definitely-not-a-janux-app')).toBeUndefined();
49
+ });
50
+ });
package/src/args.ts CHANGED
@@ -1,10 +1,15 @@
1
1
  export interface CliCommand {
2
- command: 'dev' | 'build' | 'start' | 'help';
2
+ command: 'dev' | 'build' | 'start' | 'verify' | 'eval' | 'help';
3
3
  port: number;
4
4
  root: string;
5
+ files: string[];
6
+ url: string;
7
+ startCommand?: string;
8
+ json: boolean;
5
9
  }
6
10
 
7
- const COMMANDS = new Set(['dev', 'build', 'start', 'help']);
11
+ const COMMANDS = new Set(['dev', 'build', 'start', 'verify', 'eval', 'help']);
12
+ const VALUE_FLAGS = new Set(['--port', '--url', '--start']);
8
13
 
9
14
  export const HELP_TEXT = `janux — the agent-native fullstack UI framework
10
15
 
@@ -12,14 +17,36 @@ Usage:
12
17
  janux dev [--port 3000] Start the dev server (Vite + HMR)
13
18
  janux build Bundle client assets for production
14
19
  janux start [--port 3000] Run the production server (Bun)
20
+ janux verify Check the agent surface (tool contracts)
21
+ janux eval [files...] Run agent-task scenarios (evals/**/*.eval.json)
22
+ [--url http://localhost:3000] [--start "janux start"] [--json]
15
23
  `;
16
24
 
25
+ function flagValue(argv: string[], flag: string): string | undefined {
26
+ const index = argv.indexOf(flag);
27
+
28
+ return index >= 0 ? argv[index + 1] : undefined;
29
+ }
30
+
31
+ function positionals(argv: string[]): string[] {
32
+ return argv
33
+ .slice(1)
34
+ .filter((arg, index, all) => !arg.startsWith('--') && !VALUE_FLAGS.has(all[index - 1] ?? ''));
35
+ }
36
+
17
37
  export function parseArgs(argv: string[], cwd: string): CliCommand {
18
38
  const command = COMMANDS.has(argv[0] ?? '') ? (argv[0] as CliCommand['command']) : 'help';
19
- const portFlag = argv.indexOf('--port');
20
- const port = portFlag >= 0 ? Number(argv[portFlag + 1]) : Number(process.env.PORT ?? 3000);
39
+ const port = Number(flagValue(argv, '--port') ?? process.env.PORT ?? 3000);
21
40
 
22
41
  if (Number.isNaN(port)) throw new Error('janux: --port must be a number');
23
42
 
24
- return { command, port, root: cwd };
43
+ return {
44
+ command,
45
+ port,
46
+ root: cwd,
47
+ files: positionals(argv),
48
+ url: flagValue(argv, '--url') ?? 'http://localhost:3000',
49
+ startCommand: flagValue(argv, '--start'),
50
+ json: argv.includes('--json'),
51
+ };
25
52
  }
package/src/commands.ts CHANGED
@@ -5,9 +5,31 @@ import { defineAgent } from '@janux/agent';
5
5
  import { apiFiles, apiModuleName, janux, resolveAppConfig } from '@janux/vite';
6
6
  import type { CliCommand } from './args';
7
7
 
8
+ /** Zero-config integrations: installing @janux/tailwind IS the configuration. */
9
+ export async function loadTailwindPlugin(root: string): Promise<any | undefined> {
10
+ try {
11
+ const mod = await import(Bun.resolveSync('@janux/tailwind', root));
12
+
13
+ return mod.default();
14
+ } catch {
15
+ return undefined;
16
+ }
17
+ }
18
+
19
+ /** Shared vite options: janux plugin + the tailwind postcss pipeline when installed. */
20
+ async function viteOptions(root: string): Promise<Record<string, unknown>> {
21
+ const tailwind = await loadTailwindPlugin(root);
22
+
23
+ return {
24
+ root,
25
+ plugins: [janux()],
26
+ css: tailwind ? { postcss: { plugins: [tailwind] } } : undefined,
27
+ };
28
+ }
29
+
8
30
  export async function dev({ root, port }: CliCommand): Promise<void> {
9
31
  const { createServer } = await import('vite');
10
- const server = await createServer({ root, plugins: [janux()], server: { port } });
32
+ const server = await createServer({ ...(await viteOptions(root)), server: { port } });
11
33
 
12
34
  await server.listen();
13
35
  console.log(`\n janux dev ready\n → app: http://localhost:${port}/`);
@@ -15,24 +37,77 @@ export async function dev({ root, port }: CliCommand): Promise<void> {
15
37
  console.log(` → agent: http://localhost:${port}/_janux/agent\n`);
16
38
  }
17
39
 
40
+ function bundleInputs(app: { clientEntry: string; stylesheet?: string }, tailwind: boolean) {
41
+ const input: Record<string, string> = {};
42
+
43
+ if (app.clientEntry) input.client = app.clientEntry;
44
+ if (tailwind && app.stylesheet) input.styles = app.stylesheet;
45
+
46
+ return input;
47
+ }
48
+
49
+ function cssAsStyles(info: any): string {
50
+ const name = info.names?.[0] ?? info.name ?? '';
51
+
52
+ return name.endsWith('.css') ? 'styles.css' : 'assets/[name]-[hash][extname]';
53
+ }
54
+
55
+ async function bundleClient(root: string, input: Record<string, string>): Promise<void> {
56
+ const { build: viteBuild } = await import('vite');
57
+
58
+ await viteBuild({
59
+ ...(await viteOptions(root)),
60
+ build: {
61
+ outDir: 'dist/client',
62
+ rollupOptions: {
63
+ input,
64
+ output: {
65
+ entryFileNames: (chunk: any) => (chunk.name === 'client' ? 'client.js' : '[name].js'),
66
+ assetFileNames: cssAsStyles,
67
+ },
68
+ },
69
+ },
70
+ });
71
+ }
72
+
18
73
  export async function build({ root }: CliCommand): Promise<void> {
19
74
  const app = resolveAppConfig(root);
75
+ const tailwind = await loadTailwindPlugin(root);
76
+ const input = bundleInputs(app, tailwind !== undefined);
20
77
 
21
- if (app.clientEntry) {
22
- const { build: viteBuild } = await import('vite');
78
+ if (Object.keys(input).length > 0) await bundleClient(root, input);
79
+ else console.log('janux build: nothing to bundle fully static app (0 KB JS).');
80
+ if (tailwind === undefined) copyStylesheet(app.stylesheet, root);
81
+ copyPublicDir(root);
82
+ if (app.output === 'static') await prerenderStatic(root);
83
+ }
23
84
 
24
- await viteBuild({
25
- root,
26
- plugins: [janux()],
27
- build: {
28
- outDir: 'dist/client',
29
- rollupOptions: { input: app.clientEntry, output: { entryFileNames: 'client.js' } },
30
- },
31
- });
32
- } else {
33
- console.log('janux build: no src/client.ts — fully static app, nothing to bundle (0 KB JS).');
34
- }
35
- copyStylesheet(app.stylesheet, root);
85
+ async function writePage(server: { fetch(req: Request): Promise<Response> }, outDir: string, page: string): Promise<void> {
86
+ const response = await server.fetch(new Request(`http://localhost${page}`));
87
+ const dir = join(outDir, page.slice(1));
88
+
89
+ mkdirSync(dir, { recursive: true });
90
+ await Bun.write(join(dir, 'index.html'), await response.text());
91
+ }
92
+
93
+ /** `output: "static"`: prerenders every concrete page (dynamic routes via `staticParams`) into dist/client. */
94
+ async function prerenderStatic(root: string): Promise<void> {
95
+ const server = createJanuxServer(await prodServerOptions(root));
96
+ const pages = await server.listPages();
97
+ const outDir = join(root, 'dist/client');
98
+ const concrete = pages.filter((page) => !page.includes('['));
99
+ const skipped = pages.filter((page) => page.includes('['));
100
+
101
+ skipped.forEach((page) => console.log(`janux build: skipped ${page} — dynamic route without staticParams.`));
102
+ await Promise.all(concrete.map((page) => writePage(server, outDir, page)));
103
+ await writeLlmsTxt(server, outDir);
104
+ console.log(`janux build: prerendered ${concrete.length} pages (output: static).`);
105
+ }
106
+
107
+ async function writeLlmsTxt(server: { fetch(req: Request): Promise<Response> }, outDir: string): Promise<void> {
108
+ const response = await server.fetch(new Request('http://localhost/llms.txt'));
109
+
110
+ if (response.status === 200) await Bun.write(join(outDir, 'llms.txt'), await response.text());
36
111
  }
37
112
 
38
113
  function copyStylesheet(stylesheet: string | undefined, root: string): void {
@@ -43,7 +118,14 @@ function copyStylesheet(stylesheet: string | undefined, root: string): void {
43
118
  cpSync(stylesheet, join(outDir, 'styles.css'));
44
119
  }
45
120
 
46
- async function prodServerOptions(root: string): Promise<ServerOptions> {
121
+ function copyPublicDir(root: string): void {
122
+ const publicDir = join(root, 'public');
123
+
124
+ if (!existsSync(publicDir)) return;
125
+ cpSync(publicDir, join(root, 'dist/client'), { recursive: true });
126
+ }
127
+
128
+ export async function prodServerOptions(root: string): Promise<ServerOptions> {
47
129
  const app = resolveAppConfig(root);
48
130
  const apiModules = Object.fromEntries(
49
131
  await Promise.all(
@@ -61,6 +143,7 @@ async function prodServerOptions(root: string): Promise<ServerOptions> {
61
143
  runtimeUrl: existsSync(join(root, 'dist/client/client.js')) ? '/client.js' : undefined,
62
144
  stylesheets: app.stylesheet ? ['/styles.css'] : [],
63
145
  title: app.title,
146
+ llmsTxt: app.llmsTxt,
64
147
  };
65
148
  }
66
149
 
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { deepSubset, resolveRefs, runScenario, type StepOutcome } from './eval-runner';
3
+
4
+ const outcomes: StepOutcome[] = [
5
+ { status: 200, ok: true, result: { status: 'proposal', id: 'prop_1', nested: { total: 42 } } },
6
+ ];
7
+
8
+ describe('resolveRefs', () => {
9
+ it('resolves $steps references anywhere in a value', () => {
10
+ const input = { id: '$steps[0].result.id', deep: ['$steps[0].result.nested.total'], plain: 'x' };
11
+
12
+ expect(resolveRefs(input, outcomes)).toEqual({ id: 'prop_1', deep: [42], plain: 'x' });
13
+ });
14
+
15
+ it('throws on unresolvable references', () => {
16
+ expect(() => resolveRefs('$steps[0].result.missing', outcomes)).toThrow('unresolvable');
17
+ });
18
+ });
19
+
20
+ describe('deepSubset', () => {
21
+ it('matches structural subsets and rejects mismatches', () => {
22
+ expect(deepSubset({ a: { b: 1 } }, { a: { b: 1, c: 2 }, extra: true })).toBe(true);
23
+ expect(deepSubset([{ id: 'p1' }], [{ id: 'p1', name: 'x' }, { id: 'p2' }])).toBe(true);
24
+ expect(deepSubset({ a: 1 }, { a: 2 })).toBe(false);
25
+ });
26
+ });
27
+
28
+ function fakeServer(): typeof fetch {
29
+ const respond = (body: unknown, status = 200) =>
30
+ new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
31
+
32
+ return (async (url: any, init: any) => {
33
+ const body = JSON.parse(init.body);
34
+
35
+ if (String(url).endsWith('/_janux/api/shop.pay')) {
36
+ expect(init.headers['x-janux-origin']).toBe('agent');
37
+
38
+ return respond({ ok: true, result: { status: 'proposal', id: 'prop_9', tool: 'shop.pay', input: body } });
39
+ }
40
+ if (String(url).endsWith('/_janux/approve')) {
41
+ return body.id === 'prop_9'
42
+ ? respond({ ok: true, result: { charged: 5999 } })
43
+ : respond({ ok: false, error: 'unknown proposal' }, 404);
44
+ }
45
+
46
+ return respond({ ok: false, error: 'unknown api' }, 404);
47
+ }) as typeof fetch;
48
+ }
49
+
50
+ describe('runScenario', () => {
51
+ it('carries proposal ids into approve steps and passes', async () => {
52
+ const report = await runScenario(
53
+ {
54
+ name: 'pay flow',
55
+ steps: [
56
+ { tool: 'api.shop.pay', input: { total: 5999 }, expect: { result: { status: 'proposal' } } },
57
+ { approve: '$steps[0].result.id', expect: { result: { charged: 5999 } } },
58
+ ],
59
+ },
60
+ 'http://test',
61
+ fakeServer(),
62
+ );
63
+
64
+ expect(report.pass).toBe(true);
65
+ expect(report.steps.map((step) => step.pass)).toEqual([true, true]);
66
+ });
67
+
68
+ it('fails on unmet expectations with a detail', async () => {
69
+ const report = await runScenario(
70
+ { name: 'bad', steps: [{ tool: 'shop.pay', expect: { result: { status: 'done' } } }] },
71
+ 'http://test',
72
+ fakeServer(),
73
+ );
74
+
75
+ expect(report.pass).toBe(false);
76
+ expect(report.steps[0]?.detail).toContain('result mismatch');
77
+ });
78
+
79
+ it('fails steps with unresolvable references without aborting the scenario', async () => {
80
+ const report = await runScenario(
81
+ { name: 'refs', steps: [{ approve: '$steps[5].result.id' }, { tool: 'shop.pay', expect: { result: { status: 'proposal' } } }] },
82
+ 'http://test',
83
+ fakeServer(),
84
+ );
85
+
86
+ expect(report.steps[0]?.pass).toBe(false);
87
+ expect(report.steps[1]?.pass).toBe(true);
88
+ });
89
+ });
@@ -0,0 +1,139 @@
1
+ export interface EvalExpect {
2
+ ok?: boolean;
3
+ status?: number;
4
+ error?: string;
5
+ result?: unknown;
6
+ }
7
+
8
+ export interface EvalStep {
9
+ tool?: string;
10
+ approve?: string;
11
+ input?: unknown;
12
+ expect?: EvalExpect;
13
+ }
14
+
15
+ export interface EvalScenario {
16
+ name: string;
17
+ url?: string;
18
+ steps: EvalStep[];
19
+ }
20
+
21
+ export interface StepOutcome {
22
+ status: number;
23
+ ok: boolean;
24
+ result?: unknown;
25
+ error?: string;
26
+ }
27
+
28
+ export interface StepReport {
29
+ label: string;
30
+ pass: boolean;
31
+ detail?: string;
32
+ outcome: StepOutcome;
33
+ }
34
+
35
+ export interface ScenarioReport {
36
+ name: string;
37
+ pass: boolean;
38
+ steps: StepReport[];
39
+ }
40
+
41
+ const REF_PATTERN = /^\$steps\[(\d+)\]\.(.+)$/;
42
+
43
+ function resolveRef(value: string, outcomes: StepOutcome[]): unknown {
44
+ const match = REF_PATTERN.exec(value);
45
+
46
+ if (!match) return value;
47
+ const outcome = outcomes[Number(match[1])];
48
+ const resolved = match[2]!.split('.').reduce<unknown>((acc, key) => (acc as any)?.[key], outcome);
49
+
50
+ if (resolved === undefined) throw new Error(`unresolvable reference "${value}"`);
51
+
52
+ return resolved;
53
+ }
54
+
55
+ /** Replaces `$steps[i].<dot.path>` strings anywhere in a value with earlier step outcomes. */
56
+ export function resolveRefs(value: unknown, outcomes: StepOutcome[]): unknown {
57
+ if (typeof value === 'string') return resolveRef(value, outcomes);
58
+ if (Array.isArray(value)) return value.map((item) => resolveRefs(item, outcomes));
59
+ if (value !== null && typeof value === 'object') {
60
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, resolveRefs(item, outcomes)]));
61
+ }
62
+
63
+ return value;
64
+ }
65
+
66
+ /** Structural subset match: every expected key/index must match; extra actual data is fine. */
67
+ export function deepSubset(expected: unknown, actual: unknown): boolean {
68
+ if (expected === null || typeof expected !== 'object') return Object.is(expected, actual);
69
+ if (Array.isArray(expected)) {
70
+ return Array.isArray(actual) && expected.every((item, index) => deepSubset(item, actual[index]));
71
+ }
72
+
73
+ return (
74
+ actual !== null &&
75
+ typeof actual === 'object' &&
76
+ Object.entries(expected).every(([key, item]) => deepSubset(item, (actual as any)[key]))
77
+ );
78
+ }
79
+
80
+ function checkExpect(expect: EvalExpect | undefined, outcome: StepOutcome): string | undefined {
81
+ const rules = expect ?? { ok: true };
82
+
83
+ if (rules.ok !== undefined && outcome.ok !== rules.ok) return `expected ok ${rules.ok}, got ${outcome.ok} (${outcome.error ?? ''})`;
84
+ if (rules.status !== undefined && outcome.status !== rules.status) return `expected status ${rules.status}, got ${outcome.status}`;
85
+ if (rules.error !== undefined && !String(outcome.error ?? '').includes(rules.error)) return `expected error containing "${rules.error}", got "${outcome.error}"`;
86
+ if (rules.result !== undefined && !deepSubset(rules.result, outcome.result)) return `result mismatch, got ${JSON.stringify(outcome.result)}`;
87
+
88
+ return undefined;
89
+ }
90
+
91
+ function stepRequest(step: EvalStep, outcomes: StepOutcome[]): { path: string; body: unknown; headers: Record<string, string> } {
92
+ if (step.approve) {
93
+ return { path: '/_janux/approve', body: { id: resolveRefs(step.approve, outcomes) }, headers: {} };
94
+ }
95
+ if (!step.tool) throw new Error('step needs "tool" or "approve"');
96
+
97
+ return {
98
+ path: `/_janux/api/${step.tool.replace(/^api\./, '')}`,
99
+ body: resolveRefs(step.input ?? {}, outcomes),
100
+ headers: { 'x-janux-origin': 'agent' },
101
+ };
102
+ }
103
+
104
+ async function performStep(step: EvalStep, outcomes: StepOutcome[], baseUrl: string, fetchImpl: typeof fetch): Promise<StepOutcome> {
105
+ const { path, body, headers } = stepRequest(step, outcomes);
106
+ const res = await fetchImpl(`${baseUrl}${path}`, {
107
+ method: 'POST',
108
+ body: JSON.stringify(body),
109
+ headers: { 'content-type': 'application/json', ...headers },
110
+ });
111
+ const envelope = (await res.json().catch(() => ({}))) as { ok?: boolean; result?: unknown; error?: string };
112
+
113
+ return { status: res.status, ok: envelope.ok === true, result: envelope.result, error: envelope.error };
114
+ }
115
+
116
+ /** Runs one scenario's steps in order against a live app's agent surface. */
117
+ export async function runScenario(scenario: EvalScenario, baseUrl: string, fetchImpl: typeof fetch = fetch): Promise<ScenarioReport> {
118
+ const outcomes: StepOutcome[] = [];
119
+ const steps: StepReport[] = [];
120
+
121
+ // Sequential by design: later steps reference earlier outcomes ($steps[i]).
122
+ for (const [index, step] of scenario.steps.entries()) {
123
+ const label = step.approve ? `approve ${step.approve}` : (step.tool ?? `step ${index}`);
124
+ let report: StepReport;
125
+
126
+ try {
127
+ const outcome = await performStep(step, outcomes, baseUrl, fetchImpl);
128
+ const detail = checkExpect(step.expect, outcome);
129
+
130
+ report = { label, pass: !detail, detail, outcome };
131
+ } catch (error) {
132
+ report = { label, pass: false, detail: String(error), outcome: { status: 0, ok: false, error: String(error) } };
133
+ }
134
+ outcomes.push(report.outcome);
135
+ steps.push(report);
136
+ }
137
+
138
+ return { name: scenario.name, pass: steps.every((step) => step.pass), steps };
139
+ }
package/src/eval.ts ADDED
@@ -0,0 +1,77 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { spawn, type ChildProcess } from 'node:child_process';
4
+ import { runScenario, type EvalScenario, type ScenarioReport } from './eval-runner';
5
+ import type { CliCommand } from './args';
6
+
7
+ const START_TIMEOUT_MS = 30_000;
8
+
9
+ function scenarioFiles({ files, root }: CliCommand): string[] {
10
+ if (files.length > 0) return files.map((file) => resolve(root, file));
11
+
12
+ return [...new Bun.Glob('evals/**/*.eval.json').scanSync(root)].map((file) => resolve(root, file));
13
+ }
14
+
15
+ async function waitForServer(url: string): Promise<void> {
16
+ const deadline = Date.now() + START_TIMEOUT_MS;
17
+
18
+ while (Date.now() < deadline) {
19
+ const responded = await fetch(url).then(() => true, () => false);
20
+
21
+ if (responded) return;
22
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, 500));
23
+ }
24
+ throw new Error(`janux eval: server at ${url} did not respond within ${START_TIMEOUT_MS / 1000}s`);
25
+ }
26
+
27
+ function stopApp(proc: ChildProcess): void {
28
+ if (proc.pid) {
29
+ try {
30
+ process.kill(-proc.pid, 'SIGTERM');
31
+ } catch {
32
+ proc.kill('SIGTERM');
33
+ }
34
+ }
35
+ }
36
+
37
+ function reportHuman(results: ScenarioReport[]): void {
38
+ results.forEach((result) => {
39
+ console.log(`\n${result.pass ? '✓' : '✗'} ${result.name}`);
40
+ result.steps.forEach((step) =>
41
+ console.log(` ${step.pass ? '✓' : '✗'} ${step.label}${step.detail ? ` — ${step.detail}` : ''}`),
42
+ );
43
+ });
44
+ }
45
+
46
+ async function runFiles(files: string[], baseUrl: string): Promise<ScenarioReport[]> {
47
+ const results: ScenarioReport[] = [];
48
+
49
+ // Sequential: scenarios share one live app and may mutate its state.
50
+ for (const file of files) {
51
+ const scenario = JSON.parse(readFileSync(file, 'utf8')) as EvalScenario;
52
+
53
+ results.push(await runScenario(scenario, scenario.url ?? baseUrl));
54
+ }
55
+
56
+ return results;
57
+ }
58
+
59
+ export async function evalCommand(parsed: CliCommand): Promise<void> {
60
+ const files = scenarioFiles(parsed);
61
+
62
+ if (files.length === 0) throw new Error('janux eval: no scenario files found (evals/**/*.eval.json)');
63
+ const proc = parsed.startCommand
64
+ ? spawn(parsed.startCommand, { shell: true, detached: true, stdio: 'inherit', cwd: parsed.root })
65
+ : undefined;
66
+
67
+ try {
68
+ if (proc) await waitForServer(parsed.url);
69
+ const results = await runFiles(files, parsed.url);
70
+
71
+ if (parsed.json) console.log(JSON.stringify(results, null, 2));
72
+ else reportHuman(results);
73
+ if (results.some((result) => !result.pass)) process.exitCode = 1;
74
+ } finally {
75
+ if (proc) stopApp(proc);
76
+ }
77
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { HELP_TEXT, parseArgs } from './args';
2
2
  import { build, dev, start } from './commands';
3
+ import { verify } from './verify';
4
+ import { evalCommand } from './eval';
3
5
 
4
6
  export { parseArgs, HELP_TEXT } from './args';
5
7
 
@@ -9,5 +11,7 @@ export async function runCli(argv: string[]): Promise<void> {
9
11
  if (parsed.command === 'dev') return dev(parsed);
10
12
  if (parsed.command === 'build') return build(parsed);
11
13
  if (parsed.command === 'start') return start(parsed);
14
+ if (parsed.command === 'verify') return verify(parsed);
15
+ if (parsed.command === 'eval') return evalCommand(parsed);
12
16
  console.log(HELP_TEXT);
13
17
  }
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { component, intent, jsx, schema, str } from 'janux';
3
+ import { api, createJanuxServer } from '@janux/server';
4
+ import { auditManifest, collectFindings } from './verify';
5
+
6
+ const apis = {
7
+ described: api({
8
+ description: 'Search things',
9
+ input: schema({ q: str() }),
10
+ run: ({ input }) => [input.q],
11
+ }),
12
+ undescribed: api({ guard: 'confirm', run: () => 'paid' }),
13
+ hidden: api({ guard: 'forbidden', run: () => 'secret' }),
14
+ };
15
+
16
+ const bareCart = component({
17
+ name: 'bare-cart',
18
+ state: schema({ items: str() }),
19
+ intents: { add: intent({ run: () => undefined }) },
20
+ view: () => jsx('p', { children: 'cart' }),
21
+ });
22
+
23
+ const server = createJanuxServer({
24
+ routes: {
25
+ '/': () => jsx('main', {}),
26
+ '/shop': () => jsx('div', { children: jsx(bareCart as any, {}) }),
27
+ '/boom': () => {
28
+ throw new Error('render exploded');
29
+ },
30
+ },
31
+ apis: { shop: apis },
32
+ });
33
+
34
+ const manifestFor = (path: string) => server.manifestFor(path, {});
35
+
36
+ describe('auditManifest', () => {
37
+ it('flags agent-reachable tools without description', () => {
38
+ const findings = auditManifest({
39
+ tools: [
40
+ { name: 'a.ok', description: 'fine', guard: 'auto' },
41
+ { name: 'a.bad', guard: 'confirm' },
42
+ ],
43
+ });
44
+
45
+ expect(findings).toEqual([
46
+ { level: 'error', tool: 'a.bad', message: 'missing description (agent-reachable, guard "confirm")' },
47
+ ]);
48
+ });
49
+ });
50
+
51
+ describe('collectFindings', () => {
52
+ it('errors on undescribed api tools and intents, once across routes', async () => {
53
+ const findings = await collectFindings(['/', '/shop'], manifestFor);
54
+ const tools = findings.map((finding) => finding.tool);
55
+
56
+ expect(tools).toContain('api.shop.undescribed');
57
+ expect(tools).toContain('bare-cart.add');
58
+ expect(tools.filter((tool) => tool === 'api.shop.undescribed')).toHaveLength(1);
59
+ });
60
+
61
+ it('never flags forbidden tools (not agent-reachable)', async () => {
62
+ const findings = await collectFindings(['/'], manifestFor);
63
+
64
+ expect(findings.map((finding) => finding.tool)).not.toContain('api.shop.hidden');
65
+ });
66
+
67
+ it('warns when a route fails to render', async () => {
68
+ const findings = await collectFindings(['/boom'], manifestFor);
69
+
70
+ expect(findings[0]?.level).toBe('warn');
71
+ expect(findings[0]?.message).toContain('/boom');
72
+ expect(findings[0]?.message).toContain('render exploded');
73
+ });
74
+ });
package/src/verify.ts ADDED
@@ -0,0 +1,85 @@
1
+ import { createFsRouter, createJanuxServer } from '@janux/server';
2
+ import type { ManifestTool } from 'janux/manifest';
3
+ import { prodServerOptions } from './commands';
4
+ import type { CliCommand } from './args';
5
+
6
+ export interface VerifyFinding {
7
+ level: 'error' | 'warn';
8
+ tool?: string;
9
+ message: string;
10
+ }
11
+
12
+ interface ManifestLike {
13
+ tools: ManifestTool[];
14
+ }
15
+
16
+ /** Contract checks over one route's manifest: every agent-reachable tool needs a description. */
17
+ export function auditManifest(manifest: ManifestLike): VerifyFinding[] {
18
+ return manifest.tools
19
+ .filter((tool) => !tool.description)
20
+ .map((tool) => ({
21
+ level: 'error' as const,
22
+ tool: tool.name,
23
+ message: `missing description (agent-reachable, guard "${tool.guard}")`,
24
+ }));
25
+ }
26
+
27
+ function dedupeFindings(findings: VerifyFinding[]): VerifyFinding[] {
28
+ const seen = new Set<string>();
29
+
30
+ return findings.filter((finding) => {
31
+ const key = finding.tool ?? finding.message;
32
+
33
+ if (seen.has(key)) return false;
34
+ seen.add(key);
35
+
36
+ return true;
37
+ });
38
+ }
39
+
40
+ /** Renders every route's manifest and aggregates findings (routes that fail to render are warnings). */
41
+ export async function collectFindings(
42
+ patterns: string[],
43
+ manifestFor: (path: string) => Promise<unknown>,
44
+ ): Promise<VerifyFinding[]> {
45
+ const perRoute = await Promise.all(
46
+ patterns.map(async (pattern) => {
47
+ try {
48
+ return auditManifest((await manifestFor(pattern)) as ManifestLike);
49
+ } catch (error) {
50
+ return [
51
+ {
52
+ level: 'warn' as const,
53
+ message: `route "${pattern}" failed to render — its agent surface was not verified (${error})`,
54
+ },
55
+ ];
56
+ }
57
+ }),
58
+ );
59
+
60
+ return dedupeFindings(perRoute.flat());
61
+ }
62
+
63
+ function report(findings: VerifyFinding[]): void {
64
+ const errors = findings.filter((finding) => finding.level === 'error').length;
65
+
66
+ if (findings.length === 0) {
67
+ console.log('janux verify: agent surface OK — every reachable tool has a description.');
68
+
69
+ return;
70
+ }
71
+ findings.forEach((finding) =>
72
+ console.log(` ${finding.level.toUpperCase().padEnd(5)} ${finding.tool ? `${finding.tool} — ` : ''}${finding.message}`),
73
+ );
74
+ console.log(`\njanux verify: ${errors} error(s), ${findings.length - errors} warning(s).`);
75
+ }
76
+
77
+ export async function verify({ root }: CliCommand): Promise<void> {
78
+ const options = await prodServerOptions(root);
79
+ const server = createJanuxServer(options);
80
+ const patterns = createFsRouter(options.routesDir!).routes.map((route) => route.pattern);
81
+ const findings = await collectFindings(patterns, (path) => server.manifestFor(path, {}));
82
+
83
+ report(findings);
84
+ if (findings.some((finding) => finding.level === 'error')) process.exitCode = 1;
85
+ }