total-recall-brain 3.31.2 → 3.32.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.
Files changed (47) hide show
  1. package/bin/total-recall.mjs +20 -2
  2. package/package.json +2 -2
  3. package/scaffold/.agent/skills/plugins/SKILL.md +5 -0
  4. package/scaffold/.agent/skills/total-recall/SKILL.md +33 -3
  5. package/src/cli/api.mjs +133 -0
  6. package/src/cli/app/index.mjs +181 -1
  7. package/src/cli/brief.mjs +195 -0
  8. package/src/cli/command.mjs +269 -5
  9. package/src/cli/config.mjs +25 -7
  10. package/src/cli/connect.mjs +1 -1
  11. package/src/cli/daemon.mjs +3 -3
  12. package/src/cli/group.mjs +151 -0
  13. package/src/cli/init.mjs +54 -0
  14. package/src/cli/plugin/create.mjs +20 -10
  15. package/src/cli/project-id.mjs +43 -0
  16. package/src/cli/repo-expert-generate.mjs +37 -3
  17. package/src/cli/secret.mjs +36 -3
  18. package/src/cli/skill-config.mjs +211 -0
  19. package/src/cli/skill.mjs +37 -2
  20. package/src/core/api-routes.mjs +99 -0
  21. package/src/core/app-deploy/adapter-contract.mjs +176 -0
  22. package/src/core/app-deploy/design-tokens.mjs +148 -108
  23. package/src/core/app-deploy/plan.mjs +2 -2
  24. package/src/core/app-deploy/resolve.mjs +30 -2
  25. package/src/core/app-deploy/runtime-cli/node.mjs.tmpl +211 -0
  26. package/src/core/app-deploy/runtime-cli/python.py.tmpl +178 -0
  27. package/src/core/app-deploy/runtime-cli.mjs +254 -0
  28. package/src/core/app-deploy/ui-elements.mjs +473 -0
  29. package/src/core/app-deploy/verify.mjs +18 -0
  30. package/src/core/command-surface.mjs +129 -0
  31. package/src/core/daemon-control.mjs +31 -12
  32. package/src/core/daemon-loop.mjs +14 -7
  33. package/src/core/plugin-loader.mjs +12 -12
  34. package/src/core/project-id.mjs +72 -0
  35. package/src/core/provider-catalog.mjs +1 -1
  36. package/src/core/schema.mjs +72 -13
  37. package/src/core/secrets-env-export.mjs +3 -4
  38. package/src/core/secrets-store.mjs +30 -29
  39. package/src/core/skill-config.mjs +376 -0
  40. package/src/core/skills-registry.mjs +147 -31
  41. package/src/core/source-adapters.mjs +2 -2
  42. package/src/core/surface.mjs +109 -16
  43. package/src/server/routes/help.mjs +1 -1
  44. package/src/server/routes/system.mjs +1 -1
  45. package/plugins/code-quality/README.md +0 -25
  46. package/plugins/code-quality/cli.mjs +0 -25
  47. package/plugins/code-quality/plugin.json +0 -17
@@ -2,15 +2,33 @@
2
2
  * Total Recall — Design Token Parser & Validator
3
3
  *
4
4
  * Implements Phase 2B of CAPABILITY_DEPLOYMENT_PLUGINS:
5
- * - Parses `DESIGN.md` YAML frontmatter into canonical design tokens
6
- * - Resolves token references and compiles standard CSS variables (`:root { ... }`)
7
- * - Validates plugin UI elements to reject hardcoded brand values and enforce design token compliance
5
+ * - Parses `DESIGN.md` YAML frontmatter (google-labs-code/design.md format) into tokens
6
+ * - Resolves `{group.token}` references and compiles CSS custom properties (`:root { ... }`)
7
+ * - Validates plugin UI source: no hardcoded colors, and only declared token variables
8
+ *
9
+ * Variable names: colors → `--color-<name>`, spacing → `--spacing-<name>`,
10
+ * rounded → `--radius-<name>`, typography style props → `--typography-<style>-<prop>`,
11
+ * a `typography.fontFamily` map of families → `--font-family-<name>`, and primitive
12
+ * component props → `--component-<name>-<prop>`. Names are kebab-cased.
8
13
  */
9
14
 
10
15
  import fs from 'node:fs';
11
- import path from 'node:path';
16
+ import { parse as parseYaml } from 'yaml';
17
+
18
+ export class DesignTokenError extends Error {
19
+ constructor(errors) {
20
+ super(`Invalid design tokens:\n- ${errors.join('\n- ')}`);
21
+ this.name = 'DesignTokenError';
22
+ this.errors = errors;
23
+ }
24
+ }
25
+
26
+ const TOKEN_KEY = /^[A-Za-z0-9_-]+$/;
27
+ // A value lands inside a generated stylesheet: nothing that can close the
28
+ // declaration, the rule, or an enclosing <style> element.
29
+ const UNSAFE_VALUE = /[;{}<>\\\r\n]/;
30
+ const TYPOGRAPHY_PROPS = ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'fontFeature', 'fontVariation'];
12
31
 
13
- // Matches kebab-case transformation
14
32
  function toKebabCase(str) {
15
33
  return str
16
34
  .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
@@ -18,55 +36,28 @@ function toKebabCase(str) {
18
36
  .toLowerCase();
19
37
  }
20
38
 
39
+ const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
40
+
21
41
  /**
22
42
  * Extracts YAML frontmatter from a markdown string.
23
43
  * @param {string} content
24
44
  * @returns {object}
25
45
  */
26
46
  export function extractFrontmatter(content) {
27
- const match = content.match(/^---\n([\s\S]*?)\n---/);
47
+ const match = String(content).match(/^?---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/);
28
48
  if (!match) return {};
29
- const raw = match[1];
30
-
31
- // Lightweight YAML parser for nested token structures
32
- const result = {};
33
- const lines = raw.split('\n');
34
- const stack = [{ obj: result, indent: -1 }];
35
-
36
- for (const line of lines) {
37
- if (!line.trim() || line.trim().startsWith('#')) continue;
38
- const indent = line.search(/\S/);
39
- const trimmed = line.trim();
40
- const colonIdx = trimmed.indexOf(':');
41
- if (colonIdx === -1) continue;
42
-
43
- const key = trimmed.slice(0, colonIdx).trim();
44
- let val = trimmed.slice(colonIdx + 1).trim();
45
-
46
- while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
47
- stack.pop();
48
- }
49
- const current = stack[stack.length - 1].obj;
50
-
51
- if (val === '') {
52
- current[key] = {};
53
- stack.push({ obj: current[key], indent });
54
- } else {
55
- if (
56
- (val.startsWith('"') && val.endsWith('"')) ||
57
- (val.startsWith("'") && val.endsWith("'"))
58
- ) {
59
- val = val.slice(1, -1);
60
- }
61
- current[key] = val;
62
- }
49
+ let parsed;
50
+ try {
51
+ parsed = parseYaml(match[1]);
52
+ } catch (err) {
53
+ throw new DesignTokenError([`frontmatter is not valid YAML: ${err.message}`]);
63
54
  }
64
-
65
- return result;
55
+ return isPlainObject(parsed) ? parsed : {};
66
56
  }
67
57
 
68
58
  /**
69
59
  * Resolves token references like "{colors.primary}" within values.
60
+ * An unresolvable or circular reference is left as written.
70
61
  * @param {object} tokens
71
62
  * @returns {object}
72
63
  */
@@ -80,25 +71,28 @@ export function resolveTokenReferences(tokens) {
80
71
  return curr;
81
72
  }
82
73
 
83
- function resolveVal(val) {
74
+ function resolveVal(val, seen) {
84
75
  if (typeof val !== 'string') return val;
76
+ const whole = val.match(/^\{([a-zA-Z0-9_.-]+)\}$/);
77
+ if (whole && !seen.has(whole[1])) {
78
+ const target = getNested(tokens, whole[1].split('.'));
79
+ // A whole-value reference may point at a composite (e.g. a typography style).
80
+ if (isPlainObject(target)) return walk(target, new Set([...seen, whole[1]]));
81
+ }
85
82
  return val.replace(/\{([a-zA-Z0-9_.-]+)\}/g, (match, tokenPath) => {
86
- const parts = tokenPath.split('.');
87
- const resolved = getNested(tokens, parts);
88
- return resolved !== undefined ? resolveVal(resolved) : match;
83
+ if (seen.has(tokenPath)) return match;
84
+ const resolved = getNested(tokens, tokenPath.split('.'));
85
+ if (typeof resolved === 'string' || typeof resolved === 'number') {
86
+ return String(resolveVal(resolved, new Set([...seen, tokenPath])));
87
+ }
88
+ return match;
89
89
  });
90
90
  }
91
91
 
92
- function walk(node) {
93
- if (typeof node !== 'object' || node === null) return resolveVal(node);
92
+ function walk(node, seen = new Set()) {
93
+ if (!isPlainObject(node)) return resolveVal(node, seen);
94
94
  const out = {};
95
- for (const [k, v] of Object.entries(node)) {
96
- if (typeof v === 'object' && v !== null) {
97
- out[k] = walk(v);
98
- } else {
99
- out[k] = resolveVal(v);
100
- }
101
- }
95
+ for (const [k, v] of Object.entries(node)) out[k] = walk(v, seen);
102
96
  return out;
103
97
  }
104
98
 
@@ -106,112 +100,158 @@ export function resolveTokenReferences(tokens) {
106
100
  }
107
101
 
108
102
  /**
109
- * Compiles parsed tokens into standard CSS custom properties.
110
- *
111
- * @param {object} tokens - Parsed design tokens
112
- * @returns {string} Compiled CSS block
103
+ * Lists the CSS custom properties a token set produces, in document order.
104
+ * @param {object} tokens - Parsed (resolved or not) design tokens
105
+ * @returns {Array<[string, string]>} [name without the leading `--`, value]
106
+ * @throws {DesignTokenError} on unsafe names or values, or unresolved references
113
107
  */
114
- export function generateCssVariables(tokens) {
108
+ export function collectCssVariables(tokens) {
109
+ const resolved = resolveTokenReferences(tokens || {});
115
110
  const vars = [];
116
- const resolved = resolveTokenReferences(tokens);
111
+ const errors = [];
112
+ const seen = new Map();
117
113
 
118
- // 1. Colors
119
- if (resolved.colors) {
120
- for (const [name, val] of Object.entries(resolved.colors)) {
121
- if (typeof val === 'string') {
122
- vars.push(` --color-${toKebabCase(name)}: ${val};`);
123
- }
114
+ const add = (name, value, where) => {
115
+ if (typeof value !== 'string' && typeof value !== 'number') return;
116
+ const text = String(value).trim();
117
+ if (!text) return;
118
+ if (/\{[a-zA-Z0-9_.-]+\}/.test(text)) {
119
+ errors.push(`${where}: unresolved reference '${text}'`);
120
+ return;
124
121
  }
125
- }
122
+ if (UNSAFE_VALUE.test(text)) {
123
+ errors.push(`${where}: value contains a character not allowed in a CSS value`);
124
+ return;
125
+ }
126
+ if (seen.has(name)) {
127
+ errors.push(`${where}: produces --${name}, already produced by ${seen.get(name)}`);
128
+ return;
129
+ }
130
+ seen.set(name, where);
131
+ vars.push([name, text]);
132
+ };
133
+ const keysOf = (group, where) => Object.entries(group).filter(([key]) => {
134
+ if (TOKEN_KEY.test(key)) return true;
135
+ errors.push(`${where}.${key}: token names may use letters, digits, '-' and '_' only`);
136
+ return false;
137
+ });
126
138
 
127
- // 2. Typography
128
- if (resolved.typography) {
129
- if (resolved.typography.fontFamily) {
130
- for (const [name, val] of Object.entries(resolved.typography.fontFamily)) {
131
- if (typeof val === 'string') {
132
- vars.push(` --font-family-${toKebabCase(name)}: ${val};`);
139
+ if (isPlainObject(resolved.colors)) {
140
+ for (const [name, val] of keysOf(resolved.colors, 'colors')) add(`color-${toKebabCase(name)}`, val, `colors.${name}`);
141
+ }
142
+ if (isPlainObject(resolved.typography)) {
143
+ for (const [style, def] of keysOf(resolved.typography, 'typography')) {
144
+ if (!isPlainObject(def)) continue;
145
+ if (style === 'fontFamily') {
146
+ // A map of font families by role (heading, body, …), as some DESIGN.md files use.
147
+ for (const [name, val] of keysOf(def, 'typography.fontFamily')) {
148
+ add(`font-family-${toKebabCase(name)}`, val, `typography.fontFamily.${name}`);
133
149
  }
150
+ continue;
134
151
  }
135
- }
136
- }
137
-
138
- // 3. Spacing
139
- if (resolved.spacing) {
140
- for (const [name, val] of Object.entries(resolved.spacing)) {
141
- if (typeof val === 'string') {
142
- vars.push(` --spacing-${toKebabCase(name)}: ${val};`);
152
+ for (const prop of TYPOGRAPHY_PROPS) {
153
+ add(`typography-${toKebabCase(style)}-${toKebabCase(prop)}`, def[prop], `typography.${style}.${prop}`);
143
154
  }
144
155
  }
145
156
  }
146
-
147
- // 4. Border Radius (rounded)
157
+ if (isPlainObject(resolved.spacing)) {
158
+ for (const [name, val] of keysOf(resolved.spacing, 'spacing')) add(`spacing-${toKebabCase(name)}`, val, `spacing.${name}`);
159
+ }
148
160
  const rounded = resolved.rounded || resolved.borderRadius;
149
- if (rounded) {
150
- for (const [name, val] of Object.entries(rounded)) {
151
- if (typeof val === 'string') {
152
- vars.push(` --radius-${toKebabCase(name)}: ${val};`);
161
+ if (isPlainObject(rounded)) {
162
+ for (const [name, val] of keysOf(rounded, 'rounded')) add(`radius-${toKebabCase(name)}`, val, `rounded.${name}`);
163
+ }
164
+ if (isPlainObject(resolved.components)) {
165
+ for (const [component, def] of keysOf(resolved.components, 'components')) {
166
+ if (!isPlainObject(def)) continue;
167
+ for (const [prop, val] of keysOf(def, `components.${component}`)) {
168
+ add(`component-${toKebabCase(component)}-${toKebabCase(prop)}`, val, `components.${component}.${prop}`);
153
169
  }
154
170
  }
155
171
  }
156
172
 
173
+ if (errors.length) throw new DesignTokenError(errors);
174
+ return vars;
175
+ }
176
+
177
+ /**
178
+ * Compiles parsed tokens into standard CSS custom properties.
179
+ *
180
+ * @param {object} tokens - Parsed design tokens
181
+ * @returns {string} Compiled CSS block
182
+ */
183
+ export function generateCssVariables(tokens) {
184
+ const vars = collectCssVariables(tokens).map(([name, value]) => ` --${name}: ${value};`);
157
185
  return `:root {\n${vars.join('\n')}\n}\n`;
158
186
  }
159
187
 
160
188
  /**
161
189
  * Parses DESIGN.md from a path or string and compiles to CSS variables.
162
190
  * @param {string} source - Path to DESIGN.md or raw content
163
- * @returns {{ tokens: object, css: string }}
191
+ * @returns {{ tokens: object, css: string, variables: string[] }}
164
192
  */
165
193
  export function parseDesignTokens(source) {
166
194
  let content = source;
167
- if (fs.existsSync(source)) {
195
+ if (!/[\r\n]/.test(source) && fs.existsSync(source)) {
168
196
  content = fs.readFileSync(source, 'utf8');
169
197
  }
170
198
 
171
- const rawTokens = extractFrontmatter(content);
172
- const resolved = resolveTokenReferences(rawTokens);
173
- const css = generateCssVariables(resolved);
199
+ const resolved = resolveTokenReferences(extractFrontmatter(content));
200
+ const entries = collectCssVariables(resolved);
201
+ const css = `:root {\n${entries.map(([name, value]) => ` --${name}: ${value};`).join('\n')}\n}\n`;
174
202
 
175
203
  return {
176
204
  tokens: resolved,
177
- css
205
+ css,
206
+ variables: entries.map(([name]) => name)
178
207
  };
179
208
  }
180
209
 
181
- /**
182
- * Prohibited patterns: hardcoded hex colors, rgb/hsl, hardcoded px fonts in UI code
183
- * when design tokens should be referenced via var(--...).
184
- */
185
- const HARDCODED_COLOR_REGEX = /(?:color|background|border|fill|stroke)\s*:\s*(#[0-9a-fA-F]{3,8}|rgba?\(|hsla?\()/i;
210
+ // A color-bearing declaration and the literal color forms that may not appear in its value.
211
+ const COLOR_DECLARATION = /\b(color|background(?:-color)?|border(?:-(?:top|right|bottom|left|block|inline))?(?:-color)?|outline(?:-color)?|fill|stroke|box-shadow|text-shadow|caret-color|accent-color|text-decoration(?:-color)?)\s*:\s*([^;}\n]*)/gi;
212
+ const LITERAL_COLOR = /#[0-9a-fA-F]{3,8}\b|\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\(/;
186
213
 
187
214
  /**
188
215
  * Validates plugin UI source code against design token compliance.
189
- * Rejects hardcoded brand values and ensures use of CSS custom properties.
216
+ * Rejects hardcoded colors in color-bearing declarations (including var()
217
+ * fallbacks), and, when `allowedTokens` is given, any `var(--name)` whose name is
218
+ * not declared. Custom properties starting with `--_` are element-private.
190
219
  *
191
220
  * @param {string} uiSource - Source code of UI component (HTML, JSX, CSS, Web Component)
192
- * @param {object} [options]
193
- * @returns {{ valid: boolean, violations: string[] }}
221
+ * @param {{ allowedTokens?: Iterable<string> }} [options]
222
+ * @returns {{ valid: boolean, violations: string[], usedTokens: string[] }}
194
223
  */
195
224
  export function validatePluginUiAgainstTokens(uiSource, options = {}) {
196
225
  const violations = [];
197
- const lines = uiSource.split('\n');
226
+ const used = new Set();
227
+ const allowed = options.allowedTokens ? new Set(options.allowedTokens) : null;
228
+ const lines = String(uiSource).split('\n');
198
229
 
199
230
  for (let i = 0; i < lines.length; i++) {
200
231
  const line = lines[i];
201
232
 
202
- // Check for hardcoded color values in styles
203
- if (HARDCODED_COLOR_REGEX.test(line)) {
204
- // Allow var(--...) even if it has fallback or comments
205
- if (!line.includes('var(--')) {
233
+ for (const match of line.matchAll(COLOR_DECLARATION)) {
234
+ if (LITERAL_COLOR.test(match[2])) {
206
235
  violations.push(
207
236
  `Line ${i + 1}: Hardcoded color value detected ('${line.trim()}'). Use design token CSS variable var(--color-...) instead.`
208
237
  );
238
+ break;
239
+ }
240
+ }
241
+
242
+ for (const match of line.matchAll(/var\(\s*--([A-Za-z0-9_-]+)/g)) {
243
+ const name = match[1];
244
+ if (name.startsWith('_')) continue;
245
+ used.add(name);
246
+ if (allowed && !allowed.has(name)) {
247
+ violations.push(`Line ${i + 1}: var(--${name}) is not a declared design token.`);
209
248
  }
210
249
  }
211
250
  }
212
251
 
213
252
  return {
214
253
  valid: violations.length === 0,
215
- violations
254
+ violations,
255
+ usedTokens: [...used]
216
256
  };
217
257
  }
@@ -14,7 +14,7 @@ import fs from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import crypto from 'node:crypto';
16
16
  import { resolveCapabilitySource } from './source.mjs';
17
- import { resolveCapabilityGraph } from './resolve.mjs';
17
+ import { resolveCapabilityGraph, detectSsssVersion } from './resolve.mjs';
18
18
 
19
19
  export const PROTECTED_APP_FILES = new Set([
20
20
  'package.json',
@@ -187,7 +187,7 @@ export async function createDeploymentPlan(source, options = {}) {
187
187
  targetApp: {
188
188
  dir: targetDir,
189
189
  framework: adapter,
190
- ssssVersion: '0.9.6'
190
+ ssssVersion: detectSsssVersion(targetDir)
191
191
  },
192
192
  pluginResolver: options.pluginResolver
193
193
  });
@@ -10,7 +10,30 @@
10
10
  * - Access grant and resource requirement aggregation
11
11
  */
12
12
 
13
+ import fs from 'node:fs';
13
14
  import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const SSSS_PACKAGES = ['@gregiteen/ssss-cli', '@ssss/cli'];
18
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
19
+
20
+ function installedSsssVersion(root) {
21
+ for (const name of SSSS_PACKAGES) {
22
+ try {
23
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'node_modules', name, 'package.json'), 'utf8'));
24
+ if (pkg.version) return pkg.version;
25
+ } catch { /* not installed here */ }
26
+ }
27
+ return null;
28
+ }
29
+
30
+ /**
31
+ * The SSSS version a target app runs: its installed SSSS CLI package, else the
32
+ * one bundled with Total Recall (what `ssss new` would scaffold today).
33
+ */
34
+ export function detectSsssVersion(targetDir) {
35
+ return (targetDir && installedSsssVersion(targetDir)) || installedSsssVersion(PACKAGE_ROOT);
36
+ }
14
37
 
15
38
  /**
16
39
  * Custom error types for capability resolution
@@ -110,7 +133,12 @@ export function checkSsssVersionCompatibility(manifest, targetSsssVersion) {
110
133
  const req = manifest.deploy?.required_ssss_version;
111
134
  if (!req) return;
112
135
 
113
- const currentVersion = targetSsssVersion || '0.9.6';
136
+ const currentVersion = targetSsssVersion || detectSsssVersion();
137
+ if (!currentVersion) {
138
+ throw new IncompatibleSsssVersionError(
139
+ `Plugin '${manifest.id}' requires SSSS version ${req}, but no SSSS CLI package is installed in the target app or Total Recall`
140
+ );
141
+ }
114
142
  if (!satisfiesVersionConstraint(currentVersion, req)) {
115
143
  throw new IncompatibleSsssVersionError(
116
144
  `Plugin '${manifest.id}' requires SSSS version ${req}, but target app environment provides ${currentVersion}`
@@ -138,7 +166,7 @@ export function checkSsssVersionCompatibility(manifest, targetSsssVersion) {
138
166
  */
139
167
  export async function resolveCapabilityGraph(rootDescriptor, options = {}) {
140
168
  const adapter = options.adapter || options.targetApp?.framework || 'ssss-app';
141
- const targetSsssVersion = options.targetApp?.ssssVersion || '0.9.6';
169
+ const targetSsssVersion = options.targetApp?.ssssVersion || detectSsssVersion(options.targetApp?.dir);
142
170
  const pluginResolver = options.pluginResolver || (async () => null);
143
171
 
144
172
  const graph = new Map(); // id -> descriptor
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env node
2
+ // __HEADER__
3
+ //
4
+ // Exit codes: 0 ok · 1 the app answered with an error · 2 usage · 3 the app is unreachable.
5
+
6
+ const SPEC = __SPEC__;
7
+
8
+ const EXIT = { OK: 0, HTTP: 1, USAGE: 2, UNREACHABLE: 3 };
9
+ const flagOf = (name) => `--${name.replace(/_/g, '-')}`;
10
+
11
+ class UsageError extends Error {}
12
+
13
+ function usage(command) {
14
+ if (!command) {
15
+ const lines = [`Usage: ${SPEC.name} <command> [options]`, ''];
16
+ if (SPEC.description) lines.push(SPEC.description, '');
17
+ lines.push('Commands:');
18
+ for (const c of SPEC.commands) lines.push(` ${c.name.padEnd(20)} ${c.description || ''}`);
19
+ lines.push(
20
+ '',
21
+ 'Global options:',
22
+ ' --json Print a JSON envelope: { ok, status, data | error }',
23
+ ` --base-url <url> App API base URL (default: $${SPEC.api.base_url_env} or ${SPEC.api.default_base_url})`,
24
+ ' -h, --help Show help; with a command, that command\'s options',
25
+ );
26
+ return lines.join('\n');
27
+ }
28
+ const positionals = command.args.filter((a) => a.positional).map((a) => `<${a.name}>`);
29
+ const lines = [`Usage: ${SPEC.name} ${[command.name, ...positionals].join(' ')} [options]`, ''];
30
+ if (command.description) lines.push(command.description, '');
31
+ for (const a of command.args) {
32
+ const label = a.positional ? `<${a.name}>` : a.type === 'boolean' ? flagOf(a.name) : `${flagOf(a.name)} <${a.type}>`;
33
+ const notes = [
34
+ a.required && !a.positional ? 'required' : '',
35
+ a.choices ? `one of: ${a.choices.join(', ')}` : '',
36
+ a.default !== undefined ? `default: ${a.default}` : '',
37
+ ].filter(Boolean).join('; ');
38
+ lines.push(` ${label.padEnd(26)} ${a.description || ''}${notes ? ` (${notes})` : ''}`);
39
+ }
40
+ return lines.join('\n');
41
+ }
42
+
43
+ function coerce(arg, raw) {
44
+ let value = raw;
45
+ if (arg.type === 'integer') {
46
+ if (!/^[-+]?\d+$/.test(raw.trim())) throw new UsageError(`${arg.name} must be an integer, got '${raw}'`);
47
+ value = Number(raw);
48
+ } else if (arg.type === 'number') {
49
+ value = Number(raw);
50
+ if (raw.trim() === '' || !Number.isFinite(value)) throw new UsageError(`${arg.name} must be a number, got '${raw}'`);
51
+ }
52
+ if (arg.choices && !arg.choices.includes(value)) {
53
+ throw new UsageError(`${arg.name} must be one of: ${arg.choices.join(', ')}`);
54
+ }
55
+ return value;
56
+ }
57
+
58
+ function parse(argv) {
59
+ const global = { json: false, baseUrl: null, help: false };
60
+ const rest = [];
61
+ for (let i = 0; i < argv.length; i++) {
62
+ const token = argv[i];
63
+ if (token === '--json') global.json = true;
64
+ else if (token === '-h' || token === '--help') global.help = true;
65
+ else if (token.startsWith('--base-url=')) global.baseUrl = token.slice('--base-url='.length);
66
+ else if (token === '--base-url') {
67
+ if (argv[i + 1] === undefined) throw new UsageError('--base-url needs a value');
68
+ global.baseUrl = argv[++i];
69
+ } else rest.push(token);
70
+ }
71
+
72
+ const [name, ...tokens] = rest;
73
+ if (!name) return { global, command: null, values: {} };
74
+ const command = SPEC.commands.find((c) => c.name === name);
75
+ if (!command) throw new UsageError(`unknown command '${name}'`);
76
+ if (global.help) return { global, command, values: {} };
77
+
78
+ const values = {};
79
+ const positionals = command.args.filter((a) => a.positional);
80
+ let next = 0;
81
+ for (let i = 0; i < tokens.length; i++) {
82
+ const token = tokens[i];
83
+ if (token.startsWith('--')) {
84
+ const eq = token.indexOf('=');
85
+ const flag = eq === -1 ? token : token.slice(0, eq);
86
+ const inline = eq === -1 ? undefined : token.slice(eq + 1);
87
+ const arg = command.args.find((a) => !a.positional && flagOf(a.name) === flag);
88
+ if (!arg) throw new UsageError(`unknown option ${flag} for '${name}'`);
89
+ if (arg.type === 'boolean') {
90
+ if (inline !== undefined) throw new UsageError(`${flag} takes no value`);
91
+ values[arg.name] = true;
92
+ continue;
93
+ }
94
+ const raw = inline !== undefined ? inline : tokens[++i];
95
+ if (raw === undefined) throw new UsageError(`${flag} needs a value`);
96
+ values[arg.name] = coerce(arg, raw);
97
+ } else {
98
+ const arg = positionals[next++];
99
+ if (!arg) throw new UsageError(`unexpected argument '${token}'`);
100
+ values[arg.name] = coerce(arg, token);
101
+ }
102
+ }
103
+ for (const arg of command.args) {
104
+ if (values[arg.name] === undefined && arg.default !== undefined) values[arg.name] = arg.default;
105
+ if (values[arg.name] === undefined && (arg.required || arg.positional)) {
106
+ throw new UsageError(`missing ${arg.positional ? `<${arg.name}>` : flagOf(arg.name)}`);
107
+ }
108
+ }
109
+ return { global, command, values };
110
+ }
111
+
112
+ function buildRequest(command, values, baseUrl) {
113
+ let base;
114
+ try {
115
+ base = new URL(baseUrl);
116
+ } catch {
117
+ throw new UsageError(`invalid base URL '${baseUrl}'`);
118
+ }
119
+ if (base.protocol !== 'http:' && base.protocol !== 'https:') throw new UsageError(`base URL must be http(s): '${baseUrl}'`);
120
+
121
+ const used = new Set();
122
+ const pathname = command.path.replace(/\{([a-z][a-z0-9_]*)\}/g, (_, key) => {
123
+ used.add(key);
124
+ return encodeURIComponent(String(values[key]));
125
+ });
126
+ const url = new URL(base.href.replace(/\/+$/, '') + pathname);
127
+ const rest = Object.entries(values).filter(([key]) => !used.has(key));
128
+ const init = { method: command.method, headers: { accept: 'application/json', 'user-agent': `${SPEC.name}-cli` } };
129
+ if (command.method === 'GET' || command.method === 'DELETE') {
130
+ for (const [key, value] of rest) url.searchParams.append(key, String(value));
131
+ } else if (rest.length) {
132
+ init.headers['content-type'] = 'application/json';
133
+ init.body = JSON.stringify(Object.fromEntries(rest));
134
+ }
135
+ const token = SPEC.auth ? process.env[SPEC.auth.token_env] : undefined;
136
+ if (token) init.headers.authorization = `Bearer ${token}`;
137
+ return { url, init };
138
+ }
139
+
140
+ function errorText(data, response) {
141
+ if (data && typeof data === 'object') {
142
+ const detail = data.error ?? data.message;
143
+ if (detail !== undefined) return typeof detail === 'string' ? detail : JSON.stringify(detail);
144
+ }
145
+ if (typeof data === 'string' && data.trim()) return data.trim().slice(0, 500);
146
+ return response.statusText || `HTTP ${response.status}`;
147
+ }
148
+
149
+ function fail(json, status, error) {
150
+ if (json) console.log(JSON.stringify({ ok: false, status, error }));
151
+ else console.error(`${SPEC.name}: ${error}${status ? ` (HTTP ${status})` : ''}`);
152
+ }
153
+
154
+ async function main() {
155
+ let parsed;
156
+ try {
157
+ parsed = parse(process.argv.slice(2));
158
+ } catch (err) {
159
+ if (!(err instanceof UsageError)) throw err;
160
+ console.error(`${SPEC.name}: ${err.message}\nRun '${SPEC.name} --help' for usage.`);
161
+ return EXIT.USAGE;
162
+ }
163
+ const { global, command, values } = parsed;
164
+ if (!command) {
165
+ console.log(usage());
166
+ return global.help ? EXIT.OK : EXIT.USAGE;
167
+ }
168
+ if (global.help) {
169
+ console.log(usage(command));
170
+ return EXIT.OK;
171
+ }
172
+
173
+ const baseUrl = global.baseUrl || process.env[SPEC.api.base_url_env] || SPEC.api.default_base_url;
174
+ let request;
175
+ try {
176
+ request = buildRequest(command, values, baseUrl);
177
+ } catch (err) {
178
+ if (!(err instanceof UsageError)) throw err;
179
+ console.error(`${SPEC.name}: ${err.message}`);
180
+ return EXIT.USAGE;
181
+ }
182
+
183
+ let response;
184
+ try {
185
+ response = await fetch(request.url, { ...request.init, signal: AbortSignal.timeout(SPEC.api.timeout_seconds * 1000) });
186
+ } catch (err) {
187
+ const reason = err?.name === 'TimeoutError' ? `no answer within ${SPEC.api.timeout_seconds}s` : (err?.cause?.code || err?.message || String(err));
188
+ fail(global.json, null, `cannot reach ${request.url.origin}: ${reason}`);
189
+ return EXIT.UNREACHABLE;
190
+ }
191
+
192
+ const text = await response.text();
193
+ let data = text;
194
+ try {
195
+ data = text ? JSON.parse(text) : null;
196
+ } catch { /* not JSON: keep the text */ }
197
+
198
+ if (!response.ok) {
199
+ fail(global.json, response.status, errorText(data, response));
200
+ return EXIT.HTTP;
201
+ }
202
+ if (global.json) console.log(JSON.stringify({ ok: true, status: response.status, data }));
203
+ else if (typeof data === 'string') console.log(data);
204
+ else if (data !== null) console.log(JSON.stringify(data, null, 2));
205
+ return EXIT.OK;
206
+ }
207
+
208
+ main().then(
209
+ (code) => { process.exitCode = code; },
210
+ (err) => { console.error(err?.stack || String(err)); process.exitCode = 1; },
211
+ );