@jacebenson/jsn 3.0.5 → 3.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.
@@ -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 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: 50, 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 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 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 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 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 scrapi create --name "..." --service-id ... --namespace ... --resource ...', description: 'Create a new API' },
211
+ { action: 'list', cmd: 'jsn 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 scrapi list --query "web_service_definition=${defSysId}"`, description: 'List operations for this API' },
320
+ { action: 'add', cmd: `jsn 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 scrapi list');
337
+ console.log(' jsn scrapi show my_operation');
338
+ console.log(' jsn 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
+ }
@@ -15,7 +15,7 @@ export function updateSetsCmd(wrap) {
15
15
  builder: (y) => y
16
16
  .option('query', { type: 'string', describe: 'Encoded query (e.g. "nameLIKEincident" or "active=true")' })
17
17
  .option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description")' })
18
- .option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Max records' }),
18
+ .option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Max records' }),
19
19
  handler: wrap(async (argv, app) => {
20
20
  const columns = argv.columns ? argv.columns.split(',') : ['name', 'state', 'application'];
21
21
  const query = argv.query || '';
@@ -24,6 +24,7 @@ export function updateSetsCmd(wrap) {
24
24
  app, table: 'sys_update_set', singular: 'update set', columns, limit: argv.limit, query, labelField: 'name',
25
25
  formatLabel: r => `${getStringField(r, 'name')} [${getStringField(r, 'state') || '?'}]`,
26
26
  });
27
+ if (picked === undefined) return; // user cancelled
27
28
  if (picked) {
28
29
  picked._context = { instance_url: app.getEffectiveInstance(), table: 'sys_update_set' };
29
30
  return app.ok(picked, { summary: `Update set: ${getStringField(picked, 'name')}` });
@@ -151,12 +152,12 @@ export function updateSetsCmd(wrap) {
151
152
  breadcrumbs: [
152
153
  {
153
154
  action: 'set',
154
- cmd: `jsn dev updatesets set "${argv.name}"`,
155
+ cmd: `jsn updatesets set "${argv.name}"`,
155
156
  description: 'Switch to this update set',
156
157
  },
157
158
  {
158
159
  action: 'complete',
159
- cmd: `jsn dev updatesets complete "${argv.name}"`,
160
+ cmd: `jsn updatesets complete "${argv.name}"`,
160
161
  description: 'Mark as complete when done',
161
162
  },
162
163
  ],
@@ -225,9 +226,9 @@ export function updateSetsCmd(wrap) {
225
226
  console.log(' export <name> Export an update set to XML');
226
227
  console.log(' complete <name> Mark an update set as complete (coming soon)');
227
228
  console.log(' yolo Silence the "Default update set" warning');
228
- console.log('\nRun "jsn dev updatesets <command> --help" for details.');
229
+ console.log('\nRun "jsn updatesets <command> --help" for details.');
229
230
  console.log('\nTip: Create an update set first:');
230
- console.log(' jsn dev updatesets create --name "My Feature"');
231
+ console.log(' jsn updatesets create --name "My Feature"');
231
232
  console.log(' # → Auto-set as current, then record changes are captured.');
232
233
  }
233
234
  },
@@ -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'],
@@ -74,7 +74,7 @@ export function recordsCmd(wrap) {
74
74
  const breadcrumbs = [
75
75
  { action: 'create', cmd: `jsn records create --table ${table} --data '{...}'`, description: 'Create a new record' },
76
76
  { action: 'filter', cmd: `jsn records list --table ${table} --query "priority=1"`, description: 'Filter: priority 1 only' },
77
- { action: 'columns', cmd: `jsn dev columns --table ${table}`, description: 'View available columns' },
77
+ { action: 'columns', cmd: `jsn columns --table ${table}`, description: 'View available columns' },
78
78
  ];
79
79
  if (argv['sys-id']) {
80
80
  breadcrumbs.unshift({
@@ -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
  }),
@@ -178,12 +188,12 @@ export function recordsCmd(wrap) {
178
188
  const breadcrumbs = [
179
189
  ...result.flows.map(f => ({
180
190
  action: 'show',
181
- cmd: `jsn dev flows show "${f.flow}"`,
191
+ cmd: `jsn flows show "${f.flow}"`,
182
192
  description: `View flow details for ${f.flow}`,
183
193
  })),
184
194
  ...result.businessRules.slice(0, 5).map(br => ({
185
195
  action: 'show',
186
- cmd: `jsn dev rules show "${br.name}"`,
196
+ cmd: `jsn rules show "${br.name}"`,
187
197
  description: `View business rule: ${br.name}`,
188
198
  })),
189
199
  ];
@@ -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
  },