@jacebenson/jsn 4.0.4 → 4.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.0.4",
3
+ "version": "4.1.0",
4
4
  "description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,8 +29,6 @@
29
29
  "@inquirer/core": "^10.1.0",
30
30
  "@inquirer/prompts": "^7.5.1",
31
31
  "better-sqlite3": "^13.0.3",
32
- "chalk": "^5.4.1",
33
- "cli-table3": "^0.6.5",
34
32
  "gray-matter": "^4.0.3",
35
33
  "turndown": "^7.2.4",
36
34
  "yargs": "^17.7.2"
package/src/cli.js CHANGED
@@ -10,6 +10,7 @@ import { App } from './app.js';
10
10
  import { renderHelp } from './help.js';
11
11
  import { isMutationCommand } from './mutations.js';
12
12
  import { guardExit } from './errors.js';
13
+ import { FormatJSON } from './output.js';
13
14
 
14
15
  // Command modules
15
16
  import { setupCmd } from './commands/setup.js';
@@ -86,6 +87,19 @@ function wrap(handler) {
86
87
  process.stderr.write(`Error (system): ${err.message}\n`);
87
88
  process.exit(3);
88
89
  }
90
+ if (err.code === 'confirmation_required') {
91
+ // Structured error for AI agents / piped consumers: emit the
92
+ // JSON envelope on stdout (where tooling reads) so the agent can
93
+ // surface the decision to its user or re-run with --force.
94
+ const app = argv.app;
95
+ if (app && app.output.effectiveFormat() === FormatJSON) {
96
+ app.err(err);
97
+ } else {
98
+ process.stderr.write(`Error: ${err.message}\n`);
99
+ if (err.hint) process.stderr.write(`\n${err.hint}\n`);
100
+ }
101
+ process.exit(1);
102
+ }
89
103
  process.stderr.write(`Error: ${err.message}\n`);
90
104
  process.exit(1);
91
105
  }
@@ -1,9 +1,9 @@
1
1
  // Generic command builder for CRUD operations on a ServiceNow table
2
2
  // Used by incidents, changes, requests, tasks, and most dev subcommands
3
3
 
4
- import { getStringField, formatRecordForDisplay, buildQuerySuffix, resolveFieldsParam, confirmDelete, assertSafeExactMatch } from '../helpers.js';
4
+ import { getStringField, formatRecordForDisplay, buildQuerySuffix, resolveFieldsParam, confirmDelete, assertSafeExactMatch, canPrompt } from '../helpers.js';
5
5
  import { search } from '@inquirer/prompts';
6
- import { isTTY, FormatAuto } from '../output.js';
6
+ import { FormatAuto } from '../output.js';
7
7
 
8
8
  export function buildTicketCommands(table, displayName, alias, defaultColumns, stateMap, iconFn, wrap) {
9
9
  return {
@@ -28,7 +28,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
28
28
  const offset = argv.offset;
29
29
 
30
30
  // Interactive picker in TTY with auto format and no explicit query/offset
31
- const isInteractive = isTTY(process.stdout) && isTTY(process.stdin);
31
+ const isInteractive = canPrompt();
32
32
  if (isInteractive && app.output.getFormat() === FormatAuto && !query && offset === 0) {
33
33
  const pickerColumns = ['sys_id', 'number', 'short_description', 'state', 'assigned_to'];
34
34
  const pickerParams = new URLSearchParams();
@@ -175,7 +175,15 @@ export function catalogCmd(wrap) {
175
175
  }
176
176
 
177
177
  async function showItem(app, sysID) {
178
- const item = await app.sdk.get('sc_cat_item', sysID);
178
+ // Use list + display_value=all so reference fields (category, flow) resolve
179
+ // to readable display values instead of raw sys_ids.
180
+ const p = new URLSearchParams();
181
+ p.set('sysparm_query', `sys_id=${sysID}`);
182
+ p.set('sysparm_limit', '1');
183
+ p.set('sysparm_display_value', 'all');
184
+ const items = await app.sdk.list('sc_cat_item', p);
185
+ const item = items[0] || null;
186
+ if (!item) throw new Error(`Not found: ${sysID}`);
179
187
 
180
188
  const vp = new URLSearchParams();
181
189
  vp.set('sysparm_query', `cat_item=${sysID}^active=true`);
@@ -216,7 +224,8 @@ async function showItem(app, sysID) {
216
224
  const wfName = getStringField(item, 'workflow') || '';
217
225
  const wfID = item.workflow?.value || '';
218
226
  const planName = getStringField(item, 'delivery_plan') || getStringField(item, 'execution_plan') || '';
219
- app.ok({
227
+
228
+ const data = {
220
229
  name: getStringField(item, 'name'),
221
230
  short_description: getStringField(item, 'short_description'),
222
231
  category: getStringField(item, 'category'),
@@ -232,7 +241,60 @@ async function showItem(app, sysID) {
232
241
  standalone: standalone.map(v => ({ label: v.question_text || v.name, type: v.type, mandatory: String(v.mandatory) === 'true' })),
233
242
  sets: Array.from(setVars.values()).map(s => ({ name: s.name, variables: s.variables.map(v => ({ label: v.question_text || v.name, type: v.type, mandatory: String(v.mandatory) === 'true' })) })),
234
243
  } : undefined,
235
- }, {
236
- summary: `${getStringField(item, 'name')} — ${totalVars} variable(s)`,
244
+ };
245
+
246
+ // Styled-mode rendering (non-enumerable so JSON output stays clean)
247
+ Object.defineProperty(data, '_formatted', {
248
+ value: buildCatalogFormatted(data, standalone, Array.from(setVars.values()), totalVars),
249
+ enumerable: false,
250
+ configurable: true,
237
251
  });
252
+
253
+ app.ok(data, {
254
+ summary: `${data.name} — ${totalVars} variable(s)`,
255
+ breadcrumbs: [
256
+ { action: 'list', cmd: 'jsn catalogitems list', description: 'Back to all catalog items' },
257
+ ],
258
+ });
259
+ }
260
+
261
+ function buildCatalogFormatted(data, standalone, setVars, totalVars) {
262
+ const lines = [];
263
+ lines.push(`${data.name} (sc_cat_item)`);
264
+ lines.push('');
265
+ lines.push('─ Details ─');
266
+ if (data.short_description) lines.push(` short_description: ${data.short_description}`);
267
+ if (data.category) lines.push(` category: ${data.category}`);
268
+ lines.push(` active: ${data.active}`);
269
+ if (data.url) lines.push(` url: ${data.url}`);
270
+ if (data.execution_plan) lines.push(` execution_plan: ${data.execution_plan}`);
271
+ lines.push('');
272
+ if (data.flow) {
273
+ lines.push('─ Flow ─');
274
+ lines.push(` ${data.flow}`);
275
+ if (data.flow_cmd) lines.push(` → ${data.flow_cmd}`);
276
+ lines.push('');
277
+ }
278
+ if (data.workflow) {
279
+ lines.push('─ Workflow ─');
280
+ lines.push(` ${data.workflow}`);
281
+ if (data.workflow_cmd) lines.push(` → ${data.workflow_cmd}`);
282
+ lines.push('');
283
+ }
284
+ if (totalVars > 0) {
285
+ lines.push('─ Catalog Variables ─');
286
+ for (const v of standalone) {
287
+ const label = v.question_text || v.name;
288
+ lines.push(` ${label}: ${v.type}${String(v.mandatory) === 'true' ? ' (mandatory)' : ''}`);
289
+ }
290
+ for (const s of setVars) {
291
+ lines.push(` [${s.name}]`);
292
+ for (const v of s.variables) {
293
+ const label = v.question_text || v.name;
294
+ lines.push(` ${label}: ${v.type}${String(v.mandatory) === 'true' ? ' (mandatory)' : ''}`);
295
+ }
296
+ }
297
+ lines.push('');
298
+ }
299
+ return lines.join('\n');
238
300
  }
@@ -1,9 +1,9 @@
1
1
  // Generic dev subcommand builder for table-based CRUD
2
2
 
3
- import { formatRecordForDisplay, getStringField, isHexString, parseDataArg, confirmDelete, assertSafeExactMatch } from '../../helpers.js';
3
+ import { formatRecordForDisplay, getStringField, isHexString, parseDataArg, confirmDelete, assertSafeExactMatch, canPrompt } from '../../helpers.js';
4
4
  import { getCurrentUser, getCurrentApplication } from '../../context.js';
5
5
  import { paginatedSearch } from '../../paginated-search.js';
6
- import { isTTY, FormatAuto } from '../../output.js';
6
+ import { FormatAuto } from '../../output.js';
7
7
 
8
8
  function vowelArticle(word) {
9
9
  const first = word.charAt(0).toLowerCase();
@@ -111,7 +111,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
111
111
  const limit = argv.limit;
112
112
 
113
113
  // Interactive picker with pagination
114
- const interactive = isTTY(process.stdout) && isTTY(process.stdin);
114
+ const interactive = canPrompt();
115
115
  if (interactive && app.output.getFormat() === FormatAuto && !query) {
116
116
  // Get total count
117
117
  let totalCount = 0;
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import { getCurrentUser, getCurrentApplication } from '../../context.js';
2
3
 
3
4
  export function evalCmd(wrap) {
4
5
  return {
@@ -8,16 +9,34 @@ export function evalCmd(wrap) {
8
9
  return yargs
9
10
  .option('script', { alias: 's', type: 'string', describe: "JavaScript code to execute (single-quote the script, double quotes inside: --script 'gs.log(\"Hello\")')" })
10
11
  .option('file', { alias: 'f', type: 'string', describe: 'Read script from file (avoids shell quoting issues)' })
11
- .option('scope', { alias: null, type: 'string', describe: 'Scope name or sys_id to run the script under (default: global)' });
12
+ .option('scope', { alias: null, type: 'string', describe: 'Scope name or sys_id to run the script under (default: active scope from banner)' });
12
13
  },
13
14
  handler: wrap(async (argv, app) => {
14
15
  let script;
15
16
  let scopeSysId = null;
16
17
 
17
- if (argv.scope) {
18
- scopeSysId = await app.sdk.resolveScope(argv.scope);
18
+ // Resolve execution scope: explicit --scope wins; otherwise use the
19
+ // active scope from apps.current_app (same source as the banner);
20
+ // fall back to global only when no active scope exists.
21
+ let scope = argv.scope || '';
22
+ if (!scope) {
23
+ try {
24
+ const user = await getCurrentUser(app.sdk);
25
+ if (user && user.sys_id) {
26
+ const currentApp = await getCurrentApplication(app.sdk, user.sys_id);
27
+ if (currentApp && currentApp.scope && currentApp.scope !== 'global') {
28
+ scope = currentApp.scope;
29
+ }
30
+ }
31
+ } catch {
32
+ // ignore — fall back to global
33
+ }
34
+ }
35
+
36
+ if (scope) {
37
+ scopeSysId = await app.sdk.resolveScope(scope);
19
38
  if (!scopeSysId) {
20
- throw new Error(`Scope not found: ${argv.scope}`);
39
+ throw new Error(`Scope not found: ${scope}`);
21
40
  }
22
41
  }
23
42
 
@@ -36,10 +55,13 @@ export function evalCmd(wrap) {
36
55
  const warning = scopeSysId
37
56
  ? ` ⚠️ Records without explicit sys_scope will land in global. Use $scopeSysId variable.`
38
57
  : '';
39
- const output = await app.sdk.executeScript(script, argv.scope);
58
+ // Pass the resolved sys_id (not the name) — sys.scripts.do only
59
+ // honors selectable scope values, which are sys_ids.
60
+ const output = await app.sdk.executeScript(script, scopeSysId || '');
40
61
  app.ok({
41
62
  script,
42
63
  output,
64
+ scope: scope || 'global',
43
65
  instance: app.getEffectiveInstance(),
44
66
  }, {
45
67
  summary: 'Script executed' + warning,
@@ -1,4 +1,4 @@
1
- import { formatRecordForDisplay, buildQuerySuffix, parseDataArg, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields, confirmDelete, assertSafeExactMatch } from '../helpers.js';
1
+ import { formatRecordForDisplay, buildQuerySuffix, parseDataArg, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields, confirmDelete, assertSafeExactMatch, verifyWriteBack } from '../helpers.js';
2
2
 
3
3
  const tableDefaultColumns = {
4
4
  incident: ['number', 'short_description', 'priority', 'state', 'assigned_to'],
@@ -135,7 +135,8 @@ export function recordsCmd(wrap) {
135
135
  .option('table', { type: 'string', demandOption: true, describe: 'Table name' })
136
136
  .option('data', { type: 'string', describe: 'JSON fields (e.g. \'{"state":"2"}\')' })
137
137
  .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
138
- .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin (pipe-friendly)' }),
138
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin (pipe-friendly)' })
139
+ .option('strict', { type: 'boolean', default: false, describe: 'Exit non-zero if any supplied field was not persisted (write-back check)' }),
139
140
  handler: wrap(async (argv, app) => {
140
141
  const recordData = parseDataArg(argv);
141
142
  const warnings = checkDerivedFields(argv.table, recordData);
@@ -143,6 +144,14 @@ export function recordsCmd(wrap) {
143
144
  process.stderr.write(`⚠ ${w.hint}\n`);
144
145
  }
145
146
  const record = await app.sdk.create(argv.table, recordData);
147
+ const sysID = getStringField(record, 'sys_id');
148
+ const mismatches = await verifyWriteBack(app, argv.table, sysID, recordData);
149
+ for (const m of mismatches) {
150
+ process.stderr.write(`⚠ field "${m.field}" was not persisted (sent: "${m.sent}", got: "${m.got}")\n`);
151
+ }
152
+ if (mismatches.length > 0 && argv.strict) {
153
+ throw new Error(`Strict mode: ${mismatches.length} field(s) not persisted after create`);
154
+ }
146
155
  app.ok(record, { summary: `Created record in ${argv.table}` });
147
156
  }),
148
157
  })
@@ -154,7 +163,8 @@ export function recordsCmd(wrap) {
154
163
  .option('sys-id', { type: 'string', demandOption: true, describe: 'Record sys_id' })
155
164
  .option('data', { type: 'string', describe: 'JSON fields (e.g. \'{"state":"2"}\')' })
156
165
  .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
157
- .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin (pipe-friendly)' }),
166
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin (pipe-friendly)' })
167
+ .option('strict', { type: 'boolean', default: false, describe: 'Exit non-zero if any supplied field was not persisted (write-back check)' }),
158
168
  handler: wrap(async (argv, app) => {
159
169
  const recordData = parseDataArg(argv);
160
170
  const warnings = checkDerivedFields(argv.table, recordData);
@@ -162,6 +172,13 @@ export function recordsCmd(wrap) {
162
172
  process.stderr.write(`⚠ ${w.hint}\n`);
163
173
  }
164
174
  const record = await app.sdk.update(argv.table, argv['sys-id'], recordData);
175
+ const mismatches = await verifyWriteBack(app, argv.table, argv['sys-id'], recordData);
176
+ for (const m of mismatches) {
177
+ process.stderr.write(`⚠ field "${m.field}" was not persisted (sent: "${m.sent}", got: "${m.got}")\n`);
178
+ }
179
+ if (mismatches.length > 0 && argv.strict) {
180
+ throw new Error(`Strict mode: ${mismatches.length} field(s) not persisted after update`);
181
+ }
165
182
  app.ok(record, { summary: `Updated record in ${argv.table}` });
166
183
  }),
167
184
  })
package/src/errors.js CHANGED
@@ -9,6 +9,7 @@ export const CodeNetwork = 'network_error';
9
9
  export const CodeAPI = 'api_error';
10
10
  export const CodeAmbiguous = 'ambiguous';
11
11
  export const CodeEmptyResult = 'empty_result';
12
+ export const CodeConfirmationRequired = 'confirmation_required';
12
13
 
13
14
  export class AppError extends Error {
14
15
  constructor(code, message, hint = '', status = 0, cause = null) {
@@ -117,6 +118,20 @@ export function errAmbiguous(resource, matches) {
117
118
  );
118
119
  }
119
120
 
121
+ /**
122
+ * Structured error for a destructive action that requires confirmation
123
+ * but no human is available to answer (non-TTY, pipes, or AI agents).
124
+ * The hint is written to be machine-parseable: an agent can read the
125
+ * re-run command and either present it to its user or execute it.
126
+ */
127
+ export function errConfirmationRequired(question, rerunCmd) {
128
+ return new AppError(
129
+ CodeConfirmationRequired,
130
+ `${question} — confirmation required. Pass --force to skip, or set skip_confirmations on the profile to disable prompts.`,
131
+ `This is a destructive action. Ask your user to confirm, then re-run:\n ${rerunCmd} --force\nOr set skip_confirmations on the profile to disable prompts:\n jsn auth login <instance> --skip-confirmations`
132
+ );
133
+ }
134
+
120
135
  export function asError(err) {
121
136
  if (!err) return null;
122
137
  if (err instanceof AppError) return err;
package/src/helpers.js CHANGED
@@ -5,6 +5,32 @@ import readline from 'node:readline';
5
5
  import { paginatedSearch } from './paginated-search.js';
6
6
  import { isTTY, FormatAuto } from './output.js';
7
7
  import { getActiveProfile } from './config.js';
8
+ import { errConfirmationRequired } from './errors.js';
9
+
10
+ /**
11
+ * Detect whether a human is actually available to answer interactive
12
+ * prompts. Agent tooling (VSCode Copilot, Claude Code, Cursor, etc.)
13
+ * allocates a PTY for subprocesses, so isTTY() returns true even though
14
+ * no one will ever type a response. Those environments set signals that
15
+ * let us fall back to non-interactive behavior instead of hanging:
16
+ *
17
+ * - JSN_NO_PROMPTS=1 (explicit opt-out, documented for agents)
18
+ * - CI / NONINTERACTIVE / GITHUB_ACTIONS
19
+ * - CLAUDE_CODE / COPILOT_* / CURSOR_* agent markers
20
+ * - TERM=dumb
21
+ *
22
+ * @returns {boolean} true when a human can answer a prompt
23
+ */
24
+ export function canPrompt() {
25
+ if (process.env.JSN_NO_PROMPTS === '1') return false;
26
+ if (process.env.CI === 'true' || process.env.CI === '1') return false;
27
+ if (process.env.NONINTERACTIVE === 'true' || process.env.NONINTERACTIVE === '1') return false;
28
+ if (process.env.GITHUB_ACTIONS === 'true') return false;
29
+ if (process.env.TERM === 'dumb') return false;
30
+ const agentMarkers = ['CLAUDE_CODE', 'COPILOT_AGENT', 'VSCODE_AGENT', 'CURSOR_AGENT', 'CODEX_AGENT', 'GITHUB_COPILOT_AGENT'];
31
+ if (agentMarkers.some((m) => process.env[m] === 'true' || process.env[m] === '1')) return false;
32
+ return isTTY(process.stdout) && isTTY(process.stdin);
33
+ }
8
34
 
9
35
  /**
10
36
  * Confirm a destructive action before executing.
@@ -25,7 +51,7 @@ export async function confirmDelete(app, argv, question) {
25
51
  const profile = getActiveProfile(app.config);
26
52
  if (profile?.skip_confirmations === true) return true;
27
53
  if (argv.force) return true;
28
- if (isTTY(process.stdout) && isTTY(process.stdin)) {
54
+ if (canPrompt()) {
29
55
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
30
56
  const answer = await new Promise((resolve) => {
31
57
  rl.question(`${question} (y/N): `, resolve);
@@ -37,9 +63,10 @@ export async function confirmDelete(app, argv, question) {
37
63
  }
38
64
  return true;
39
65
  }
40
- throw new Error(
41
- `${question} — confirmation required. Pass --force to skip, or set skip_confirmations on the profile to disable prompts.`
42
- );
66
+ // No human available (pipes, scripts, AI agents). Fail fast with a
67
+ // structured error an agent can act on — never hang waiting for stdin.
68
+ const cmd = ['jsn', ...(argv._ || [])].join(' ');
69
+ throw errConfirmationRequired(question, cmd);
43
70
  }
44
71
 
45
72
 
@@ -55,6 +82,84 @@ export function getStringField(record, field) {
55
82
  return String(val);
56
83
  }
57
84
 
85
+ /**
86
+ * Extract the raw value from a field that may be a string or a
87
+ * {display_value, value} object returned by the Table API.
88
+ * Prefers .value (the raw stored value) over .display_value.
89
+ */
90
+ function getRawValue(val) {
91
+ if (val == null) return '';
92
+ if (typeof val === 'object') {
93
+ if (val.value != null) return String(val.value);
94
+ if (val.display_value != null) return String(val.display_value);
95
+ return '';
96
+ }
97
+ return String(val);
98
+ }
99
+
100
+ /**
101
+ * Normalize a value the user supplied in --data for comparison against
102
+ * what the API persisted. Objects like {"value": "<sys_id>"} are unwrapped.
103
+ */
104
+ function normalizeSupplied(val) {
105
+ if (val == null) return '';
106
+ if (typeof val === 'object') {
107
+ if (val.value != null) return String(val.value);
108
+ return '';
109
+ }
110
+ return String(val);
111
+ }
112
+
113
+ /**
114
+ * Write-back verification for records create/update.
115
+ *
116
+ * After a mutation, re-GET the record and compare each field supplied in
117
+ * --data against what actually persisted. Surfaces silent server-side
118
+ * drops (inherited references, ACL-restricted fields, IO: prefixed strings,
119
+ * platform quirks) that ServiceNow ignores without erroring.
120
+ *
121
+ * Skips sys_* fields (system-managed, already warned by checkDerivedFields).
122
+ *
123
+ * @returns {Promise<Array<{field: string, sent: string, got: string}>>}
124
+ */
125
+ export async function verifyWriteBack(app, table, sysID, sentData) {
126
+ const mismatches = [];
127
+ if (!sysID || !sentData || typeof sentData !== 'object') return mismatches;
128
+
129
+ const fields = Object.keys(sentData).filter(f => f !== 'sys_id' && !f.startsWith('sys_'));
130
+ if (fields.length === 0) return mismatches;
131
+
132
+ let persisted;
133
+ try {
134
+ const params = new URLSearchParams();
135
+ params.set('sysparm_query', `sys_id=${sysID}`);
136
+ params.set('sysparm_limit', '1');
137
+ params.set('sysparm_display_value', 'all');
138
+ params.set('sysparm_fields', ['sys_id', ...fields].join(','));
139
+ const records = await app.sdk.list(table, params);
140
+ persisted = records[0] || null;
141
+ } catch {
142
+ // Verification GET failed — don't fail the mutation, just report it.
143
+ mismatches.push({ field: '(verify)', sent: '', got: 'write-back check failed (GET error)' });
144
+ return mismatches;
145
+ }
146
+
147
+ if (!persisted) {
148
+ mismatches.push({ field: '(verify)', sent: '', got: 'record not found after write' });
149
+ return mismatches;
150
+ }
151
+
152
+ for (const field of fields) {
153
+ const sent = normalizeSupplied(sentData[field]);
154
+ const got = getRawValue(persisted[field]);
155
+ if (sent !== got) {
156
+ mismatches.push({ field, sent, got });
157
+ }
158
+ }
159
+
160
+ return mismatches;
161
+ }
162
+
58
163
  export function formatRecordForDisplay(record, columns) {
59
164
  const result = {};
60
165
 
@@ -154,7 +259,7 @@ export function assertSafeExactMatch(value) {
154
259
  export async function interactiveList({ app, table, singular, columns, limit = 50, _query = '', formatLabel, labelField = 'name' }) {
155
260
  app.requireInstance();
156
261
  const effectiveFormat = app.output.getFormat() === FormatAuto ? (isTTY(process.stdout) ? FormatAuto : FormatAuto) : app.output.getFormat();
157
- if (effectiveFormat !== FormatAuto || !isTTY(process.stdout) || !isTTY(process.stdin)) {
262
+ if (effectiveFormat !== FormatAuto || !canPrompt()) {
158
263
  return null; // not interactive — caller should fall back to text/table
159
264
  }
160
265
 
package/src/output.js CHANGED
@@ -1,7 +1,6 @@
1
1
  // Output formatting: JSON, Markdown, Styled, Quiet
2
2
 
3
3
  import process from 'node:process';
4
- import chalk from 'chalk';
5
4
 
6
5
  export const FormatAuto = 'auto';
7
6
  export const FormatJSON = 'json';
@@ -28,6 +27,12 @@ export function isTTY(writer) {
28
27
  return writer.isTTY === true;
29
28
  }
30
29
 
30
+ // Minimal ANSI colors — replaces chalk (only used for error/hint styling,
31
+ // which is already TTY-gated by callers).
32
+ const ansi = (code) => (text) => `\x1b[${code}m${text}\x1b[0m`;
33
+ const colorRed = ansi('31');
34
+ const colorYellow = ansi('33');
35
+
31
36
  export class OutputWriter {
32
37
  constructor(opts = {}) {
33
38
  this.format = opts.format || FormatAuto;
@@ -82,8 +87,8 @@ export class OutputWriter {
82
87
  break;
83
88
  default:
84
89
  if (isTTY(this.writer)) {
85
- this.writer.write(chalk.red(`Error (${e.code}): ${e.message}\n`));
86
- if (e.hint) this.writer.write(chalk.yellow(`Hint: ${e.hint}\n`));
90
+ this.writer.write(colorRed(`Error (${e.code}): ${e.message}\n`));
91
+ if (e.hint) this.writer.write(colorYellow(`Hint: ${e.hint}\n`));
87
92
  } else {
88
93
  this.writer.write(JSON.stringify(resp, null, 2) + '\n');
89
94
  }