@zeyos/cli 0.1.1 → 0.3.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/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/lib/resources.mjs CHANGED
@@ -14,13 +14,21 @@
14
14
 
15
15
  /** @type {Record<string, ResourceDef>} */
16
16
  const REGISTRY = {
17
+ actionstep: {
18
+ list: 'listActionSteps',
19
+ get: 'getActionStep',
20
+ create: 'createActionStep',
21
+ update: 'updateActionStep',
22
+ delete: 'deleteActionStep',
23
+ fields: ['ID', 'actionnum', 'name', 'status', 'date', 'duedate', 'effort', 'ticket', 'task', 'account'],
24
+ },
17
25
  ticket: {
18
26
  list: 'listTickets',
19
27
  get: 'getTicket',
20
28
  create: 'createTicket',
21
29
  update: 'updateTicket',
22
30
  delete: 'deleteTicket',
23
- fields: ['ID', 'ticketnum', 'name', 'status', 'priority', 'duedate', 'lastmodified'],
31
+ fields: ['ID', 'ticketnum', 'name', 'status', 'priority', 'duedate', 'account', 'project', 'lastmodified'],
24
32
  },
25
33
  task: {
26
34
  list: 'listTasks',
@@ -28,7 +36,7 @@ const REGISTRY = {
28
36
  create: 'createTask',
29
37
  update: 'updateTask',
30
38
  delete: 'deleteTask',
31
- fields: ['ID', 'tasknum', 'name', 'status', 'priority', 'duedate', 'ticket'],
39
+ fields: ['ID', 'tasknum', 'name', 'status', 'priority', 'duedate', 'ticket', 'project', 'projectedeffort'],
32
40
  },
33
41
  account: {
34
42
  list: 'listAccounts',
@@ -84,7 +92,7 @@ const REGISTRY = {
84
92
  create: 'createMessage',
85
93
  update: 'updateMessage',
86
94
  delete: 'deleteMessage',
87
- fields: ['ID', 'subject', 'sender', 'created', 'read'],
95
+ fields: ['ID', 'date', 'mailbox', 'subject', 'sender_email', 'to_email', 'ticket', 'reference', 'messageid'],
88
96
  },
89
97
  item: {
90
98
  list: 'listItems',
@@ -144,6 +152,11 @@ const REGISTRY = {
144
152
  delete: 'deleteCampaign',
145
153
  fields: ['ID', 'name', 'status', 'startdate', 'enddate'],
146
154
  },
155
+ customfield: {
156
+ list: 'listCustomFields',
157
+ get: 'getCustomField',
158
+ fields: ['ID', 'name', 'identifier', 'context', 'reference', 'type', 'entity', 'activity'],
159
+ },
147
160
  file: {
148
161
  list: 'listFiles',
149
162
  get: 'getFile',
@@ -174,6 +187,13 @@ const REGISTRY = {
174
187
 
175
188
  const ALIASES = {
176
189
  // Plurals
190
+ actionsteps: 'actionstep',
191
+ 'action-steps': 'actionstep',
192
+ action_steps: 'actionstep',
193
+ timeentry: 'actionstep',
194
+ timeentries: 'actionstep',
195
+ 'time-entry': 'actionstep',
196
+ 'time-entries': 'actionstep',
177
197
  tickets: 'ticket',
178
198
  tasks: 'task',
179
199
  accounts: 'account',
@@ -193,6 +213,9 @@ const ALIASES = {
193
213
  payments: 'payment',
194
214
  opportunities:'opportunity',
195
215
  campaigns: 'campaign',
216
+ customfields: 'customfield',
217
+ custom_fields: 'customfield',
218
+ 'custom-fields': 'customfield',
196
219
  files: 'file',
197
220
  invitations: 'invitation',
198
221
  storages: 'storage',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeyos/cli",
3
- "version": "0.1.1",
3
+ "version": "0.3.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.3.0"
43
43
  },
44
44
  "scripts": {
45
45
  "test": "node --test test/offline.mjs"