@jacebenson/jsn 3.0.3 → 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.3",
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": {
@@ -43,4 +43,4 @@
43
43
  "README.md",
44
44
  "LICENSE"
45
45
  ]
46
- }
46
+ }
@@ -10,12 +10,12 @@ description: |
10
10
  license: MIT
11
11
  compatibility: |
12
12
  Requires jsn CLI (github.com/jacebenson/jsn). Install via:
13
- curl -fsSL https://jsn.jace.pro/install | bash
13
+ npm install -g @jacebenson/jsn
14
14
  Works with Claude Code, OpenCode, Cursor, and agentskills-compatible tools.
15
15
 
16
- Windows users: Download from GitHub releases. If jsn is not in your PATH, the setup
17
- command will automatically detect its location and show the correct command path
18
- (e.g., `.\jsn.exe tables list` instead of `jsn tables list`).
16
+ Windows users: Install Node.js first, then the npm command above. If jsn is not
17
+ in your PATH, the setup command will automatically detect its location and show
18
+ the correct command path.
19
19
  metadata:
20
20
  author: jacebenson
21
21
  version: "1.0.0"
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 });
@@ -7,10 +7,19 @@ export function evalCmd(wrap) {
7
7
  builder: (yargs) => {
8
8
  return yargs
9
9
  .option('script', { alias: 's', type: 'string', describe: "JavaScript code to execute (single-quote the script, double quotes inside: --script 'gs.log(\"Hello\")')" })
10
- .option('file', { alias: 'f', type: 'string', describe: 'Read script from file (avoids shell quoting issues)' });
10
+ .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)' });
11
12
  },
12
13
  handler: wrap(async (argv, app) => {
13
14
  let script;
15
+ let scopeSysId = null;
16
+
17
+ if (argv.scope) {
18
+ scopeSysId = await app.sdk.resolveScope(argv.scope);
19
+ if (!scopeSysId) {
20
+ throw new Error(`Scope not found: ${argv.scope}`);
21
+ }
22
+ }
14
23
 
15
24
  if (argv.file) {
16
25
  script = fs.readFileSync(argv.file, 'utf-8');
@@ -20,13 +29,20 @@ export function evalCmd(wrap) {
20
29
  throw new Error('--script or --file is required');
21
30
  }
22
31
 
23
- const output = await app.sdk.executeScript(script);
32
+ if (scopeSysId) {
33
+ script = `var $scopeSysId = '${scopeSysId}';\n${script}`;
34
+ }
35
+
36
+ const warning = scopeSysId
37
+ ? ` ⚠️ Records without explicit sys_scope will land in global. Use $scopeSysId variable.`
38
+ : '';
39
+ const output = await app.sdk.executeScript(script, argv.scope);
24
40
  app.ok({
25
41
  script,
26
42
  output,
27
43
  instance: app.getEffectiveInstance(),
28
44
  }, {
29
- summary: 'Script executed',
45
+ summary: 'Script executed' + warning,
30
46
  breadcrumbs: [{
31
47
  action: 'eval',
32
48
  cmd: 'jsn dev eval --script \'...\'',
@@ -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,15 +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
 
13
+ const HERMES_SKILL_PATH = path.join(os.homedir(), '.hermes', 'skills', SKILL_NAME, 'SKILL.md');
14
+
11
15
  const SKILL_RAW_URL = 'https://raw.githubusercontent.com/jacebenson/jsn/nodejs/skills/servicenow/SKILL.md';
12
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
+
13
35
  function readBundledSkill() {
14
36
  try {
15
37
  return fs.readFileSync(SKILL_REPO_PATH, 'utf-8');
@@ -18,9 +40,23 @@ function readBundledSkill() {
18
40
  }
19
41
  }
20
42
 
43
+ function readInstalledSkill() {
44
+ try {
45
+ return fs.readFileSync(HERMES_SKILL_PATH, 'utf-8');
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
21
51
  export async function checkSkill() {
22
- const bundled = readBundledSkill();
23
- if (!bundled) return { current: false, error: 'Skill file not found in package' };
52
+ // Compare the Hermes-installed copy's version against GitHub (not the npm-bundled copy).
53
+ // Uses the YAML frontmatter "version" field so publishing jsn doesn't flag it.
54
+ const installed = readInstalledSkill() || readBundledSkill();
55
+ if (!installed) return { current: false, error: 'Skill file not found' };
56
+
57
+ const installedVersion = extractVersion(installed);
58
+ if (!installedVersion) return { current: false, error: 'No version field in installed skill' };
59
+
24
60
  try {
25
61
  const controller = new AbortController();
26
62
  const timer = setTimeout(() => controller.abort(), 10000);
@@ -28,18 +64,27 @@ export async function checkSkill() {
28
64
  clearTimeout(timer);
29
65
  if (!res.ok) return { current: false, error: `GitHub returned ${res.status}` };
30
66
  const upstream = await res.text();
31
- const current = bundled === upstream;
67
+ const upstreamVersion = extractVersion(upstream);
68
+ if (!upstreamVersion) return { current: false, error: 'No version field in upstream skill' };
69
+
70
+ const current = installedVersion === upstreamVersion;
32
71
  return {
33
72
  current,
34
- bundled_lines: bundled.split('\n').length,
35
- upstream_lines: upstream.split('\n').length,
36
- error: current ? null : 'Bundled skill is outdated — run "jsn skill install" to update',
73
+ installed_version: installedVersion,
74
+ upstream_version: upstreamVersion,
75
+ error: current ? null : `Skill version ${installedVersion} vs GitHub ${upstreamVersion} — run "jsn skill install" to update`,
37
76
  };
38
77
  } catch {
39
78
  return { current: false, error: 'Could not check — GitHub unreachable' };
40
79
  }
41
80
  }
42
81
 
82
+ function extractVersion(content) {
83
+ // Matches version in YAML frontmatter (top-level or nested under metadata:)
84
+ const m = content.match(/^\s*version:\s*["']?(.+?)["']?\s*$/m);
85
+ return m ? m[1] : null;
86
+ }
87
+
43
88
  export function skillCmd(wrap) {
44
89
  return {
45
90
  command: 'skill',
@@ -70,9 +115,9 @@ export function skillCmd(wrap) {
70
115
  if (result.error && !result.current) {
71
116
  app.ok(result, { summary: result.error });
72
117
  } else if (result.current) {
73
- app.ok(result, { summary: `✓ Skill is current (${result.bundled_lines} lines)` });
118
+ app.ok(result, { summary: `✓ Skill is current (v${result.installed_version})` });
74
119
  } else {
75
- app.ok(result, { summary: `⚠ Skill is outdated (bundled ${result.bundled_lines} lines vs upstream ${result.upstream_lines} lines) — run "jsn skill install" to update` });
120
+ app.ok(result, { summary: `⚠ Skill outdated: installed v${result.installed_version} vs GitHub v${result.upstream_version} — run "jsn skill install" to update` });
76
121
  }
77
122
  }),
78
123
  })
@@ -90,18 +135,23 @@ export function skillCmd(wrap) {
90
135
  command: 'path',
91
136
  describe: 'Show skill file locations and install targets',
92
137
  handler: wrap(async (_argv, app) => {
93
- const hermesDir = path.join(os.homedir(), '.hermes', 'skills', 'servicenow');
94
- 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
+
95
144
  app.ok({
96
145
  bundled: SKILL_REPO_PATH,
97
- hermes: hermesPath,
146
+ targets: allPaths,
98
147
  raw_url: SKILL_RAW_URL,
99
148
  }, {
100
- summary: 'Skill file locations',
149
+ summary: 'Skill file locations and install targets',
101
150
  breadcrumbs: [
102
- { action: 'view', cmd: 'jsn skill show', description: 'Show bundled skill content' },
103
- { action: 'fetch', cmd: 'jsn skill fetch', description: 'Download latest from GitHub' },
104
- { 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' },
105
155
  ],
106
156
  });
107
157
  }),
@@ -112,30 +162,71 @@ export function skillCmd(wrap) {
112
162
  builder: (y) => y
113
163
  .positional('dir', {
114
164
  type: 'string',
115
- 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',
116
171
  }),
117
172
  handler: wrap(async (argv, app) => {
118
- const targetDir = argv.dir || path.join(os.homedir(), '.hermes', 'skills', 'servicenow');
119
- const targetPath = path.join(targetDir, 'SKILL.md');
120
-
121
- fs.mkdirSync(targetDir, { recursive: true });
122
-
123
173
  const res = await fetch(SKILL_RAW_URL);
124
174
  if (!res.ok) throw new Error(`Failed to fetch skill: ${res.status} ${res.statusText}`);
125
175
  const content = await res.text();
126
176
 
127
- fs.writeFileSync(targetPath, content, 'utf-8');
177
+ // Resolve target directories
178
+ let targets = [];
128
179
 
129
- app.ok({
130
- installed: targetPath,
131
- from: SKILL_RAW_URL,
132
- }, {
133
- 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,
134
220
  breadcrumbs: [
135
- { action: 'verify', cmd: `head -30 ${targetPath}`, description: 'Verify the installed skill' },
136
- { 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' },
137
222
  ],
138
- });
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);
139
230
  }),
140
231
  });
141
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
 
package/src/sdk.js CHANGED
@@ -188,6 +188,25 @@ export class SDKClient {
188
188
  };
189
189
  }
190
190
 
191
+ /**
192
+ * Resolve a scope name or sys_id to a sys_id.
193
+ * @param {string} scope - Scope name (e.g. "sn_notif_dashboard") or sys_id
194
+ * @returns {Promise<string|null>} The scope sys_id, or null if not found
195
+ */
196
+ async resolveScope(scope) {
197
+ if (!scope) return null;
198
+ // If it looks like a sys_id (32 hex chars), try it directly
199
+ if (/^[0-9a-fA-F]{32}$/.test(scope)) return scope;
200
+
201
+ const params = new URLSearchParams();
202
+ params.set('sysparm_query', `scope=${scope}`);
203
+ params.set('sysparm_limit', '1');
204
+ params.set('sysparm_fields', 'sys_id,name');
205
+ const records = await this.list('sys_scope', params);
206
+ if (records.length === 0) return null;
207
+ return records[0].sys_id?.value || records[0].sys_id;
208
+ }
209
+
191
210
  async inspectFlow(identifier) {
192
211
  const isSysID = identifier.length === 32 && /^[0-9a-fA-F]+$/.test(identifier);
193
212
 
@@ -372,7 +391,7 @@ export class SDKClient {
372
391
  * @param {string} script - JavaScript code to execute
373
392
  * @returns {Promise<string>} The script's output text
374
393
  */
375
- async executeScript(script) {
394
+ async executeScript(script, scope) {
376
395
  // Step 1: Warm up the session by hitting any REST API — this makes
377
396
  // ServiceNow issue session cookies for subsequent UI page requests.
378
397
  // We capture cookies to forward them (Node.js fetch() has no built-in cookie jar).
@@ -387,7 +406,7 @@ export class SDKClient {
387
406
  formBody.set('script', script);
388
407
  formBody.set('sysparm_ck', csrfToken);
389
408
  formBody.set('runscript', 'Run script');
390
- formBody.set('sys_scope', 'global');
409
+ formBody.set('sys_scope', scope || 'global');
391
410
  formBody.set('record_for_rollback', 'on');
392
411
  formBody.set('quota_managed_transaction', 'on');
393
412