@zeyos/cli 0.1.1 → 0.2.0

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/README.md CHANGED
@@ -52,6 +52,7 @@ Inspect the CLI-supported resource registry:
52
52
 
53
53
  ```bash
54
54
  zeyos resources
55
+ zeyos doctor agent --json
55
56
  ```
56
57
 
57
58
  List tickets for automation:
@@ -65,11 +66,19 @@ zeyos list tickets \
65
66
  --json
66
67
  ```
67
68
 
69
+ For larger or reusable filters, put the JSON in a file:
70
+
71
+ ```bash
72
+ zeyos list tickets --filter-file ./filters/open-tickets.json --json
73
+ ```
74
+
68
75
  Create, update, and delete:
69
76
 
70
77
  ```bash
71
78
  zeyos create ticket --data '{"name":"Fix login bug","status":0,"priority":3,"visibility":0}' --json
79
+ zeyos create ticket --data-file ./ticket.json --json
72
80
  zeyos update ticket 42 --data '{"status":4}' --json
81
+ zeyos update ticket 42 --data-file ./ticket-update.json --json
73
82
  zeyos delete ticket 42
74
83
  ```
75
84
 
package/bin/zeyos.mjs CHANGED
@@ -16,6 +16,7 @@
16
16
  * update <resource> Update a record
17
17
  * delete <resource> Delete a record
18
18
  * resources List available resource types
19
+ * doctor agent Check local CLI readiness for coding agents
19
20
  */
20
21
 
21
22
  // ── Version ───────────────────────────────────────────────────────────────────
@@ -23,45 +24,52 @@
23
24
  import { createRequire as _createRequire } from 'node:module';
24
25
  import { dirname as _dirname } from 'node:path';
25
26
  import { fileURLToPath as _fileURLToPath } from 'node:url';
27
+ import { colors as _c } from '../lib/output.mjs';
26
28
  const _require = _createRequire(import.meta.url);
27
29
  const _VERSION = _require('../package.json').version;
28
30
 
29
31
  // ── Global help ───────────────────────────────────────────────────────────────
30
32
 
33
+ // Section headers are bold and the `zeyos` binary / command names are cyan,
34
+ // gated by USE_COLOR in output.mjs (so `zeyos --help | less` stays plain text).
35
+ const _z = _c.cyan('zeyos');
31
36
  const HELP = `\
32
- Usage: zeyos <command> [options] [args…]
33
-
34
- Commands:
35
- login Authenticate with a ZeyOS instance
36
- logout Revoke session and clear stored tokens
37
- whoami Show currently authenticated user
38
- list <resource> List / query records
39
- count <resource> Count records (with optional filter)
40
- get <resource> <id> Fetch a single record by ID
41
- show <resource> <id> Alias for get
42
- create <resource> Create a new record
43
- update <resource> <id> Update an existing record
44
- delete <resource> <id> Delete a record
45
- resources List all available resource types
46
- describe <resource> Show a resource's fields, types and enums
47
- skills <command> List / show / install ZeyOS agent skills
48
-
49
- Global options:
37
+ Usage: ${_z} <command> [options] [args…]
38
+
39
+ ${_c.bold('Commands:')}
40
+ ${_c.cyan('login')} Authenticate with a ZeyOS instance
41
+ ${_c.cyan('logout')} Revoke session and clear stored tokens
42
+ ${_c.cyan('whoami')} Show currently authenticated user
43
+ ${_c.cyan('list')} <resource> List / query records
44
+ ${_c.cyan('count')} <resource> Count records (with optional filter)
45
+ ${_c.cyan('get')} <resource> <id> Fetch a single record by ID
46
+ ${_c.cyan('show')} <resource> <id> Alias for get
47
+ ${_c.cyan('create')} <resource> Create a new record
48
+ ${_c.cyan('update')} <resource> <id> Update an existing record
49
+ ${_c.cyan('delete')} <resource> <id> Delete a record
50
+ ${_c.cyan('resources')} List all available resource types
51
+ ${_c.cyan('describe')} <resource> Show a resource's fields, types and enums
52
+ ${_c.cyan('doctor')} agent Check local CLI readiness for coding agents
53
+ ${_c.cyan('skills')} <command> List / show / install ZeyOS agent skills
54
+
55
+ ${_c.bold('Global options:')}
50
56
  --json Output as JSON
51
57
  --yaml Output as YAML
58
+ --query Print the API route + JSON payload without sending it
52
59
  --no-color Disable ANSI colors
53
60
  -h, --help Show help for a command
54
61
  -v, --version Print the CLI version and exit
55
62
 
56
- Examples:
57
- zeyos login --base-url https://cloud.zeyos.com/demo --client-id myapp --secret "$ZEYOS_CLIENT_SECRET"
58
- zeyos list tickets --filter '{"status":1}' --sort -lastmodified
59
- zeyos count tickets --filter '{"status":1}'
60
- zeyos get ticket 42
61
- zeyos get ticket 42 --all
62
- zeyos create ticket --name "Fix login bug" --priority 3
63
- zeyos update ticket 42 --status 2
64
- zeyos delete ticket 42 --force
63
+ ${_c.bold('Examples:')}
64
+ ${_z} login --base-url https://cloud.zeyos.com/demo --client-id myapp --secret "$ZEYOS_CLIENT_SECRET"
65
+ ${_z} list tickets --filter '{"status":1}' --sort -lastmodified
66
+ ${_z} list tickets --filter-file ./filters/open-tickets.json
67
+ ${_z} count tickets --filter '{"status":1}'
68
+ ${_z} get ticket 42
69
+ ${_z} get ticket 42 --all
70
+ ${_z} create ticket --name "Fix login bug" --priority 3
71
+ ${_z} update ticket 42 --status 2
72
+ ${_z} delete ticket 42 --force
65
73
  `;
66
74
 
67
75
  // ── Argument definitions ──────────────────────────────────────────────────────
@@ -73,6 +81,7 @@ const OPTIONS = {
73
81
  'json': { type: 'boolean' },
74
82
  'yaml': { type: 'boolean' },
75
83
  'no-color': { type: 'boolean' },
84
+ 'query': { type: 'boolean' },
76
85
  // login
77
86
  'base-url': { type: 'string' },
78
87
  'client-id': { type: 'string' },
@@ -88,6 +97,7 @@ const OPTIONS = {
88
97
  // list
89
98
  'fields': { type: 'string' },
90
99
  'filter': { type: 'string' },
100
+ 'filter-file': { type: 'string' },
91
101
  'sort': { type: 'string' },
92
102
  'limit': { type: 'string' },
93
103
  'offset': { type: 'string' },
@@ -100,6 +110,7 @@ const OPTIONS = {
100
110
  'show-token': { type: 'boolean' },
101
111
  // create / update
102
112
  'data': { type: 'string' },
113
+ 'data-file': { type: 'string' },
103
114
  // delete
104
115
  // (--force is already declared above)
105
116
  // skills install
@@ -128,10 +139,43 @@ const COMMANDS = {
128
139
  resources: '../commands/resources.mjs',
129
140
  resource: '../commands/resources.mjs',
130
141
  describe: '../commands/describe.mjs',
142
+ doctor: '../commands/doctor.mjs',
131
143
  skills: '../commands/skills.mjs',
132
144
  skill: '../commands/skills.mjs',
133
145
  };
134
146
 
147
+ // ── Per-command flag allow-lists ────────────────────────────────────────────────
148
+ // Unknown flags are rejected (e.g. `zeyos list --invalid`) so typos surface
149
+ // immediately instead of being silently ignored. `create`/`update` are the
150
+ // exception: they accept arbitrary `--<field>` flags, marked with `null` below.
151
+
152
+ const ALWAYS_FLAGS = ['help', 'json', 'yaml', 'no-color'];
153
+ const SKILLS_FLAGS = ['target', 'dir', 'global', 'local', 'force', 'yes', 'no-logo'];
154
+ const DELETE_FLAGS = ['force', 'query'];
155
+ const GET_FLAGS = ['fields', 'extdata', 'tags', 'expand', 'all', 'query'];
156
+
157
+ const COMMAND_FLAGS = {
158
+ login: ['base-url', 'client-id', 'secret', 'scope', 'port', 'global', 'force', 'clean', 'manual'],
159
+ logout: ['global'],
160
+ whoami: ['show-token'],
161
+ list: ['fields', 'filter', 'filter-file', 'sort', 'limit', 'offset', 'extdata', 'expand', 'query'],
162
+ count: ['filter', 'filter-file', 'query'],
163
+ get: GET_FLAGS,
164
+ show: GET_FLAGS,
165
+ create: null,
166
+ update: null,
167
+ edit: null,
168
+ delete: DELETE_FLAGS,
169
+ rm: DELETE_FLAGS,
170
+ remove: DELETE_FLAGS,
171
+ resources: [],
172
+ resource: [],
173
+ describe: [],
174
+ doctor: [],
175
+ skills: SKILLS_FLAGS,
176
+ skill: SKILLS_FLAGS,
177
+ };
178
+
135
179
  // ── Main ──────────────────────────────────────────────────────────────────────
136
180
 
137
181
  async function main() {
@@ -149,6 +193,14 @@ async function main() {
149
193
  }
150
194
 
151
195
  const command = argv[0];
196
+
197
+ // A leading flag (e.g. `zeyos --invalid`) is not a command — surface it as a
198
+ // bad option rather than letting it masquerade as one.
199
+ if (command.startsWith('-')) {
200
+ process.stderr.write(`Unknown option: "${command}". Run 'zeyos --help' for usage.\n`);
201
+ process.exit(1);
202
+ }
203
+
152
204
  const rest = argv.slice(1);
153
205
 
154
206
  // Parse remaining args permissively: known options are parsed normally and
@@ -168,6 +220,24 @@ async function main() {
168
220
  process.exit(0);
169
221
  }
170
222
 
223
+ // Reject unknown flags so typos / unsupported options fail loudly instead of
224
+ // being silently ignored. `create`/`update` opt out (COMMAND_FLAGS = null)
225
+ // because they accept arbitrary `--<field>` flags as record data.
226
+ const allowed = COMMAND_FLAGS[command];
227
+ if (allowed) {
228
+ const allowedSet = new Set([...ALWAYS_FLAGS, ...allowed]);
229
+ const unknown = Object.keys(values).filter((key) => !allowedSet.has(key));
230
+ if (unknown.length > 0) {
231
+ const flag = unknown[0];
232
+ const hint = _suggestFlag(flag, [...allowedSet]);
233
+ process.stderr.write(
234
+ `Unknown option: --${flag}${hint ? ` (did you mean --${hint}?)` : ''}\n\n` +
235
+ `Run 'zeyos ${command} --help' for available options.\n`
236
+ );
237
+ process.exit(1);
238
+ }
239
+ }
240
+
171
241
  await mod.run(values, positional);
172
242
  }
173
243
 
@@ -274,6 +344,38 @@ function _parsePermissive(argv, options) {
274
344
  return { values, positional };
275
345
  }
276
346
 
347
+ /** Suggest the closest allowed flag for an unknown one, if it's a near miss. */
348
+ function _suggestFlag(input, candidates) {
349
+ let best = null;
350
+ let bestDist = Infinity;
351
+ for (const candidate of candidates) {
352
+ const dist = _levenshtein(input, candidate);
353
+ if (dist < bestDist) {
354
+ bestDist = dist;
355
+ best = candidate;
356
+ }
357
+ }
358
+ // Only suggest a reasonably close match (avoid nonsense "did you mean").
359
+ return bestDist <= Math.max(2, Math.floor(input.length / 2)) ? best : null;
360
+ }
361
+
362
+ /** Levenshtein edit distance between two short strings. */
363
+ function _levenshtein(a, b) {
364
+ const m = a.length;
365
+ const n = b.length;
366
+ const dp = Array.from({ length: m + 1 }, (_, i) => i);
367
+ for (let j = 1; j <= n; j++) {
368
+ let prev = dp[0];
369
+ dp[0] = j;
370
+ for (let i = 1; i <= m; i++) {
371
+ const tmp = dp[i];
372
+ dp[i] = a[i - 1] === b[j - 1] ? prev : 1 + Math.min(prev, dp[i], dp[i - 1]);
373
+ prev = tmp;
374
+ }
375
+ }
376
+ return dp[m];
377
+ }
378
+
277
379
  main().catch(err => {
278
380
  process.stderr.write(`Fatal: ${err.message}\n`);
279
381
  process.exit(1);
@@ -4,13 +4,14 @@
4
4
  * Return the count of records matching an optional filter.
5
5
  *
6
6
  * Options:
7
- * --filter <json> JSON filter object e.g. '{"status":1}'
8
- * --json Output as JSON
9
- * --yaml Output as YAML
7
+ * --filter <json> JSON filter object e.g. '{"status":1}'
8
+ * --filter-file <path> Read JSON filter object from a file
9
+ * --json Output as JSON
10
+ * --yaml Output as YAML
10
11
  */
11
12
 
12
13
  import { normalizeCountResult } from '@zeyos/client';
13
- import { buildCliClient, callApi, parseJsonOption, requireResource } from '../lib/command.mjs';
14
+ import { buildCliClient, callApi, maybeDryRun, parseJsonOptionOrFile, requireResource } from '../lib/command.mjs';
14
15
  import { outputMode, printJson, printYaml } from '../lib/output.mjs';
15
16
 
16
17
  export const USAGE = `\
@@ -23,29 +24,36 @@ Arguments:
23
24
 
24
25
  Options:
25
26
  --filter <json> JSON filter object e.g. '{"status":1}'
27
+ --filter-file <path>
28
+ Read JSON filter object from a file
26
29
  --json Output as JSON ({ "count": N })
27
30
  --yaml Output as YAML
31
+ --query Print the request route + JSON body without sending it
28
32
  -h, --help Show this help
29
33
 
30
34
  Examples:
31
35
  zeyos count tickets
32
36
  zeyos count tickets --filter '{"status":1}'
37
+ zeyos count tickets --filter-file ./filters/open-tickets.json
33
38
  zeyos count accounts --json
34
39
  `;
35
40
 
36
41
  export async function run(values, positional) {
37
42
  const resourceName = positional[0];
38
43
  const res = requireResource(resourceName, 'zeyos count <resource>');
39
- const clientState = buildCliClient();
40
44
 
41
45
  // ── Build request body ─────────────────────────────────────────────────────
42
46
  const body = { count: true };
43
47
 
44
- if (values.filter) {
45
- body.filters = parseJsonOption(values.filter, 'filter');
48
+ const filters = parseJsonOptionOrFile(values, 'filter', 'filter-file');
49
+ if (filters !== undefined) {
50
+ body.filters = filters;
46
51
  }
47
52
 
48
53
  // ── Call API ───────────────────────────────────────────────────────────────
54
+ const clientState = buildCliClient();
55
+ if (await maybeDryRun(clientState, res.list, body, values)) return;
56
+
49
57
  const result = await callApi(clientState, res.list, body);
50
58
 
51
59
  const count = normalizeCountResult(result);
@@ -3,15 +3,17 @@
3
3
  *
4
4
  * Create a new record. Field values can be supplied either as:
5
5
  * - a JSON blob via --data '{"name":"foo","status":1}'
6
+ * - a JSON file via --data-file ./ticket.json
6
7
  * - individual --<field> <value> flags (converted automatically)
7
8
  *
8
9
  * Options:
9
- * --data <json> Full record as JSON object
10
- * --json Output created record as JSON
11
- * --yaml Output created record as YAML
10
+ * --data <json> Full record as JSON object
11
+ * --data-file <path> Read full record JSON object from a file
12
+ * --json Output created record as JSON
13
+ * --yaml Output created record as YAML
12
14
  */
13
15
 
14
- import { buildCliClient, buildRecordPayload, callApi, requireResource } from '../lib/command.mjs';
16
+ import { buildCliClient, buildRecordPayload, callApi, maybeDryRun, requireResource } from '../lib/command.mjs';
15
17
  import { outputMode, printJson, printYaml, printRecord, success } from '../lib/output.mjs';
16
18
 
17
19
  export const USAGE = `\
@@ -24,14 +26,17 @@ Arguments:
24
26
 
25
27
  Options:
26
28
  --data <json> Record fields as a JSON object
29
+ --data-file <path> Read record fields as a JSON object from a file
27
30
  --<field> <value> Set individual fields e.g. --name "My Ticket" --status 1
28
31
  --json Output created record as JSON
29
32
  --yaml Output created record as YAML
33
+ --query Print the request route + JSON body without sending it
30
34
  -h, --help Show this help
31
35
 
32
36
  Examples:
33
37
  zeyos create ticket --name "Fix login bug" --status 0 --priority 2
34
38
  zeyos create account --data '{"lastname":"Acme Corp","email":"info@acme.com"}'
39
+ zeyos create ticket --data-file ./ticket.json
35
40
  `;
36
41
 
37
42
  export async function run(values, positional) {
@@ -46,6 +51,8 @@ export async function run(values, positional) {
46
51
  const clientState = buildCliClient();
47
52
 
48
53
  // ── Call API ───────────────────────────────────────────────────────────────
54
+ if (await maybeDryRun(clientState, res.create, data, values)) return;
55
+
49
56
  const record = await callApi(clientState, res.create, data);
50
57
 
51
58
  const mode = outputMode(values);
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { createInterface } from 'node:readline';
11
- import { buildCliClient, callApi, requireRecordId, requireResource } from '../lib/command.mjs';
11
+ import { buildCliClient, callApi, maybeDryRun, requireRecordId, requireResource } from '../lib/command.mjs';
12
12
  import { success, warn } from '../lib/output.mjs';
13
13
 
14
14
  export const USAGE = `\
@@ -22,6 +22,7 @@ Arguments:
22
22
 
23
23
  Options:
24
24
  --force Skip confirmation prompt
25
+ --query Print the request route + JSON body without sending it
25
26
  -h, --help Show this help
26
27
 
27
28
  Examples:
@@ -36,6 +37,12 @@ export async function run(values, positional) {
36
37
  const res = requireResource(resourceName, 'zeyos delete <resource> <id>', 'delete', 'deletion');
37
38
  requireRecordId(id, 'zeyos delete <resource> <id>');
38
39
 
40
+ const clientState = buildCliClient();
41
+
42
+ // ── Dry run ────────────────────────────────────────────────────────────────
43
+ // Show the request without prompting or deleting anything.
44
+ if (await maybeDryRun(clientState, res.delete, { ID: id }, values)) return;
45
+
39
46
  // ── Confirmation ───────────────────────────────────────────────────────────
40
47
  if (!values.force) {
41
48
  const confirmed = await _confirm(`Delete ${resourceName} #${id}? [y/N] `);
@@ -45,8 +52,6 @@ export async function run(values, positional) {
45
52
  }
46
53
  }
47
54
 
48
- const clientState = buildCliClient();
49
-
50
55
  // ── Call API ───────────────────────────────────────────────────────────────
51
56
  await callApi(clientState, res.delete, { ID: id }, {
52
57
  notFoundMessage: `${resourceName} #${id} not found.`
@@ -82,12 +82,19 @@ export function run(values, positional = []) {
82
82
  return;
83
83
  }
84
84
 
85
+ // Keep the join-critical flags (→ fk, indexed, enum) in the table, but keep
86
+ // the `enum:` note SHORT so the long value list never blows out the column.
87
+ // The full enum values are printed below the table (see `enumDetails`), so FK
88
+ // and index flags stay legible in-line and the enum codes remain discoverable.
89
+ const enumDetails = [];
85
90
  const rows = Object.entries(def.fields).map(([name, field]) => {
86
91
  const notes = [];
87
92
  if (field.fk) notes.push(`→ ${field.fk}`);
88
93
  if (field.indexed) notes.push('indexed');
89
94
  if (field.enum) {
90
- notes.push('enum: ' + Object.entries(field.enum).map(([k, v]) => `${k}=${v}`).join(' '));
95
+ const count = Object.keys(field.enum).length;
96
+ notes.push(`enum (${count})`);
97
+ enumDetails.push({ name, values: field.enum });
91
98
  }
92
99
  return { field: name, type: field.type, notes: notes.join(' ') };
93
100
  });
@@ -96,6 +103,20 @@ export function run(values, positional = []) {
96
103
 
97
104
  process.stdout.write(`\n ${c.bold(def.name)} ${c.dim(`(${def.type}, ${rows.length} fields)`)}\n`);
98
105
  printTable(rows, ['field', 'type', 'notes']);
106
+
107
+ // Full enum values, one field per block, below the table. Each `code = LABEL`
108
+ // pair is on its own line so even long enums (e.g. ticket status) stay readable.
109
+ if (enumDetails.length > 0) {
110
+ process.stdout.write(` ${c.bold('enums')}\n`);
111
+ for (const { name, values } of enumDetails) {
112
+ process.stdout.write(` ${c.cyan(name)}\n`);
113
+ for (const [code, label] of Object.entries(values)) {
114
+ process.stdout.write(` ${c.dim(code.padStart(2))} ${label}\n`);
115
+ }
116
+ }
117
+ process.stdout.write('\n');
118
+ }
119
+
99
120
  if (operations.length > 0) {
100
121
  process.stdout.write(` ${c.bold('operations')} ${c.dim(operations.join(', '))}\n\n`);
101
122
  }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * zeyos doctor agent
3
+ *
4
+ * Offline diagnostic for coding agents before they rely on the CLI.
5
+ */
6
+
7
+ import { createRequire } from 'node:module';
8
+ import { existsSync, readdirSync } from 'node:fs';
9
+ import { loadConfigWithSource, localConfigPath, globalConfigPath } from '../lib/config.mjs';
10
+ import { loadResourceConfig } from '../lib/resource-config.mjs';
11
+ import { listResources, resolveResource } from '../lib/resources.mjs';
12
+ import { colors as c, error, outputMode, printJson, printYaml } from '../lib/output.mjs';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const VERSION = require('../package.json').version;
16
+
17
+ const ENV_KEYS = {
18
+ ZEYOS_BASE_URL: 'baseUrl',
19
+ ZEYOS_INSTANCE: 'instance',
20
+ ZEYOS_CLIENT_ID: 'clientId',
21
+ ZEYOS_CLIENT_SECRET: 'clientSecret',
22
+ ZEYOS_TOKEN: 'accessToken',
23
+ ZEYOS_REFRESH_TOKEN: 'refreshToken',
24
+ };
25
+
26
+ export const USAGE = `\
27
+ Usage: zeyos doctor agent [options]
28
+
29
+ Check local CLI readiness for coding agents. Runs offline and never prints
30
+ tokens or client secrets.
31
+
32
+ Options:
33
+ --json Output as JSON
34
+ --yaml Output as YAML
35
+ -h, --help Show this help
36
+
37
+ Examples:
38
+ zeyos doctor agent
39
+ zeyos doctor agent --json
40
+ `;
41
+
42
+ export function run(values, positional = []) {
43
+ const subject = positional[0];
44
+ if (subject !== 'agent') {
45
+ error('Unknown doctor target. Usage: zeyos doctor agent');
46
+ process.exit(1);
47
+ }
48
+
49
+ const report = buildAgentReport();
50
+ const mode = outputMode(values);
51
+
52
+ if (mode === 'json') {
53
+ printJson(report);
54
+ return;
55
+ }
56
+ if (mode === 'yaml') {
57
+ printYaml(report);
58
+ return;
59
+ }
60
+
61
+ printAgentReport(report);
62
+ }
63
+
64
+ function buildAgentReport() {
65
+ const localPath = localConfigPath();
66
+ const globalPath = globalConfigPath();
67
+ const envVariables = Object.keys(ENV_KEYS).filter((key) => process.env[key]);
68
+ let loaded = { config: {}, source: null };
69
+ let configError = null;
70
+
71
+ try {
72
+ loaded = loadConfigWithSource();
73
+ } catch (err) {
74
+ configError = err.message || String(err);
75
+ }
76
+
77
+ const config = loaded.config;
78
+ const effective = {
79
+ baseUrl: Boolean(config.baseUrl),
80
+ instance: Boolean(config.instance),
81
+ clientId: Boolean(config.clientId),
82
+ clientSecret: Boolean(config.clientSecret),
83
+ accessToken: Boolean(config.accessToken),
84
+ refreshToken: Boolean(config.refreshToken),
85
+ };
86
+ const ready = Boolean(effective.baseUrl && effective.clientId && effective.clientSecret && effective.accessToken);
87
+ const resources = inspectResources();
88
+
89
+ return {
90
+ ok: ready && !configError && resources.ok,
91
+ cli: {
92
+ version: VERSION,
93
+ },
94
+ connection: {
95
+ baseUrl: config.baseUrl ?? null,
96
+ instance: config.instance ?? null,
97
+ },
98
+ auth: {
99
+ ready,
100
+ source: envVariables.length > 0 ? 'env' : loaded.source,
101
+ env: {
102
+ present: envVariables.length > 0,
103
+ variables: envVariables,
104
+ },
105
+ local: {
106
+ present: Boolean(localPath),
107
+ path: localPath,
108
+ },
109
+ global: {
110
+ present: existsSync(globalPath),
111
+ path: globalPath,
112
+ },
113
+ effective,
114
+ error: configError,
115
+ },
116
+ resources,
117
+ };
118
+ }
119
+
120
+ function inspectResources() {
121
+ const names = listResources();
122
+ const missing = [];
123
+ const configErrors = [];
124
+
125
+ for (const name of names) {
126
+ if (!resolveResource(name)) {
127
+ missing.push(name);
128
+ continue;
129
+ }
130
+
131
+ try {
132
+ loadResourceConfig(name);
133
+ } catch (err) {
134
+ configErrors.push(err.message || String(err));
135
+ }
136
+ }
137
+
138
+ return {
139
+ ok: names.length > 0 && missing.length === 0 && configErrors.length === 0,
140
+ count: names.length,
141
+ shippedConfigCount: countShippedResourceConfigs(),
142
+ missing,
143
+ configErrors,
144
+ };
145
+ }
146
+
147
+ function countShippedResourceConfigs() {
148
+ try {
149
+ return readdirSync(new URL('../config/', import.meta.url))
150
+ .filter((name) => name.endsWith('.json'))
151
+ .length;
152
+ } catch {
153
+ return 0;
154
+ }
155
+ }
156
+
157
+ function printAgentReport(report) {
158
+ process.stdout.write('\n');
159
+ process.stdout.write(` ${c.bold('ZeyOS CLI doctor: agent')}\n\n`);
160
+ process.stdout.write(` CLI version ${report.cli.version}\n`);
161
+ process.stdout.write(` Base URL ${report.connection.baseUrl ?? '(not set)'}\n`);
162
+ process.stdout.write(` Instance ${report.connection.instance ?? '(not set)'}\n`);
163
+ process.stdout.write(` Auth ready ${yesNo(report.auth.ready)}\n`);
164
+ process.stdout.write(` Auth source ${report.auth.source ?? '(none)'}\n`);
165
+ process.stdout.write(` Env config ${report.auth.env.present ? report.auth.env.variables.join(', ') : '(none)'}\n`);
166
+ process.stdout.write(` Local config ${report.auth.local.present ? report.auth.local.path : '(none)'}\n`);
167
+ process.stdout.write(` Global config ${report.auth.global.present ? report.auth.global.path : '(none)'}\n`);
168
+ process.stdout.write(` Resource registry ${report.resources.ok ? 'ok' : 'problem'} (${report.resources.count} resources, ${report.resources.shippedConfigCount} shipped configs)\n`);
169
+
170
+ if (report.auth.error) {
171
+ process.stdout.write(`\n ${c.bold('Auth config error')}\n`);
172
+ process.stdout.write(` ${report.auth.error}\n`);
173
+ }
174
+ if (report.resources.configErrors.length > 0) {
175
+ process.stdout.write(`\n ${c.bold('Resource config errors')}\n`);
176
+ for (const message of report.resources.configErrors) {
177
+ process.stdout.write(` ${message}\n`);
178
+ }
179
+ }
180
+
181
+ process.stdout.write('\n');
182
+ }
183
+
184
+ function yesNo(value) {
185
+ return value ? 'yes' : 'no';
186
+ }
package/commands/get.mjs CHANGED
@@ -17,11 +17,12 @@
17
17
  import { loadConfig } from '../lib/config.mjs';
18
18
  import { canonicalName } from '../lib/resources.mjs';
19
19
  import { getGetFields, getGetParams } from '../lib/resource-config.mjs';
20
- import { outputMode, printJson, printYaml, printRecord, buildDateFormatters } from '../lib/output.mjs';
20
+ import { outputMode, printJson, printYaml, printRecord, buildDateFormatters, buildEnumFormatters } from '../lib/output.mjs';
21
21
  import {
22
22
  buildCliClient,
23
23
  callApi,
24
24
  fail,
25
+ maybeDryRun,
25
26
  requireRecordId,
26
27
  requireResource
27
28
  } from '../lib/command.mjs';
@@ -44,6 +45,7 @@ Options:
44
45
  --all Fetch all data (extdata + tags + all fields)
45
46
  --json Output as JSON
46
47
  --yaml Output as YAML
48
+ --query Print the request route + JSON body without sending it
47
49
  -h, --help Show this help
48
50
 
49
51
  Fields format:
@@ -98,6 +100,8 @@ export async function run(values, positional) {
98
100
  }
99
101
 
100
102
  // ── Call API ───────────────────────────────────────────────────────────────
103
+ if (await maybeDryRun(clientState, res.get, params, values)) return;
104
+
101
105
  const record = await callApi(clientState, res.get, params, {
102
106
  notFoundMessage: `${resourceName} #${id} not found.`
103
107
  });
@@ -121,7 +125,16 @@ export async function run(values, positional) {
121
125
  const cfg = loadConfig();
122
126
  const dateFormat = cfg.dateFormat ?? 'YYYY-MM-DD';
123
127
  const displayKeys = fields ?? Object.keys(record);
124
- const formatters = buildDateFormatters(displayKeys, dateFormat);
125
- printRecord(record, displayKeys, fieldLabels, formatters);
128
+ const dateFormatters = buildDateFormatters(displayKeys, dateFormat);
129
+
130
+ // QW-3: schema-driven enum/ID coloring in the single-record view too.
131
+ // Enum values are colored by their resolved label keyword; ID/FK fields are
132
+ // dimmed. Date formatters win for date columns. No-op when color is off.
133
+ const schema = clientState.client.schema;
134
+ const schemaKey = schema?.resourceForOperation?.(res.get);
135
+ const fieldDefs = schemaKey ? schema.describe(schemaKey)?.fields : undefined;
136
+ const enumFormatters = fieldDefs ? buildEnumFormatters(displayKeys, fieldDefs) : {};
137
+
138
+ printRecord(record, displayKeys, fieldLabels, { ...enumFormatters, ...dateFormatters });
126
139
  }
127
140
  }
package/commands/list.mjs CHANGED
@@ -6,6 +6,7 @@
6
6
  * Options:
7
7
  * --fields <list> Field selection (comma-separated or JSON object)
8
8
  * --filter <json> JSON filter object e.g. '{"status":1}'
9
+ * --filter-file <path> Read JSON filter object from a file
9
10
  * --sort <field> Sort field, prefix with - for descending e.g. '-lastmodified'
10
11
  * --limit <n> Max records to fetch (default: 50)
11
12
  * --offset <n> Skip first N records (default: 0)
@@ -19,12 +20,13 @@ import { normalizeListResult } from '@zeyos/client';
19
20
  import { loadConfig } from '../lib/config.mjs';
20
21
  import { canonicalName } from '../lib/resources.mjs';
21
22
  import { getListFields } from '../lib/resource-config.mjs';
22
- import { outputMode, printJson, printYaml, printTable, buildDateFormatters, warn, info } from '../lib/output.mjs';
23
+ import { outputMode, printJson, printYaml, printTable, buildDateFormatters, buildEnumFormatters, info } from '../lib/output.mjs';
23
24
  import {
24
25
  buildCliClient,
25
26
  callApi,
26
27
  fail,
27
- parseJsonOption,
28
+ maybeDryRun,
29
+ parseJsonOptionOrFile,
28
30
  requireApiMethod,
29
31
  requireResource
30
32
  } from '../lib/command.mjs';
@@ -40,6 +42,8 @@ Arguments:
40
42
  Options:
41
43
  --fields <list> Field selection (see formats below)
42
44
  --filter <json> JSON filter object e.g. '{"status":1}'
45
+ --filter-file <path>
46
+ Read JSON filter object from a file
43
47
  --sort <fields> Sort expression e.g. '-lastmodified'
44
48
  --limit <n> Max records (default: 50)
45
49
  --offset <n> Skip first N records (default: 0)
@@ -47,6 +51,7 @@ Options:
47
51
  --expand <list> Expand JSON/binary columns (e.g. binfile, items)
48
52
  --json Output as JSON
49
53
  --yaml Output as YAML
54
+ --query Print the request route + JSON body without sending it
50
55
  -h, --help Show this help
51
56
 
52
57
  Fields format:
@@ -57,6 +62,7 @@ Fields format:
57
62
  Examples:
58
63
  zeyos list tickets
59
64
  zeyos list tickets --filter '{"status":1}' --sort -lastmodified
65
+ zeyos list tickets --filter-file ./filters/open-tickets.json
60
66
  zeyos list tickets --fields ID,name,status --limit 10
61
67
  zeyos list accounts --fields '{"Name": "lastname", "City": "contact.city"}'
62
68
  zeyos list tickets --extdata
@@ -68,7 +74,6 @@ export async function run(values, positional) {
68
74
  const res = requireResource(resourceName, 'zeyos list <resource>');
69
75
 
70
76
  const resName = canonicalName(resourceName);
71
- const clientState = buildCliClient();
72
77
 
73
78
  // ── Resolve field config ──────────────────────────────────────────────────
74
79
  const { apiFields, displayColumns } = getListFields(res, resName, values.fields);
@@ -79,8 +84,9 @@ export async function run(values, positional) {
79
84
  // Pass configured fields to the API for server-side field selection
80
85
  if (apiFields) body.fields = apiFields;
81
86
 
82
- if (values.filter) {
83
- body.filters = parseJsonOption(values.filter, 'filter');
87
+ const filters = parseJsonOptionOrFile(values, 'filter', 'filter-file');
88
+ if (filters !== undefined) {
89
+ body.filters = filters;
84
90
  }
85
91
 
86
92
  if (values.sort) body.sort = values.sort.split(',').map(s => s.trim()).filter(Boolean);
@@ -110,6 +116,9 @@ export async function run(values, positional) {
110
116
  }
111
117
 
112
118
  // ── Call API ───────────────────────────────────────────────────────────────
119
+ const clientState = buildCliClient();
120
+ if (await maybeDryRun(clientState, res.list, body, values)) return;
121
+
113
122
  const fn = requireApiMethod(clientState, res.list);
114
123
  let records = await callApi(clientState, res.list, body);
115
124
 
@@ -125,13 +134,27 @@ export async function run(values, positional) {
125
134
  } else if (mode === 'yaml') {
126
135
  printYaml(records);
127
136
  } else if (records.length === 0) {
128
- warn(`No ${resourceName} found.`);
137
+ // QW-7: an empty result is a neutral fact, not a warning — use the info `·`
138
+ // glyph rather than the `⚠` glyph (which reads as an error).
139
+ info(`No ${resourceName} match.`);
129
140
  return;
130
141
  } else {
131
142
  const cfg = loadConfig();
132
143
  const dateFormat = cfg.dateFormat ?? 'YYYY-MM-DD';
133
- const formatters = buildDateFormatters(displayColumns, dateFormat, apiFields);
134
- printTable(records, displayColumns, {}, formatters);
144
+ const dateFormatters = buildDateFormatters(displayColumns, dateFormat, apiFields);
145
+
146
+ // QW-3: schema-driven enum/ID coloring. Resolve the resource's field defs
147
+ // (enums, FKs) via the same schema source `describe` uses, then color enum
148
+ // values by label keyword and dim ID/FK columns. No-op when color is off.
149
+ // Date formatters win for date columns (a column is never both).
150
+ const schema = clientState.client.schema;
151
+ const schemaKey = schema?.resourceForOperation?.(res.list);
152
+ const fieldDefs = schemaKey ? schema.describe(schemaKey)?.fields : undefined;
153
+ const enumFormatters = fieldDefs
154
+ ? buildEnumFormatters(displayColumns, fieldDefs, apiFields)
155
+ : {};
156
+
157
+ printTable(records, displayColumns, {}, { ...enumFormatters, ...dateFormatters });
135
158
  }
136
159
 
137
160
  // ── Pagination / truncation hint ──────────────────────────────────────────
@@ -149,14 +172,14 @@ export async function run(values, positional) {
149
172
  const countResult = await fn(countBody);
150
173
  const total = countResult?.count ?? null;
151
174
  if (total !== null && total > records.length) {
152
- info(`Showing ${from}–${to} of ${total} (default --limit ${limit} truncated this — pass --limit, --offset ${to} for the next page, or use \`zeyos count ${resourceName}\` for the total).`);
175
+ info(`→ Showing ${from}–${to} of ${total} (default --limit ${limit} truncated this — pass --limit, --offset ${to} for the next page, or use \`zeyos count ${resourceName}\` for the total).`);
153
176
  } else if (total !== null) {
154
- info(`Showing ${from}–${to} of ${total} (--offset ${to} for next page)`);
177
+ info(`→ Showing ${from}–${to} of ${total} (--offset ${to} for next page)`);
155
178
  }
156
179
  } catch {
157
180
  // Non-critical — skip pagination info
158
181
  }
159
182
  } else if (offset > 0) {
160
- info(`Showing ${from}–${to} of ${to}`);
183
+ info(`→ Showing ${from}–${to} of ${to}`);
161
184
  }
162
185
  }
@@ -177,7 +177,7 @@ async function resolveTarget(values) {
177
177
  } else if (interactive) {
178
178
  agent = await promptAgent();
179
179
  } else {
180
- agent = detectAgent() || AGENTS[0];
180
+ agent = detectAgent() || AGENTS.find((a) => a.key === 'agents');
181
181
  }
182
182
 
183
183
  // (b) Install globally or just for this project?
@@ -225,7 +225,8 @@ function promptMenu(question, items, defaultIndex = 0) {
225
225
 
226
226
  async function promptAgent() {
227
227
  const detected = detectAgent();
228
- const defaultIndex = detected ? AGENTS.indexOf(detected) : 0;
228
+ const agentsIdx = AGENTS.findIndex((a) => a.key === 'agents');
229
+ const defaultIndex = detected ? AGENTS.indexOf(detected) : agentsIdx;
229
230
  const items = AGENTS.map((a) => ({
230
231
  label: a.label,
231
232
  hint: a === detected ? `${a.local} (detected here)` : a.local,
@@ -4,15 +4,17 @@
4
4
  * Update an existing record. Works like `create` but requires an ID.
5
5
  *
6
6
  * Options:
7
- * --data <json> Fields to update as a JSON object
8
- * --json Output updated record as JSON
9
- * --yaml Output updated record as YAML
7
+ * --data <json> Fields to update as a JSON object
8
+ * --data-file <path> Read fields to update as a JSON object from a file
9
+ * --json Output updated record as JSON
10
+ * --yaml Output updated record as YAML
10
11
  */
11
12
 
12
13
  import {
13
14
  buildCliClient,
14
15
  buildRecordPayload,
15
16
  callApi,
17
+ maybeDryRun,
16
18
  requireRecordId,
17
19
  requireResource
18
20
  } from '../lib/command.mjs';
@@ -29,14 +31,17 @@ Arguments:
29
31
 
30
32
  Options:
31
33
  --data <json> Fields to update as a JSON object
34
+ --data-file <path> Read fields to update as a JSON object from a file
32
35
  --<field> <value> Set individual fields e.g. --status 2
33
36
  --json Output updated record as JSON
34
37
  --yaml Output updated record as YAML
38
+ --query Print the request route + JSON body without sending it
35
39
  -h, --help Show this help
36
40
 
37
41
  Examples:
38
42
  zeyos update ticket 42 --status 3
39
43
  zeyos update account 7 --data '{"email":"new@example.com"}'
44
+ zeyos update ticket 42 --data-file ./ticket-update.json
40
45
  `;
41
46
 
42
47
  export async function run(values, positional) {
@@ -54,6 +59,8 @@ export async function run(values, positional) {
54
59
  const clientState = buildCliClient();
55
60
 
56
61
  // ── Call API ───────────────────────────────────────────────────────────────
62
+ if (await maybeDryRun(clientState, res.update, { ID: id, body: data }, values)) return;
63
+
57
64
  const record = await callApi(clientState, res.update, { ID: id, body: data }, {
58
65
  notFoundMessage: `${resourceName} #${id} not found.`
59
66
  });
package/lib/command.mjs CHANGED
@@ -1,7 +1,9 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
1
3
  import { buildClient, syncTokens } from './client.mjs';
2
4
  import { collectFieldFlags } from './flags.mjs';
3
5
  import { resolveResource } from './resources.mjs';
4
- import { error, info, warn } from './output.mjs';
6
+ import { error, info, warn, printQuery } from './output.mjs';
5
7
 
6
8
  export function fail(message) {
7
9
  error(message);
@@ -49,6 +51,54 @@ export function parseJsonOption(value, flagName) {
49
51
  }
50
52
  }
51
53
 
54
+ export function parseJsonFileOption(value, flagName) {
55
+ if (value == null || value === '') {
56
+ fail(`--${flagName} requires a file path.`);
57
+ }
58
+
59
+ const filePath = String(value);
60
+ const absolutePath = resolve(process.cwd(), filePath);
61
+ let text;
62
+
63
+ try {
64
+ text = readFileSync(absolutePath, 'utf8');
65
+ } catch (err) {
66
+ if (err?.code === 'ENOENT') {
67
+ fail(`--${flagName} file not found: ${filePath}`);
68
+ }
69
+ if (err?.code === 'EISDIR') {
70
+ fail(`--${flagName} points to a directory, not a JSON file: ${filePath}`);
71
+ }
72
+ fail(`Could not read --${flagName} file ${filePath}: ${err.message || err}`);
73
+ }
74
+
75
+ try {
76
+ return JSON.parse(text);
77
+ } catch (err) {
78
+ fail(`--${flagName} file must contain valid JSON: ${filePath} (${err.message || err})`);
79
+ }
80
+ }
81
+
82
+ export function parseJsonOptionOrFile(values, flagName, fileFlagName = `${flagName}-file`) {
83
+ const hasInline = Object.prototype.hasOwnProperty.call(values, flagName);
84
+ const hasFile = Object.prototype.hasOwnProperty.call(values, fileFlagName);
85
+
86
+ if (hasInline && hasFile) {
87
+ fail(`Use either --${flagName} or --${fileFlagName}, not both.`);
88
+ }
89
+ if (hasInline) {
90
+ if (values[flagName] === '') {
91
+ fail(`--${flagName} requires a JSON value. Use --${fileFlagName} <path> for file input.`);
92
+ }
93
+ return parseJsonOption(values[flagName], flagName);
94
+ }
95
+ if (hasFile) {
96
+ return parseJsonFileOption(values[fileFlagName], fileFlagName);
97
+ }
98
+
99
+ return undefined;
100
+ }
101
+
52
102
  /** Cheap structural check: does this string look like an intended JSON object? */
53
103
  function looksLikeJsonObject(value) {
54
104
  return typeof value === 'string' && value.trim().startsWith('{');
@@ -90,7 +140,7 @@ function tryParseJsonObject(value) {
90
140
  * @returns {Record<string, unknown>}
91
141
  */
92
142
  export function buildRecordPayload(values, positionalData) {
93
- const parsed = parseJsonOption(values.data, 'data');
143
+ const parsed = parseJsonOptionOrFile(values, 'data', 'data-file');
94
144
  const data = parsed === undefined ? {} : parsed;
95
145
 
96
146
  if (!data || typeof data !== 'object' || Array.isArray(data)) {
@@ -125,6 +175,31 @@ export function buildRecordPayload(values, positionalData) {
125
175
  fail('No fields provided. Use --data or individual --<field> flags.');
126
176
  }
127
177
 
178
+ /**
179
+ * Handle the global `--query` flag: instead of sending the request, ask the
180
+ * client to resolve the route + payload (dry run) and print them. Returns
181
+ * `true` when it handled a dry run, so the caller can `return` early.
182
+ *
183
+ * @param {ReturnType<typeof buildCliClient>} clientState
184
+ * @param {string} operationId
185
+ * @param {unknown} input - the same input the real call would receive
186
+ * @param {Record<string, unknown>} values - parsed CLI flags
187
+ * @returns {Promise<boolean>}
188
+ */
189
+ export async function maybeDryRun(clientState, operationId, input, values) {
190
+ if (!values.query) return false;
191
+
192
+ const fn = requireApiMethod(clientState, operationId);
193
+ let descriptor;
194
+ try {
195
+ descriptor = await fn(input, { dryRun: true });
196
+ } catch (err) {
197
+ fail(`Could not build request: ${err.message}`);
198
+ }
199
+ printQuery(descriptor, values);
200
+ return true;
201
+ }
202
+
128
203
  export function requireApiMethod(clientState, operationId) {
129
204
  const fn = clientState.client.api[operationId];
130
205
  if (typeof fn !== 'function') {
package/lib/flags.mjs CHANGED
@@ -6,11 +6,11 @@
6
6
  // Global CLI flags that are never record fields. Any other --flag on
7
7
  // create/update is treated as a field on the record being written.
8
8
  const RESERVED_FLAGS = new Set([
9
- 'data', 'json', 'yaml', 'help', 'h',
10
- 'no-color', 'force', 'fields', 'filter', 'sort',
9
+ 'data', 'data-file', 'json', 'yaml', 'help', 'h',
10
+ 'no-color', 'force', 'fields', 'filter', 'filter-file', 'sort',
11
11
  'limit', 'offset', 'expand', 'base-url', 'client-id',
12
12
  'secret', 'scope', 'global', 'port', 'manual', 'show-token',
13
- 'extdata', 'tags', 'all', 'clean',
13
+ 'extdata', 'tags', 'all', 'clean', 'query',
14
14
  ]);
15
15
 
16
16
  /**
package/lib/output.mjs CHANGED
@@ -20,6 +20,7 @@ const c = {
20
20
  red: s => USE_COLOR ? `\x1b[31m${s}\x1b[0m` : s,
21
21
  yellow: s => USE_COLOR ? `\x1b[33m${s}\x1b[0m` : s,
22
22
  cyan: s => USE_COLOR ? `\x1b[36m${s}\x1b[0m` : s,
23
+ gray: s => USE_COLOR ? `\x1b[90m${s}\x1b[0m` : s, // bright-black: dim IDs / muted cells
23
24
  };
24
25
 
25
26
  export { c as colors };
@@ -39,6 +40,33 @@ export function printJson(data) {
39
40
  process.stdout.write(JSON.stringify(data, null, 2) + '\n');
40
41
  }
41
42
 
43
+ // ── Query (dry run) ─────────────────────────────────────────────────────────────
44
+
45
+ /**
46
+ * Print a dry-run request descriptor (from `--query`): the resolved HTTP route
47
+ * and the JSON payload that *would* be sent, without performing the request.
48
+ *
49
+ * @param {{method:string,url:string,body?:unknown,bodyType?:string}} descriptor
50
+ * @param {Record<string, unknown>} [values] - parsed CLI flags (for --json/--yaml)
51
+ */
52
+ export function printQuery(descriptor, values = {}) {
53
+ if (values.json) { printJson(descriptor); return; }
54
+ if (values.yaml) { printYaml(descriptor); return; }
55
+
56
+ const { method, url, body, bodyType } = descriptor;
57
+ process.stdout.write(`${c.bold(method)} ${url}\n`);
58
+ if (bodyType) {
59
+ const contentType = bodyType === 'form' ? 'application/x-www-form-urlencoded' : 'application/json';
60
+ process.stdout.write(c.dim(`Content-Type: ${contentType}`) + '\n');
61
+ }
62
+ process.stdout.write('\n');
63
+ if (body === undefined || body === null) {
64
+ process.stdout.write(c.dim('(no request body)') + '\n');
65
+ } else {
66
+ process.stdout.write(JSON.stringify(body, null, 2) + '\n');
67
+ }
68
+ }
69
+
42
70
  // ── YAML ──────────────────────────────────────────────────────────────────────
43
71
 
44
72
  export function printYaml(data) {
@@ -129,14 +157,54 @@ export function printTable(rows, columns, labels = {}, formatters = {}) {
129
157
  Math.max(headers[i].length, ...data.map(row => _visibleLength(row[i])))
130
158
  );
131
159
 
132
- const headerRow = headers.map((h, i) => _pad(c.bold(h), widths[i])).join(' ');
160
+ // QW-2: detect numeric columns (every non-empty cell is a plain number,
161
+ // ignoring ANSI) so we can right-align them — header included.
162
+ const numeric = columns.map((_, i) => {
163
+ let sawValue = false;
164
+ for (const row of data) {
165
+ const plain = row[i].replace(/\x1b\[[0-9;]*m/g, '');
166
+ if (plain === '' || plain === '—') continue; // blank / em-dash placeholder
167
+ sawValue = true;
168
+ if (!/^-?\d+(\.\d+)?$/.test(plain)) return false;
169
+ }
170
+ return sawValue;
171
+ });
172
+
173
+ // QW-1: when stdout is a TTY, shrink the widest column(s) until the row fits
174
+ // the terminal. Non-TTY (piped) output stays full-width so `| grep`/`| awk`
175
+ // see complete cell text. Budget = columns − 2 leading spaces − 2 per gutter.
176
+ if (process.stdout.isTTY) {
177
+ const term = process.stdout.columns;
178
+ if (term && term > 0) {
179
+ const MIN_COL = 8;
180
+ const gutters = (widths.length - 1) * 2;
181
+ const budget = term - 2 - gutters;
182
+ // Repeatedly trim the single widest column above the floor until it fits.
183
+ let total = widths.reduce((a, b) => a + b, 0);
184
+ while (total > budget) {
185
+ let widest = -1;
186
+ for (let i = 0; i < widths.length; i++) {
187
+ if (widths[i] > MIN_COL && (widest === -1 || widths[i] > widths[widest])) widest = i;
188
+ }
189
+ if (widest === -1) break; // every column already at the floor
190
+ widths[widest]--;
191
+ total--;
192
+ }
193
+ }
194
+ }
195
+
196
+ const align = (str, i) => (numeric[i] ? _padLeft(str, widths[i]) : _pad(_truncate(str, widths[i]), widths[i]));
197
+
198
+ const headerRow = headers.map((h, i) =>
199
+ numeric[i] ? _padLeft(c.bold(h), widths[i]) : _pad(_truncate(c.bold(h), widths[i]), widths[i])
200
+ ).join(' ');
133
201
  const separator = widths.map(w => '─'.repeat(w)).join(' ');
134
202
 
135
203
  process.stdout.write('\n');
136
204
  process.stdout.write(' ' + headerRow + '\n');
137
205
  process.stdout.write(' ' + c.dim(separator) + '\n');
138
206
  for (const row of data) {
139
- process.stdout.write(' ' + row.map((v, i) => _pad(v, widths[i])).join(' ') + '\n');
207
+ process.stdout.write(' ' + row.map((v, i) => align(v, i)).join(' ') + '\n');
140
208
  }
141
209
  process.stdout.write('\n');
142
210
  }
@@ -163,10 +231,15 @@ export function printRecord(record, keys, labels = {}, formatters = {}) {
163
231
 
164
232
  if (formatters[key]) {
165
233
  display = String(formatters[key](val, record));
166
- } else if (val === null) {
234
+ } else if (val === null || val === '') {
235
+ // QW-6: render null AND empty string as a dim em-dash, not a blank gap.
167
236
  display = c.dim('—');
237
+ } else if (Array.isArray(val)) {
238
+ // QW-6: empty array → dim em-dash; otherwise compact JSON.
239
+ display = val.length === 0 ? c.dim('—') : JSON.stringify(val);
168
240
  } else if (typeof val === 'object') {
169
- display = JSON.stringify(val);
241
+ // QW-6: empty object → dim em-dash; otherwise compact JSON.
242
+ display = Object.keys(val).length === 0 ? c.dim('—') : JSON.stringify(val);
170
243
  } else {
171
244
  display = String(val);
172
245
  }
@@ -269,6 +342,77 @@ export function buildDateFormatters(columns, dateFormat = 'YYYY-MM-DD', aliasToP
269
342
  return formatters;
270
343
  }
271
344
 
345
+ // ── Semantic enum / ID coloring (QW-3) ─────────────────────────────────────────
346
+
347
+ /**
348
+ * Pick a colorizer for an enum LABEL by keyword.
349
+ *
350
+ * Enum codes are resource-specific (ticket status 1 = AWAITINGACCEPTANCE but
351
+ * transaction status 1 = COMPLETED), so color is derived from the label text —
352
+ * never from the numeric code. Returns `null` when no keyword matches, so the
353
+ * caller renders the value plain rather than guessing.
354
+ *
355
+ * @param {string} label
356
+ * @returns {((s:string)=>string)|null}
357
+ */
358
+ function _enumColorForLabel(label) {
359
+ const L = String(label).toUpperCase();
360
+ // Positive / terminal-success states → green.
361
+ if (/COMPLETED|BOOKED|ACTIVE|DONE|ACCEPTED|PAID/.test(L)) return c.green;
362
+ // Failure / negative states → red.
363
+ if (/CANCELLED|CANCELED|FAILED|REJECTED|DELETED|OVERDUE/.test(L)) return c.red;
364
+ // Priority extremes.
365
+ if (/HIGHEST|HIGH/.test(L)) return c.red;
366
+ if (/LOWEST|LOW/.test(L)) return c.dim;
367
+ return null;
368
+ }
369
+
370
+ /** A field name that denotes a record identifier / foreign key → render dim. */
371
+ function _isIdField(name) {
372
+ const lower = String(name).toLowerCase();
373
+ return lower === 'id' || lower.endsWith('id');
374
+ }
375
+
376
+ /**
377
+ * Build value formatters that colorize enum + ID columns, schema-driven.
378
+ *
379
+ * For each display column, the API field path (via `aliasToPath`) is reduced to
380
+ * its leaf column name and looked up in `fieldDefs` (a resource's
381
+ * `schema.describe(resource).fields` map). Columns whose field has an `enum` are
382
+ * colored by label keyword; ID/FK columns are dimmed. Columns with no resolvable
383
+ * enum label are left plain. No-ops entirely when color is disabled.
384
+ *
385
+ * @param {string[]} columns - display column keys
386
+ * @param {Record<string, {enum?:Record<string,string>, fk?:string}>} [fieldDefs]
387
+ * @param {Record<string,string>} [aliasToPath] - alias → API field path
388
+ * @returns {Record<string, ValueFormatter>}
389
+ */
390
+ export function buildEnumFormatters(columns, fieldDefs = {}, aliasToPath) {
391
+ const formatters = {};
392
+ if (!USE_COLOR) return formatters; // color-gated: nothing to do when plain.
393
+
394
+ for (const col of columns) {
395
+ const fieldPath = aliasToPath?.[col] ?? col;
396
+ // Dot-notation joins (contact.city) can't be mapped to a base column reliably.
397
+ if (fieldPath.includes('.')) continue;
398
+ const def = fieldDefs[fieldPath];
399
+
400
+ if (def?.enum) {
401
+ const enumMap = def.enum;
402
+ formatters[col] = (val) => {
403
+ if (val == null || val === '') return c.dim('—');
404
+ const label = enumMap[String(val)];
405
+ if (label == null) return String(val); // unknown code → plain, never guess
406
+ const paint = _enumColorForLabel(label);
407
+ return paint ? paint(String(val)) : String(val);
408
+ };
409
+ } else if (_isIdField(fieldPath) || def?.fk) {
410
+ formatters[col] = (val) => (val == null || val === '' ? c.dim('—') : c.gray(String(val)));
411
+ }
412
+ }
413
+ return formatters;
414
+ }
415
+
272
416
  // ── Helpers ───────────────────────────────────────────────────────────────────
273
417
 
274
418
  /** String length ignoring ANSI escape codes. */
@@ -282,3 +426,49 @@ function _pad(str, len) {
282
426
  if (visible >= len) return str;
283
427
  return str + ' '.repeat(len - visible);
284
428
  }
429
+
430
+ /** Left-pad a string to a visible width (right-align), ANSI-aware. */
431
+ function _padLeft(str, len) {
432
+ const visible = _visibleLength(str);
433
+ if (visible >= len) return str;
434
+ return ' '.repeat(len - visible) + str;
435
+ }
436
+
437
+ /**
438
+ * Truncate a string to a max visible width, appending '…', ANSI-aware.
439
+ * Preserves any trailing reset so colored cells don't bleed. Strings already
440
+ * within the budget are returned untouched.
441
+ *
442
+ * @param {string} str
443
+ * @param {number} max - max visible width (including the ellipsis)
444
+ * @returns {string}
445
+ */
446
+ function _truncate(str, max) {
447
+ if (max <= 0) return '';
448
+ if (_visibleLength(str) <= max) return str;
449
+
450
+ // Walk the string copying characters, skipping over ANSI sequences (which
451
+ // have zero visible width), until we've kept (max - 1) visible chars; then
452
+ // append the ellipsis and any trailing reset.
453
+ const keep = max - 1;
454
+ let out = '';
455
+ let visible = 0;
456
+ let i = 0;
457
+ let hadColor = false;
458
+ while (i < str.length && visible < keep) {
459
+ const ansi = str.slice(i).match(/^\x1b\[[0-9;]*m/);
460
+ if (ansi) {
461
+ out += ansi[0];
462
+ hadColor = true;
463
+ i += ansi[0].length;
464
+ continue;
465
+ }
466
+ out += str[i];
467
+ visible++;
468
+ i++;
469
+ }
470
+ out += '…';
471
+ // Re-apply a reset if the original was colored so the ellipsis/padding stay clean.
472
+ if (hadColor) out += '\x1b[0m';
473
+ return out;
474
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeyos/cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Command-line interface for the ZeyOS API",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -39,7 +39,7 @@
39
39
  "node": ">=18.3"
40
40
  },
41
41
  "dependencies": {
42
- "@zeyos/client": "^0.1.0"
42
+ "@zeyos/client": "^0.2.0"
43
43
  },
44
44
  "scripts": {
45
45
  "test": "node --test test/offline.mjs"