@jacebenson/jsn 3.0.5 → 3.0.6

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": "3.0.5",
3
+ "version": "3.0.6",
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": {
package/src/cli.js CHANGED
@@ -142,9 +142,12 @@ export const cli = yargs(hideBin(process.argv))
142
142
  }
143
143
 
144
144
  // Auto-check skill on every command (fire-and-forget, non-blocking)
145
+ // Skipped under --json, --quiet, --no-skill-check, or env var
145
146
  const skipSkillCheck = ['help', 'version', 'completion', 'skill'].includes(cmd)
146
147
  || process.env.JSN_NO_SKILL_CHECK === '1'
147
- || argv['no-skill-check'];
148
+ || argv['no-skill-check']
149
+ || argv.json
150
+ || argv.quiet;
148
151
  if (!skipSkillCheck) {
149
152
  // Fire check but don't await — it runs in background
150
153
  checkSkill().then(result => {
@@ -81,7 +81,6 @@ export const spPagesCmd = (wrap) => buildDevCmd('sppages', 'sp_page', ['sp-pages
81
81
  export const spWidgetsCmd = (wrap) => buildDevCmd('spwidgets', 'sp_widget', ['sp-widget', 'widgets'], ['id', 'name', 'sys_scope'], wrap, { singular: 'Service Portal widget', readOnly: true });
82
82
  export const uiPagesCmd = (wrap) => buildDevCmd('uipages', 'sys_ui_page', ['ui-page', 'pages'], ['name', 'sys_scope'], wrap, { singular: 'UI page', scopeValidation: true });
83
83
  export const appMenuCmd = (wrap) => buildDevCmd('appmenu', 'sys_app_application', ['app-menu', 'menu'], ['name', 'active', 'sys_scope'], wrap, { singular: 'application menu', scopeValidation: true });
84
- export const scRAPICmd = (wrap) => buildDevCmd('scrapi', 'sys_ws_operation', ['scripted-rest', 'rest-api'], ['name', 'sys_ws_definition', 'sys_scope'], wrap, { singular: 'scripted REST API', readOnly: true });
85
84
 
86
85
  // Commands with full CRUD
87
86
  export const aclsCmd = (wrap) => buildDevCmd('acls', 'sys_security_acl', ['acl'], ['name', 'operation', 'type', 'active', 'sys_scope'], wrap, { singular: 'ACL', scopeValidation: true });
@@ -0,0 +1,344 @@
1
+ // Custom scraPI command: list/show individual operations + one-shot API definition creation
2
+ //
3
+ // The 'create' subcommand lets you build a full Scripted REST API in one call:
4
+ // jsn dev scrapi create \
5
+ // --name "SL Managed Services" --service-id managed_services --namespace slapi \
6
+ // --resource create=POST:/create:./create.js \
7
+ // --resource comment=POST:/comment:./comment.js
8
+
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { formatRecordForDisplay, getStringField, isHexString } from '../../helpers.js';
12
+ import { errUsageHint } from '../../errors.js';
13
+
14
+ /**
15
+ * Parse a --resource flag value of the form:
16
+ * name=METHOD:/path:./script.js
17
+ * Returns { name, method, relativePath, scriptFile } or null.
18
+ */
19
+ function parseResourceArg(raw) {
20
+ if (!raw || typeof raw !== 'string') return null;
21
+
22
+ // Split on first '=' to get name and the rest
23
+ const eqIdx = raw.indexOf('=');
24
+ if (eqIdx === -1) return null;
25
+ const name = raw.slice(0, eqIdx).trim();
26
+ const rest = raw.slice(eqIdx + 1).trim();
27
+
28
+ // Rest should be METHOD:/path:./script.js
29
+ // Split on first ':' to separate METHOD from the path:file part
30
+ const colonIdx = rest.indexOf(':');
31
+ if (colonIdx === -1) return null;
32
+ const method = rest.slice(0, colonIdx).trim().toUpperCase();
33
+ const pathFile = rest.slice(colonIdx + 1).trim();
34
+
35
+ // Split pathAndFile on last ':' to separate relative_path from script file
36
+ const lastColon = pathFile.lastIndexOf(':');
37
+ if (lastColon === -1) return null;
38
+ const relativePath = pathFile.slice(0, lastColon).trim();
39
+ const scriptFile = pathFile.slice(lastColon + 1).trim();
40
+
41
+ if (!relativePath.startsWith('/')) {
42
+ return null; // relative_path must start with /
43
+ }
44
+
45
+ return { name, method, relativePath, scriptFile };
46
+ }
47
+
48
+ /**
49
+ * Read script content from a file path (relative to cwd or absolute).
50
+ */
51
+ function readScriptFile(filePath) {
52
+ const resolved = path.resolve(filePath);
53
+ if (!fs.existsSync(resolved)) {
54
+ throw errUsageHint(
55
+ `Script file not found: ${filePath}`,
56
+ `Checked: ${resolved}`
57
+ );
58
+ }
59
+ return fs.readFileSync(resolved, 'utf-8');
60
+ }
61
+
62
+ export function scrapiCmd(wrap) {
63
+ return {
64
+ command: 'scrapi [subcommand]',
65
+ aliases: ['scripted-rest', 'rest-api'],
66
+ describe: 'Manage Scripted REST APIs (list/show operations, create full APIs with resources)',
67
+ builder: (yargs) => {
68
+ return yargs
69
+ // ── list ──
70
+ .command({
71
+ command: 'list',
72
+ aliases: ['ls'],
73
+ describe: 'List API operations',
74
+ builder: (y) => y
75
+ .option('query', { type: 'string', describe: 'Encoded query (e.g. "nameLIKEincident")' })
76
+ .option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns' })
77
+ .option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Max records' }),
78
+ handler: wrap(async (argv, app) => {
79
+ const columns = argv.columns ? argv.columns.split(',') : ['name', 'sys_ws_definition', 'http_method', 'relative_path', 'sys_scope'];
80
+ const query = argv.query || '';
81
+ const limit = argv.limit;
82
+
83
+ const params = new URLSearchParams();
84
+ params.set('sysparm_limit', String(limit));
85
+ params.set('sysparm_display_value', 'all');
86
+ params.set('sysparm_fields', ['sys_id', ...columns].join(','));
87
+ const q = query ? query + '^ORDERBYDESCsys_updated_on' : 'ORDERBYDESCsys_updated_on';
88
+ params.set('sysparm_query', q);
89
+ const records = await app.sdk.list('sys_ws_operation', params);
90
+
91
+ app.ok({
92
+ table: 'sys_ws_operation',
93
+ count: records.length,
94
+ columns,
95
+ records: records.map(r => formatRecordForDisplay(r, columns)),
96
+ context: { instance_url: app.getEffectiveInstance() },
97
+ }, {
98
+ summary: `${records.length} operation(s)`,
99
+ breadcrumbs: [
100
+ { action: 'create', cmd: 'jsn dev scrapi create --name "My API" --service-id my-api --namespace myapp --resource get=GET:/items:./script.js', description: 'Create a new REST API' },
101
+ { action: 'show', cmd: 'jsn dev scrapi show <name>', description: 'Show operation details' },
102
+ ],
103
+ });
104
+ }),
105
+ })
106
+ // ── show ──
107
+ .command({
108
+ command: 'show <identifier>',
109
+ aliases: ['get'],
110
+ describe: 'Show an API definition or operation by service_id, name, or sys_id — includes child resources',
111
+ handler: wrap(async (argv, app) => {
112
+ const id = argv.identifier;
113
+ const isSysId = isHexString(id) && id.length === 32;
114
+
115
+ // 1. Try to find a sys_ws_operation by name or sys_id
116
+ const opParams = new URLSearchParams();
117
+ opParams.set('sysparm_query', `${isSysId ? 'sys_id' : 'name'}=${id}`);
118
+ opParams.set('sysparm_limit', '1');
119
+ opParams.set('sysparm_display_value', 'all');
120
+ let records = await app.sdk.list('sys_ws_operation', opParams);
121
+
122
+ if (records.length > 0) {
123
+ // Found an operation — show it directly
124
+ records[0]._context = {
125
+ instance_url: app.getEffectiveInstance(),
126
+ table: 'sys_ws_operation',
127
+ };
128
+ return app.ok(records[0], {
129
+ summary: `Operation: ${getStringField(records[0], 'name') || id}`,
130
+ breadcrumbs: [
131
+ { action: 'list', cmd: 'jsn dev scrapi list', description: 'Back to all operations' },
132
+ ],
133
+ });
134
+ }
135
+
136
+ // 2. Try to find a sys_ws_definition by service_id, name, or sys_id
137
+ const defQuery = isSysId
138
+ ? `sys_id=${id}`
139
+ : `service_id=${id}^ORname=${id}`;
140
+ const defParams = new URLSearchParams();
141
+ defParams.set('sysparm_query', defQuery);
142
+ defParams.set('sysparm_limit', '1');
143
+ defParams.set('sysparm_display_value', 'all');
144
+ const defRecords = await app.sdk.list('sys_ws_definition', defParams);
145
+
146
+ if (defRecords.length === 0) {
147
+ throw new Error(`No API or operation found matching: ${id}`);
148
+ }
149
+
150
+ const definition = defRecords[0];
151
+ const defName = getStringField(definition, 'name');
152
+ const defSysId = definition.sys_id?.value || definition.sys_id;
153
+
154
+ // 3. Fetch child operations for this definition
155
+ const childParams = new URLSearchParams();
156
+ childParams.set('sysparm_query', `web_service_definition=${defSysId}`);
157
+ childParams.set('sysparm_limit', '50');
158
+ childParams.set('sysparm_display_value', 'all');
159
+ childParams.set('sysparm_fields', 'sys_id,name,http_method,relative_path,active');
160
+ const childOps = await app.sdk.list('sys_ws_operation', childParams);
161
+
162
+ // Build a formatted display
163
+ const instanceURL = app.getEffectiveInstance();
164
+ const defLink = `${instanceURL}/sys_ws_definition.do?sys_id=${defSysId}`;
165
+
166
+ let formatted = `\n${defName} (Scripted REST API)\n`;
167
+ formatted += `${'─'.repeat(defName.length + 22)}\n\n`;
168
+ formatted += ` service_id: ${getStringField(definition, 'service_id')}\n`;
169
+ formatted += ` namespace: ${getStringField(definition, 'namespace')}\n`;
170
+ formatted += ` scope: ${getStringField(definition, 'sys_scope')}\n`;
171
+ formatted += ` base_uri: ${getStringField(definition, 'base_uri')}\n`;
172
+ formatted += ` description: ${(getStringField(definition, 'description') || '(none)').slice(0, 60)}\n`;
173
+ formatted += `\n Link: ${defLink}\n`;
174
+
175
+ if (childOps.length > 0) {
176
+ formatted += `\n ── Resources (${childOps.length}) ──\n\n`;
177
+ for (const op of childOps) {
178
+ const opName = getStringField(op, 'name');
179
+ const method = getStringField(op, 'http_method');
180
+ const relPath = getStringField(op, 'relative_path');
181
+ const active = getStringField(op, 'active');
182
+ formatted += ` ${(method + ' ').slice(0, 7)} ${relPath.padEnd(30)} ${opName}${active !== 'true' ? ' (inactive)' : ''}\n`;
183
+ }
184
+ } else {
185
+ formatted += `\n (no resources — run jsn dev scrapi create --resource ... to add some)\n`;
186
+ }
187
+
188
+ app.ok({
189
+ _formatted: formatted,
190
+ definition: {
191
+ sys_id: defSysId,
192
+ name: defName,
193
+ service_id: getStringField(definition, 'service_id'),
194
+ namespace: getStringField(definition, 'namespace'),
195
+ base_uri: getStringField(definition, 'base_uri'),
196
+ scope: getStringField(definition, 'sys_scope'),
197
+ description: getStringField(definition, 'description'),
198
+ link: defLink,
199
+ },
200
+ resources: childOps.map(op => ({
201
+ name: getStringField(op, 'name'),
202
+ method: getStringField(op, 'http_method'),
203
+ relative_path: getStringField(op, 'relative_path'),
204
+ active: getStringField(op, 'active'),
205
+ })),
206
+ _context: { instance_url: instanceURL, table: 'sys_ws_definition' },
207
+ }, {
208
+ summary: `API: ${defName} — ${childOps.length} resource(s)`,
209
+ breadcrumbs: [
210
+ { action: 'create', cmd: 'jsn dev scrapi create --name "..." --service-id ... --namespace ... --resource ...', description: 'Create a new API' },
211
+ { action: 'list', cmd: 'jsn dev scrapi list', description: 'Back to all operations' },
212
+ ],
213
+ });
214
+ }),
215
+ })
216
+ // ── create (one-shot API + operations) ──
217
+ .command({
218
+ command: 'create',
219
+ describe: 'Create a new Scripted REST API with operations in one command',
220
+ builder: (y) => y
221
+ .option('name', { type: 'string', demandOption: true, describe: 'API name (e.g. "SL Managed Services")' })
222
+ .option('service-id', { type: 'string', demandOption: true, describe: 'Service ID (e.g. "managed_services")' })
223
+ .option('namespace', { type: 'string', demandOption: true, describe: 'API namespace (e.g. "slapi")' })
224
+ .option('resource', {
225
+ type: 'string',
226
+ array: true,
227
+ demandOption: true,
228
+ describe: 'Resource definition: name=METHOD:/path:./script.js (can be repeated)',
229
+ })
230
+ .option('description', { type: 'string', describe: 'API description' })
231
+ .option('scope', { type: 'string', describe: 'Scope name or sys_id (default: current scope)' }),
232
+ handler: wrap(async (argv, app) => {
233
+ if (!app.sdk) throw new Error('Not connected to a ServiceNow instance');
234
+
235
+ // 1. Parse and validate all resources first
236
+ const resources = [];
237
+ for (const raw of argv.resource) {
238
+ const parsed = parseResourceArg(raw);
239
+ if (!parsed) {
240
+ throw errUsageHint(
241
+ `Invalid --resource format: ${raw}`,
242
+ 'Expected: name=METHOD:/path:./script.js e.g. create=POST:/create:./create.js'
243
+ );
244
+ }
245
+ const script = readScriptFile(parsed.scriptFile);
246
+ resources.push({ ...parsed, script });
247
+ }
248
+
249
+ const apiName = argv.name;
250
+ const serviceId = argv['service-id'];
251
+ const namespace = argv.namespace;
252
+ const description = argv.description || '';
253
+
254
+ // 2. Resolve scope if provided
255
+ let scopeSysId = null;
256
+ if (argv.scope) {
257
+ scopeSysId = await app.sdk.resolveScope(argv.scope);
258
+ if (!scopeSysId) {
259
+ throw new Error(`Scope not found: ${argv.scope}`);
260
+ }
261
+ }
262
+
263
+ // 3. Create the API definition (sys_ws_definition)
264
+ const definitionData = {
265
+ name: apiName,
266
+ service_id: serviceId,
267
+ namespace,
268
+ description,
269
+ };
270
+ if (scopeSysId) {
271
+ definitionData.sys_scope = scopeSysId;
272
+ }
273
+
274
+ const definition = await app.sdk.create('sys_ws_definition', definitionData);
275
+ const defSysId = definition?.sys_id?.value || definition?.sys_id;
276
+ if (!defSysId) {
277
+ throw new Error('API definition creation returned no sys_id — check for validation errors');
278
+ }
279
+
280
+ // 4. Create each operation (sys_ws_operation)
281
+ const operations = [];
282
+ for (const res of resources) {
283
+ const opData = {
284
+ name: res.name,
285
+ http_method: res.method,
286
+ relative_path: res.relativePath,
287
+ operation_script: res.script,
288
+ web_service_definition: defSysId,
289
+ };
290
+ // Set the scope so the operation inherits the API's scope
291
+ if (scopeSysId) {
292
+ opData.sys_scope = scopeSysId;
293
+ }
294
+ const operation = await app.sdk.create('sys_ws_operation', opData);
295
+ operations.push(operation?.result || operation);
296
+ }
297
+
298
+ // 5. Report
299
+ const instanceURL = app.getEffectiveInstance();
300
+ const apiURL = `${instanceURL}/sys_ws_definition.do?sys_id=${defSysId}`;
301
+
302
+ app.ok({
303
+ api_definition: {
304
+ sys_id: defSysId,
305
+ name: apiName,
306
+ service_id: serviceId,
307
+ namespace,
308
+ link: apiURL,
309
+ },
310
+ operations: operations.map((op, i) => ({
311
+ name: resources[i].name,
312
+ method: resources[i].method,
313
+ relative_path: resources[i].relativePath,
314
+ })),
315
+ }, {
316
+ summary: `Created API "${apiName}" with ${operations.length} operation(s)`,
317
+ notice: `View API: ${apiURL}`,
318
+ breadcrumbs: [
319
+ { action: 'show', cmd: `jsn dev scrapi list --query "web_service_definition=${defSysId}"`, description: 'List operations for this API' },
320
+ { action: 'add', cmd: `jsn dev scrapi add-resource --api "${serviceId}" --name <name>=METHOD:/path:./script.js`, description: 'Add another resource' },
321
+ ],
322
+ });
323
+ }),
324
+ });
325
+ },
326
+ // Default handler when no subcommand matches
327
+ handler: () => {
328
+ console.log('Manage Scripted REST APIs.');
329
+ console.log('');
330
+ console.log('Available subcommands:');
331
+ console.log(' list List API operations');
332
+ console.log(' show Show an operation by name or sys_id');
333
+ console.log(' create Create a new API with operations (one-shot)');
334
+ console.log('');
335
+ console.log('Examples:');
336
+ console.log(' jsn dev scrapi list');
337
+ console.log(' jsn dev scrapi show my_operation');
338
+ console.log(' jsn dev scrapi create \\');
339
+ console.log(' --name "My API" --service-id my-api --namespace myapp \\');
340
+ console.log(' --resource "create=POST:/create:./create.js" \\');
341
+ console.log(' --resource "list=GET:/list:./list.js"');
342
+ },
343
+ };
344
+ }
@@ -4,7 +4,7 @@ import {
4
4
  actionsCmd, includesCmd, rulesCmd,
5
5
  clientScriptsCmd, uiActionsCmd, uiPoliciesCmd,
6
6
  tablesCmd, columnsCmd, importCmd,
7
- spPagesCmd, spWidgetsCmd, uiPagesCmd, appMenuCmd, scRAPICmd,
7
+ spPagesCmd, spWidgetsCmd, uiPagesCmd, appMenuCmd,
8
8
  aclsCmd, rolesCmd, propertiesCmd,
9
9
  relationshipsCmd, appmodulesCmd, listcontrolsCmd, viewsCmd,
10
10
  privilegesCmd, securitytypesCmd, uxscriptsCmd, aliasesCmd,
@@ -18,6 +18,7 @@ import { scopesCmd } from './dev/scopes.js';
18
18
  import { evalCmd } from './dev/eval.js';
19
19
  import { restCmd } from './dev/rest.js';
20
20
  import { logsCmd } from './dev/logs.js';
21
+ import { scrapiCmd } from './dev/scrapi.js';
21
22
 
22
23
  export function devCmd(wrap) {
23
24
  return {
@@ -41,7 +42,7 @@ export function devCmd(wrap) {
41
42
  .command(spWidgetsCmd(wrap))
42
43
  .command(uiPagesCmd(wrap))
43
44
  .command(appMenuCmd(wrap))
44
- .command(scRAPICmd(wrap))
45
+ .command(scrapiCmd(wrap))
45
46
  .command(aclsCmd(wrap))
46
47
  .command(rolesCmd(wrap))
47
48
  .command(updateSetsCmd(wrap))
@@ -1,4 +1,4 @@
1
- import { formatRecordForDisplay, buildQuerySuffix, parseDataArg, getStringField, interactiveList, resolveFieldsParam } from '../helpers.js';
1
+ import { formatRecordForDisplay, buildQuerySuffix, parseDataArg, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields } from '../helpers.js';
2
2
 
3
3
  const tableDefaultColumns = {
4
4
  incident: ['number', 'short_description', 'priority', 'state', 'assigned_to'],
@@ -133,9 +133,14 @@ export function recordsCmd(wrap) {
133
133
  builder: (y) => y
134
134
  .option('table', { type: 'string', demandOption: true, describe: 'Table name' })
135
135
  .option('data', { type: 'string', describe: 'JSON fields (e.g. \'{"state":"2"}\')' })
136
- .option('data-file', { type: 'string', describe: 'Read JSON payload from file' }),
136
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
137
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin (pipe-friendly)' }),
137
138
  handler: wrap(async (argv, app) => {
138
139
  const recordData = parseDataArg(argv);
140
+ const warnings = checkDerivedFields(argv.table, recordData);
141
+ for (const w of warnings) {
142
+ process.stderr.write(`⚠ ${w.hint}\n`);
143
+ }
139
144
  const record = await app.sdk.create(argv.table, recordData);
140
145
  app.ok(record, { summary: `Created record in ${argv.table}` });
141
146
  }),
@@ -147,9 +152,14 @@ export function recordsCmd(wrap) {
147
152
  .option('table', { type: 'string', demandOption: true, describe: 'Table name' })
148
153
  .option('sys-id', { type: 'string', demandOption: true, describe: 'Record sys_id' })
149
154
  .option('data', { type: 'string', describe: 'JSON fields (e.g. \'{"state":"2"}\')' })
150
- .option('data-file', { type: 'string', describe: 'Read JSON payload from file' }),
155
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
156
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin (pipe-friendly)' }),
151
157
  handler: wrap(async (argv, app) => {
152
158
  const recordData = parseDataArg(argv);
159
+ const warnings = checkDerivedFields(argv.table, recordData);
160
+ for (const w of warnings) {
161
+ process.stderr.write(`⚠ ${w.hint}\n`);
162
+ }
153
163
  const record = await app.sdk.update(argv.table, argv['sys-id'], recordData);
154
164
  app.ok(record, { summary: `Updated record in ${argv.table}` });
155
165
  }),
@@ -1,17 +1,37 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
+ import process from 'node:process';
4
5
  import { fileURLToPath } from 'node:url';
5
6
 
7
+ const SKILL_NAME = 'servicenow';
6
8
  const SKILL_REPO_PATH = path.join(
7
9
  path.dirname(fileURLToPath(import.meta.url)),
8
- '..', '..', 'skills', 'servicenow', 'SKILL.md'
10
+ '..', '..', 'skills', SKILL_NAME, 'SKILL.md'
9
11
  );
10
12
 
11
- const HERMES_SKILL_PATH = path.join(os.homedir(), '.hermes', 'skills', 'servicenow', 'SKILL.md');
13
+ const HERMES_SKILL_PATH = path.join(os.homedir(), '.hermes', 'skills', SKILL_NAME, 'SKILL.md');
12
14
 
13
15
  const SKILL_RAW_URL = 'https://raw.githubusercontent.com/jacebenson/jsn/nodejs/skills/servicenow/SKILL.md';
14
16
 
17
+ // Known agent skill directories (personal/user-level)
18
+ // Keyed by agent name for the --target flag
19
+ const AGENT_SKILL_DIRS = {
20
+ hermes: path.join(os.homedir(), '.hermes', 'skills', SKILL_NAME),
21
+ copilot: path.join(os.homedir(), '.copilot', 'skills', SKILL_NAME),
22
+ claude: path.join(os.homedir(), '.claude', 'skills', SKILL_NAME),
23
+ cursor: path.join(os.homedir(), '.cursor', 'rules'),
24
+ agents: path.join(os.homedir(), '.agents', 'skills', SKILL_NAME),
25
+ };
26
+
27
+ const TARGET_NAMES = {
28
+ hermes: 'Hermes Agent',
29
+ copilot: 'GitHub Copilot',
30
+ claude: 'Claude Code',
31
+ cursor: 'Cursor',
32
+ agents: 'Agents (open standard)',
33
+ };
34
+
15
35
  function readBundledSkill() {
16
36
  try {
17
37
  return fs.readFileSync(SKILL_REPO_PATH, 'utf-8');
@@ -115,18 +135,23 @@ export function skillCmd(wrap) {
115
135
  command: 'path',
116
136
  describe: 'Show skill file locations and install targets',
117
137
  handler: wrap(async (_argv, app) => {
118
- const hermesDir = path.join(os.homedir(), '.hermes', 'skills', 'servicenow');
119
- const hermesPath = path.join(hermesDir, 'SKILL.md');
138
+ const allPaths = {};
139
+ for (const [key, dir] of Object.entries(AGENT_SKILL_DIRS)) {
140
+ const file = key === 'cursor' ? path.join(dir, `${SKILL_NAME}.mdc`) : path.join(dir, 'SKILL.md');
141
+ allPaths[TARGET_NAMES[key]] = file;
142
+ }
143
+
120
144
  app.ok({
121
145
  bundled: SKILL_REPO_PATH,
122
- hermes: hermesPath,
146
+ targets: allPaths,
123
147
  raw_url: SKILL_RAW_URL,
124
148
  }, {
125
- summary: 'Skill file locations',
149
+ summary: 'Skill file locations and install targets',
126
150
  breadcrumbs: [
127
- { action: 'view', cmd: 'jsn skill show', description: 'Show bundled skill content' },
128
- { action: 'fetch', cmd: 'jsn skill fetch', description: 'Download latest from GitHub' },
129
- { action: 'install', cmd: 'jsn skill install', description: 'Download + save to Hermes' },
151
+ { action: 'install', cmd: 'jsn skill install', description: 'Install to Hermes (default)' },
152
+ { action: 'install-all', cmd: 'jsn skill install --target all', description: 'Install to all supported agents' },
153
+ { action: 'install-copilot', cmd: 'jsn skill install --target copilot', description: 'Install for GitHub Copilot' },
154
+ { action: 'install-claude', cmd: 'jsn skill install --target claude', description: 'Install for Claude Code' },
130
155
  ],
131
156
  });
132
157
  }),
@@ -137,30 +162,71 @@ export function skillCmd(wrap) {
137
162
  builder: (y) => y
138
163
  .positional('dir', {
139
164
  type: 'string',
140
- describe: 'Target directory (default: ~/.hermes/skills/servicenow/)',
165
+ describe: 'Target directory (overrides --target; installs only to this dir)',
166
+ })
167
+ .option('target', {
168
+ type: 'string',
169
+ describe: 'Target agent(s): hermes, copilot, claude, cursor, agents, or "all" (comma-separated)',
170
+ default: 'hermes',
141
171
  }),
142
172
  handler: wrap(async (argv, app) => {
143
- const targetDir = argv.dir || path.join(os.homedir(), '.hermes', 'skills', 'servicenow');
144
- const targetPath = path.join(targetDir, 'SKILL.md');
145
-
146
- fs.mkdirSync(targetDir, { recursive: true });
147
-
148
173
  const res = await fetch(SKILL_RAW_URL);
149
174
  if (!res.ok) throw new Error(`Failed to fetch skill: ${res.status} ${res.statusText}`);
150
175
  const content = await res.text();
151
176
 
152
- fs.writeFileSync(targetPath, content, 'utf-8');
177
+ // Resolve target directories
178
+ let targets = [];
153
179
 
154
- app.ok({
155
- installed: targetPath,
156
- from: SKILL_RAW_URL,
157
- }, {
158
- summary: `Skill installed to ${targetPath}`,
180
+ if (argv.dir) {
181
+ // Explicit --dir overrides targets — single install
182
+ const p = path.resolve(argv.dir);
183
+ fs.mkdirSync(p, { recursive: true });
184
+ const targetPath = path.join(p, 'SKILL.md');
185
+ fs.writeFileSync(targetPath, content, 'utf-8');
186
+ targets.push({ name: path.basename(argv.dir), path: targetPath });
187
+ } else {
188
+ const rawTargets = argv.target.split(',').map(t => t.trim().toLowerCase());
189
+ const all = rawTargets.includes('all');
190
+
191
+ for (const [key, dir] of Object.entries(AGENT_SKILL_DIRS)) {
192
+ if (all || rawTargets.includes(key)) {
193
+ // For cursor, the skill goes in a .mdc file, not a subfolder
194
+ if (key === 'cursor') {
195
+ fs.mkdirSync(dir, { recursive: true });
196
+ const targetPath = path.join(dir, `${SKILL_NAME}.mdc`);
197
+ fs.writeFileSync(targetPath, content, 'utf-8');
198
+ targets.push({ name: TARGET_NAMES[key], path: targetPath });
199
+ } else {
200
+ fs.mkdirSync(dir, { recursive: true });
201
+ const targetPath = path.join(dir, 'SKILL.md');
202
+ fs.writeFileSync(targetPath, content, 'utf-8');
203
+ targets.push({ name: TARGET_NAMES[key], path: targetPath });
204
+ }
205
+ }
206
+ }
207
+ }
208
+
209
+ if (targets.length === 0) {
210
+ throw new Error(`No targets matched. Valid targets: ${Object.keys(AGENT_SKILL_DIRS).join(', ')}, or "all"`);
211
+ }
212
+
213
+ const installed = targets.reduce((acc, t) => { acc[t.name] = t.path; return acc; }, {});
214
+ const summary = targets.length === 1
215
+ ? `Skill installed to ${targets[0].path}`
216
+ : `Skill installed to ${targets.length} target(s)`;
217
+
218
+ const okOpts = {
219
+ summary,
159
220
  breadcrumbs: [
160
- { action: 'verify', cmd: `head -30 ${targetPath}`, description: 'Verify the installed skill' },
161
- { action: 'reinstall', cmd: 'jsn skill install', description: 'Re-download and reinstall' },
221
+ { action: 'reinstall', cmd: 'jsn skill install', description: 'Re-download and reinstall to default target' },
162
222
  ],
163
- });
223
+ };
224
+
225
+ if (targets.length > 1) {
226
+ okOpts.notice = 'Installed to multiple agents. Restart or reload your agent to pick up the skill.';
227
+ }
228
+
229
+ app.ok({ installed, from: SKILL_RAW_URL }, okOpts);
164
230
  }),
165
231
  });
166
232
  },
package/src/errors.js CHANGED
@@ -79,7 +79,34 @@ export function errAPI(status, msg) {
79
79
  if (status >= 500) {
80
80
  hint = 'The ServiceNow instance may be experiencing issues. Try again later.';
81
81
  }
82
- return new AppError(CodeAPI, `API error (status ${status}): ${msg}`, hint, status);
82
+
83
+ // Try to extract structured error detail from JSON API error bodies
84
+ let displayMsg = msg;
85
+ if (typeof msg === 'string' && (msg.startsWith('{') || msg.startsWith('['))) {
86
+ try {
87
+ const parsed = JSON.parse(msg);
88
+ const errObj = parsed.error || parsed;
89
+ const detail = errObj.detail || errObj.message || '';
90
+ const message = errObj.message || '';
91
+
92
+ if (detail && message) {
93
+ displayMsg = `${message}: ${detail}`;
94
+ } else if (detail) {
95
+ displayMsg = detail;
96
+ } else if (message) {
97
+ displayMsg = message;
98
+ }
99
+
100
+ // Detect business rule abortion and add a hint
101
+ if (detail && detail.includes('Business Rule')) {
102
+ hint = 'A Business Rule rejected this operation. Check for overlapping routes, validation rules, or ACLs on this table.';
103
+ }
104
+ } catch {
105
+ // Not valid JSON, use original message
106
+ }
107
+ }
108
+
109
+ return new AppError(CodeAPI, `API error (status ${status}): ${displayMsg}`, hint, status);
83
110
  }
84
111
 
85
112
  export function errAmbiguous(resource, matches) {
package/src/helpers.js CHANGED
@@ -135,25 +135,80 @@ function vowelArticle(word) {
135
135
  }
136
136
 
137
137
  /**
138
- * Parse --data or --data-file into a JSON object.
139
- * If --data-file is given, reads the file. Otherwise parses --data directly.
140
- * Throws if neither is provided or JSON is invalid.
138
+ * Known derived (read-only) ServiceNow fields that should not be set directly.
139
+ * Maps table name → array of field names that are computed by the platform.
140
+ * When a create/update payload contains these, a warning is emitted.
141
+ */
142
+ export const DERIVED_FIELDS = {
143
+ sys_ws_operation: ['operation_uri'],
144
+ sys_ws_definition: ['base_uri'],
145
+ incident: ['sys_created_on', 'sys_updated_on', 'sys_created_by', 'sys_updated_by', 'sys_mod_count'],
146
+ change_request: ['sys_created_on', 'sys_updated_on', 'sys_created_by', 'sys_updated_by', 'sys_mod_count'],
147
+ // Generic — all sys_ fields are system-managed
148
+ };
149
+
150
+ /**
151
+ * Check a data payload for fields that appear to be derived/read-only.
152
+ * Returns an array of warning objects ({field, hint}) for any matches.
153
+ * @param {string} table - Table name (e.g. 'sys_ws_operation')
154
+ * @param {object} data - The JSON payload being sent
155
+ * @returns {Array<{field: string, hint: string}>}
156
+ */
157
+ export function checkDerivedFields(table, data) {
158
+ if (!data || typeof data !== 'object') return [];
159
+ const warnings = [];
160
+
161
+ // Check explicitly known derived fields for this table
162
+ const knownFields = DERIVED_FIELDS[table] || [];
163
+ for (const field of knownFields) {
164
+ if (field in data && data[field] != null) {
165
+ warnings.push({
166
+ field,
167
+ hint: `${field} is a derived/read-only field. Setting it directly will be ignored.`,
168
+ });
169
+ }
170
+ }
171
+
172
+ // Warn about any sys_* fields that look like system-managed metadata
173
+ // (but be careful — not all sys_ fields are read-only)
174
+ if (data.sys_created_on) {
175
+ warnings.push({
176
+ field: 'sys_created_on',
177
+ hint: 'sys_created_on is a system-managed field. Setting it directly will be ignored.',
178
+ });
179
+ }
180
+ if (data.sys_updated_on) {
181
+ warnings.push({
182
+ field: 'sys_updated_on',
183
+ hint: 'sys_updated_on is a system-managed field. Setting it directly will be ignored.',
184
+ });
185
+ }
186
+
187
+ return warnings;
188
+ }
189
+
190
+ /**
191
+ * Parse --data, --data-file, or --data-stdin into a JSON object.
192
+ * Priority: --data-file > --data-stdin > --data
193
+ * Throws if none is provided or JSON is invalid.
141
194
  */
142
195
  export function parseDataArg(argv) {
143
196
  let raw;
144
197
  if (argv['data-file']) {
145
198
  raw = fs.readFileSync(argv['data-file'], 'utf-8');
146
- // Strip UTF-8 BOM (\ufeff) which some editors (Windows/PowerShell) add
199
+ // Strip UTF-8 BOM (\\ufeff) which some editors (Windows/PowerShell) add
147
200
  if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1);
201
+ } else if (argv['data-stdin']) {
202
+ raw = fs.readFileSync(process.stdin.fd, 'utf-8');
148
203
  } else if (argv.data) {
149
204
  raw = argv.data;
150
205
  } else {
151
- throw new Error('--data or --data-file is required');
206
+ throw new Error('--data, --data-file, or --data-stdin is required');
152
207
  }
153
208
  try {
154
209
  return JSON.parse(raw);
155
210
  } catch (e) {
156
- throw new Error(`Invalid JSON: ${e.message}\n\nHint: On Windows PowerShell, use --data-file instead of --data to avoid quote mangling.\nRaw value: ${raw.substring(0, 200)}`, { cause: e });
211
+ throw new Error(`Invalid JSON: ${e.message}\n\nHint: Use --data-file for multiline payloads to avoid shell escaping issues.\nRaw value: ${raw.substring(0, 200)}`, { cause: e });
157
212
  }
158
213
  }
159
214