@jacebenson/jsn 4.3.12 → 4.3.13-dev.101.1

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.
Files changed (77) hide show
  1. package/README.md +95 -0
  2. package/package.json +2 -1
  3. package/skills/servicenow/SKILL.md +20 -172
  4. package/src/app.js +29 -40
  5. package/src/auth-recovery.js +83 -0
  6. package/src/auth.js +2 -0
  7. package/src/capabilities.js +9 -0
  8. package/src/cli-adapter.js +24 -4
  9. package/src/cli.js +41 -9
  10. package/src/command-audience.js +35 -0
  11. package/src/commands/_generic.js +58 -37
  12. package/src/commands/_simple.js +30 -33
  13. package/src/commands/_ticket.js +38 -31
  14. package/src/commands/attachments.js +88 -0
  15. package/src/commands/auth.js +94 -19
  16. package/src/commands/b4rules.js +8 -3
  17. package/src/commands/changes.js +2 -4
  18. package/src/commands/columns.js +2 -1
  19. package/src/commands/customizations.js +13 -7
  20. package/src/commands/discover.js +54 -0
  21. package/src/commands/docs/db.js +9 -1
  22. package/src/commands/docs/docs.js +25 -2
  23. package/src/commands/docs/ingest.js +20 -3
  24. package/src/commands/docs/install.js +86 -0
  25. package/src/commands/docs/locks.js +34 -0
  26. package/src/commands/docs/refresh.js +22 -2
  27. package/src/commands/docs/replace.js +30 -0
  28. package/src/commands/docs/snapshot-check.js +11 -0
  29. package/src/commands/docs/snapshot.js +130 -0
  30. package/src/commands/docs/sync.js +16 -6
  31. package/src/commands/forms.js +22 -97
  32. package/src/commands/groups.js +11 -8
  33. package/src/commands/history.js +57 -0
  34. package/src/commands/incidents.js +2 -4
  35. package/src/commands/midservers.js +117 -10
  36. package/src/commands/records.js +64 -119
  37. package/src/commands/requests.js +10 -16
  38. package/src/commands/rest.js +15 -2
  39. package/src/commands/scan.js +234 -0
  40. package/src/commands/scopes.js +1 -1
  41. package/src/commands/setup.js +46 -9
  42. package/src/commands/skill.js +72 -76
  43. package/src/commands/tasks.js +2 -4
  44. package/src/commands/tickets.js +31 -22
  45. package/src/commands/uipolicies.js +2 -27
  46. package/src/commands/updatesets.js +66 -13
  47. package/src/commands/users.js +11 -8
  48. package/src/commands/version.js +14 -8
  49. package/src/constrained-query.js +20 -0
  50. package/src/context.js +6 -6
  51. package/src/customization-guidance.js +31 -0
  52. package/src/customizations-report.js +6 -0
  53. package/src/customizations.js +28 -13
  54. package/src/errors.js +23 -1
  55. package/src/essential-records.js +40 -0
  56. package/src/field-behavior.js +15 -0
  57. package/src/help.js +11 -2
  58. package/src/helpers.js +44 -0
  59. package/src/metadata/records.js +249 -0
  60. package/src/midserver-linux-remote.js +210 -0
  61. package/src/midserver-remote-recipe.js +49 -0
  62. package/src/midserver-remote.js +221 -0
  63. package/src/midserver-windows-remote.js +318 -0
  64. package/src/midserver-zip.js +2 -2
  65. package/src/mutation-context.js +74 -0
  66. package/src/mutation-history.js +75 -0
  67. package/src/mutations.js +3 -2
  68. package/src/output.js +34 -2
  69. package/src/record-command-profiles.js +70 -0
  70. package/src/record-profiles.js +93 -0
  71. package/src/records-engine.js +57 -0
  72. package/src/related-lists.js +153 -0
  73. package/src/resolve-record.js +50 -28
  74. package/src/scan.js +249 -0
  75. package/src/sdk-batch.js +130 -0
  76. package/src/sdk.js +157 -18
  77. package/src/update-set-xml.js +89 -0
@@ -3,7 +3,7 @@ import { requireCurrentUserSysId, setCurrentApplication } from '../context.js';
3
3
  import { declareCapabilities } from '../capabilities.js';
4
4
  import { unwrapSysId } from '../resolve-record.js';
5
5
 
6
- declareCapabilities('scopes', { mutationSubcommands: ['create', 'set'] });
6
+ declareCapabilities('scopes', { mutationSubcommands: ['create', 'set'], mutationContextExemptSubcommands: ['create', 'set'] });
7
7
 
8
8
  /**
9
9
  * Resolve a scope identifier to a single sys_scope record.
@@ -1,5 +1,5 @@
1
1
  import { loginWizard, loginProfile, showAuthStatus, switchProfile, modifyProfile, logoutProfile, removeConnection } from './auth.js';
2
- import { errUsage } from '../errors.js';
2
+ import { errUsageHint } from '../errors.js';
3
3
  import { declareCapabilities } from '../capabilities.js';
4
4
 
5
5
  // Setup manages local profile config — runs without a configured instance.
@@ -7,15 +7,36 @@ import { declareCapabilities } from '../capabilities.js';
7
7
  declareCapabilities('setup', { noInstance: true, skipContextHeader: true });
8
8
 
9
9
  const HUB_CHOICES = [
10
- { name: 'Add a connection', value: 'add' },
11
- { name: 'Sign in again', value: 'login' },
12
- { name: 'Check connections', value: 'status' },
13
- { name: 'Choose the default profile', value: 'switch' },
14
- { name: 'Change settings', value: 'modify' },
15
- { name: 'Sign out (keep the profile)', value: 'logout' },
16
- { name: 'Remove a connection', value: 'remove' },
10
+ { name: 'Add a connection', value: 'add', command: 'jsn auth login <instance> --name <name> --basic' },
11
+ { name: 'Sign in again', value: 'login', command: 'jsn auth login --profile <name> (with the authentication options below)' },
12
+ { name: 'Check connections', value: 'status', command: 'jsn auth status --details --json (use --local to skip instance probes)' },
13
+ { name: 'Choose the default profile', value: 'switch', command: 'jsn auth switch <name>' },
14
+ { name: 'Change settings', value: 'modify', command: 'jsn auth modify --profile <name> --read-only --confirm-deletes --no-include-counts' },
15
+ { name: 'Sign out (keep the profile)', value: 'logout', command: 'jsn auth logout --profile <name> --force (only after explicit approval)' },
16
+ { name: 'Remove a connection', value: 'remove', command: 'jsn auth remove <name>' },
17
+ { name: 'Install optional offline documentation', value: 'docs', command: 'jsn docs install --source snapshot|github|skip' },
17
18
  ];
18
19
 
20
+ const setupHelp = () => [
21
+ 'With no profiles, setup opens the add-connection wizard; otherwise it opens the management menu.',
22
+ 'The wizard and these scriptable commands share the same authentication and configuration operations:',
23
+ ...HUB_CHOICES.map(choice => ` ${choice.name}: ${choice.command}`),
24
+ '',
25
+ 'Authentication input without secrets in command arguments:',
26
+ ' Basic: set SN_USERNAME and SN_PASSWORD through your environment, then use --basic.',
27
+ ' OAuth: jsn auth login <instance> --name <name> --oauth --print-url --wait-file <code-file> --json',
28
+ ' Browser session: jsn auth login <instance> --name <name> --headers-file <session-file> --json',
29
+ ' Piped browser session: jsn auth login <instance> --name <name> --headers-file - --json',
30
+ ' A browser window can also be requested explicitly with --browser; it requires a human to sign in.',
31
+ ' Refresh OAuth: jsn auth refresh --profile <name>',
32
+ ' Renew captured browser headers: jsn auth refresh --profile <name> --headers-file <session-file>',
33
+ '',
34
+ 'New-profile preferences: --read-only, --confirm-deletes, --include-counts and --make-default.',
35
+ 'Use --no-read-only, --no-confirm-deletes, --no-include-counts or --no-make-default for the opposite value.',
36
+ 'Use --json for structured output. Explicit flags avoid implicit pickers; ambiguous targets return an error.',
37
+ 'Discover all options with jsn auth <command> --help.',
38
+ ].join('\n');
39
+
19
40
  /** The setup hub prompt; q and Escape resolve with no selected action. */
20
41
  export const authHubPrompt = async (...args) => {
21
42
  const { createPrompt, useKeypress, usePagination, useState, isDownKey, isEnterKey, isUpKey } = await import('@inquirer/core');
@@ -81,19 +102,35 @@ export function setupCmd(wrap) {
81
102
  return {
82
103
  command: 'setup',
83
104
  describe: 'Set up and manage your ServiceNow instances (interactive)',
105
+ builder: y => y.option('docs-source', { type: 'string', choices: ['snapshot', 'github', 'skip'], describe: 'Install optional documentation directly without the authentication wizard' }).epilogue(setupHelp()),
84
106
  handler: wrap(async (argv, app) => {
107
+ if (argv['docs-source']) {
108
+ const { installDocumentation } = await import('./docs/install.js');
109
+ app.ok(await installDocumentation(argv['docs-source']));
110
+ return;
111
+ }
85
112
  const { canUsePicker } = await import('../helpers.js');
86
113
  if (!app.isInteractive() || !canUsePicker(app, argv)) {
87
- throw errUsage('"jsn setup" is interactive. Use the specific commands:\n jsn auth login <instance>\n jsn auth refresh\n jsn auth status');
114
+ throw errUsageHint('"jsn setup" is interactive. Use explicit auth commands instead.', setupHelp());
88
115
  }
89
116
  const profileNames = Object.keys(app.config.profiles || {});
90
117
  if (profileNames.length === 0) {
91
118
  const result = await loginWizard(app, argv);
92
119
  await showAuthStatus({ ...argv, profile: result.profileName }, app);
120
+ const { docsDbExists } = await import('./docs/db.js');
121
+ if (!docsDbExists()) {
122
+ const { offerDocumentation } = await import('./docs/install.js');
123
+ app.ok(await offerDocumentation(app, argv));
124
+ }
93
125
  return;
94
126
  }
95
127
  const action = await authHubMenu();
96
128
  if (action === undefined) return;
129
+ if (action === 'docs') {
130
+ const { offerDocumentation } = await import('./docs/install.js');
131
+ app.ok(await offerDocumentation(app, argv));
132
+ return;
133
+ }
97
134
  const result = await dispatchSetupAction(app, argv, action);
98
135
  if (action !== 'status') await showAuthStatus({ ...argv, profile: result?.profile || result?.profileName }, app);
99
136
  }),
@@ -18,8 +18,6 @@ const SKILL_REPO_PATH = path.join(
18
18
  '..', '..', 'skills', SKILL_NAME, 'SKILL.md'
19
19
  );
20
20
 
21
- const SKILL_RAW_URL = 'https://raw.githubusercontent.com/jacebenson/jsn/main/skills/servicenow/SKILL.md';
22
-
23
21
  /**
24
22
  * Resolve the user's real home directory, even when Hermes overrides $HOME.
25
23
  */
@@ -119,62 +117,56 @@ function saveSkillConfig(updates) {
119
117
  fs.writeFileSync(globalConfigPath(), JSON.stringify(cfg, null, 2), { mode: 0o600 });
120
118
  }
121
119
 
122
- function recordSkillInstall(targetPath) {
123
- const bundled = readBundledSkill();
124
- const version = extractVersion(bundled) || 'unknown';
120
+ function replaceSkill(targetPath, content) {
121
+ const temporary = `${targetPath}.${process.pid}.tmp`;
122
+ try {
123
+ fs.writeFileSync(temporary, content, { encoding: 'utf-8', mode: 0o600 });
124
+ fs.renameSync(temporary, targetPath);
125
+ } finally {
126
+ fs.rmSync(temporary, { force: true });
127
+ }
128
+ }
129
+
130
+ function recordSkillInstall(targetPaths, content) {
131
+ const version = extractVersion(content) || 'unknown';
125
132
  saveSkillConfig({
126
- skillLocation: targetPath,
133
+ skillLocation: targetPaths[0],
134
+ skillLocations: targetPaths,
127
135
  skillVersion: version,
128
136
  skillLastChecked: new Date().toISOString(),
129
137
  });
130
138
  }
131
139
 
132
- // ── Skill version check (once per day, uses recorded location) ──
133
-
134
- export function checkSkill() {
140
+ // Compare bytes, including edits that retain the same version number.
141
+ export function checkSkill({ warn = true } = {}) {
135
142
  const cfg = loadSkillConfig();
136
- const location = cfg.skillLocation;
137
- if (!location) return { current: true, note: 'Skill not installed — run "jsn skill install"' };
138
-
139
- // Throttle: skip if checked within last 24 hours
140
- if (cfg.skillLastChecked) {
141
- const elapsed = Date.now() - new Date(cfg.skillLastChecked).getTime();
142
- if (elapsed < 86400000) return { current: true, note: 'Checked within 24h — skipping' };
143
- }
144
-
145
- let installed;
146
- try {
147
- installed = fs.readFileSync(location, 'utf-8');
148
- } catch {
149
- return { current: true, note: `Skill file not found at ${location} — run "jsn skill install"` };
150
- }
151
-
152
- const installedVersion = extractVersion(installed);
143
+ const locations = cfg.skillLocations || (cfg.skillLocation ? [cfg.skillLocation] : []);
153
144
  const bundled = readBundledSkill();
154
- const bundledVersion = extractVersion(bundled);
155
-
156
- if (!installedVersion || !bundledVersion) {
157
- return { current: true, error: 'Could not determine skill version' };
158
- }
159
-
160
- const current = installedVersion === bundledVersion;
161
-
162
- if (!current) {
163
- process.stderr.write(
164
- `\n⚠ jsn: ServiceNow skill is outdated (installed v${installedVersion}, bundled v${bundledVersion}).\n` +
165
- ` Run "jsn skill install" to update.\n\n`
166
- );
167
- }
168
-
169
- // Update last-checked timestamp
145
+ if (bundled === null) return { current: false, error: 'Bundled skill missing; reinstall jsn.' };
146
+ if (!locations.length) return { current: false, note: 'Skill not installed — run "jsn skill install"' };
147
+ const copies = locations.map(location => {
148
+ let installed;
149
+ try { installed = fs.readFileSync(location, 'utf-8'); } catch {
150
+ return { location, current: false, status: 'missing' };
151
+ }
152
+ const current = installed === bundled;
153
+ const actual = installed.split('\n');
154
+ const expected = bundled.split('\n');
155
+ const line = current ? undefined : Array.from({ length: Math.max(actual.length, expected.length) }, (_, i) => i)
156
+ .find(i => actual[i] !== expected[i]) + 1;
157
+ return { location, current, status: current ? 'matching' : 'different',
158
+ installed_version: extractVersion(installed), first_different_line: line };
159
+ });
160
+ const current = copies.every(copy => copy.current);
161
+ if (!current && warn) process.stderr.write('ServiceNow skill differs from the packaged copy. Run "jsn skill check" for locations or "jsn skill install" to replace it.\n');
170
162
  saveSkillConfig({ skillLastChecked: new Date().toISOString() });
171
-
172
- return { current, installed_version: installedVersion, bundled_version: bundledVersion };
163
+ return { current, installed_version: copies[0].installed_version,
164
+ bundled_version: extractVersion(bundled), copies };
173
165
  }
174
166
 
175
167
  function extractVersion(content) {
176
168
  // Matches version in YAML frontmatter (top-level or nested under metadata:)
177
- const m = content.match(/^\s*version:\s*["']?(.+?)["']?\s*$/m);
169
+ const m = (content || '').match(/^\s*version:\s*["']?(.+?)["']?\s*$/m);
178
170
  return m ? m[1] : null;
179
171
  }
180
172
 
@@ -205,9 +197,9 @@ export function skillCmd(wrap) {
205
197
  })
206
198
  .command({
207
199
  command: 'check',
208
- describe: 'Check if the installed skill matches the bundled version',
200
+ describe: 'Compare installed skill contents with the packaged skill',
209
201
  handler: wrap(async (_argv, app) => {
210
- const result = checkSkill();
202
+ const result = checkSkill({ warn: false });
211
203
 
212
204
  let summary;
213
205
  if (result.error) {
@@ -215,21 +207,21 @@ export function skillCmd(wrap) {
215
207
  } else if (result.note) {
216
208
  summary = result.note;
217
209
  } else if (result.current) {
218
- summary = `✓ Skill is current (v${result.installed_version} matches bundled v${result.bundled_version})`;
210
+ summary = `✓ Skill contents match the packaged copy (v${result.bundled_version})`;
219
211
  } else {
220
- summary = `⚠ Skill outdated: installed v${result.installed_version} vs bundled v${result.bundled_version} — run "jsn skill install" to update`;
212
+ summary = `⚠ Skill contents differ — run "jsn skill install" to replace installed copies`;
221
213
  }
222
214
 
223
- app.ok(result, { summary });
215
+ const details = result.copies?.map(copy => `${copy.status}: ${copy.location}${copy.first_different_line ? ` (first difference: line ${copy.first_different_line})` : ''}`).join('\n');
216
+ app.ok({ ...result, _formatted: [summary, details].filter(Boolean).join('\n') }, { summary });
224
217
  }),
225
218
  })
226
219
  .command({
227
220
  command: 'fetch',
228
- describe: 'Download the latest skill file from GitHub to stdout',
221
+ describe: 'Print the packaged skill to stdout (offline)',
229
222
  handler: wrap(async (_argv, _app) => {
230
- const res = await fetch(SKILL_RAW_URL);
231
- if (!res.ok) throw new Error(`Failed to fetch skill: ${res.status} ${res.statusText}`);
232
- const content = await res.text();
223
+ const content = readBundledSkill();
224
+ if (content === null) throw new Error('Bundled skill missing; reinstall jsn.');
233
225
  process.stdout.write(content);
234
226
  }),
235
227
  })
@@ -246,7 +238,6 @@ export function skillCmd(wrap) {
246
238
  app.ok({
247
239
  bundled: SKILL_REPO_PATH,
248
240
  targets: allPaths,
249
- raw_url: SKILL_RAW_URL,
250
241
  }, {
251
242
  summary: 'Skill file locations and install targets',
252
243
  breadcrumbs: [
@@ -260,7 +251,7 @@ export function skillCmd(wrap) {
260
251
  })
261
252
  .command({
262
253
  command: 'install [dir]',
263
- describe: 'Download and save the latest skill file',
254
+ describe: 'Install the bundled skill matched to this CLI version',
264
255
  builder: (y) => y
265
256
  .positional('dir', {
266
257
  type: 'string',
@@ -268,12 +259,13 @@ export function skillCmd(wrap) {
268
259
  })
269
260
  .option('target', {
270
261
  type: 'string',
271
- describe: 'Target agent(s): hermes, copilot, vscode, claude, cursor, agents, opencode, openclaw, codex, or "all" (comma-separated). Omit for interactive picker.',
262
+ describe: 'Target agent(s): hermes, copilot, vscode, claude, cursor, agents, opencode, openclaw, codex, or "all" (comma-separated). Omit to replace recorded copies, or choose targets on first install.',
272
263
  }),
273
264
  handler: wrap(async (argv, app) => {
274
- const res = await fetch(SKILL_RAW_URL);
275
- if (!res.ok) throw new Error(`Failed to fetch skill: ${res.status} ${res.statusText}`);
276
- const content = await res.text();
265
+ const content = readBundledSkill();
266
+ if (!content) {
267
+ throw new Error('Skill file not found in package (expected at skills/servicenow/SKILL.md). Reinstall jsn to restore it.');
268
+ }
277
269
 
278
270
  // Resolve target directories
279
271
  let targets = [];
@@ -283,7 +275,7 @@ export function skillCmd(wrap) {
283
275
  const p = path.resolve(argv.dir);
284
276
  fs.mkdirSync(p, { recursive: true });
285
277
  const targetPath = path.join(p, 'SKILL.md');
286
- fs.writeFileSync(targetPath, content, 'utf-8');
278
+ replaceSkill(targetPath, content);
287
279
  targets.push({ name: path.basename(argv.dir), path: targetPath });
288
280
  } else if (argv.target) {
289
281
  // Explicit --target: install to specified agents
@@ -296,38 +288,41 @@ export function skillCmd(wrap) {
296
288
  if (key === 'cursor') {
297
289
  fs.mkdirSync(dir, { recursive: true });
298
290
  const targetPath = path.join(dir, `${SKILL_NAME}.mdc`);
299
- fs.writeFileSync(targetPath, content, 'utf-8');
291
+ replaceSkill(targetPath, content);
300
292
  targets.push({ name: TARGET_NAMES[key], path: targetPath });
301
293
  } else {
302
294
  fs.mkdirSync(dir, { recursive: true });
303
295
  const targetPath = path.join(dir, 'SKILL.md');
304
- fs.writeFileSync(targetPath, content, 'utf-8');
296
+ replaceSkill(targetPath, content);
305
297
  targets.push({ name: TARGET_NAMES[key], path: targetPath });
306
298
  }
307
299
  }
308
300
  }
301
+ } else if (loadSkillConfig().skillLocation) {
302
+ const cfg = loadSkillConfig();
303
+ for (const targetPath of cfg.skillLocations || [cfg.skillLocation]) {
304
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
305
+ replaceSkill(targetPath, content);
306
+ targets.push({ name: targetPath, path: targetPath });
307
+ }
309
308
  } else if (canUsePicker(app, argv)) {
310
309
  // Interactive: show multi-select picker. canUsePicker() (not a
311
310
  // bare stdin TTY check) keeps a PTY allocated for an agent,
312
311
  // CI, JSN_NO_PROMPTS, --json/--quiet or --get out of a prompt.
313
- const fetchedVersion = extractVersion(content);
314
312
  // Check if any existing installed copies are outdated
315
313
  const outdated = [];
316
- if (fetchedVersion) {
317
- for (const [key, dir] of Object.entries(AGENT_SKILL_DIRS)) {
314
+ for (const [key, dir] of Object.entries(AGENT_SKILL_DIRS)) {
318
315
  const skillPath = key === 'cursor'
319
316
  ? path.join(dir, `${SKILL_NAME}.mdc`)
320
317
  : path.join(dir, 'SKILL.md');
321
318
  try {
322
319
  const existing = fs.readFileSync(skillPath, 'utf-8');
323
- const existingVersion = extractVersion(existing);
324
- if (existingVersion && existingVersion !== fetchedVersion) {
320
+ if (existing !== content) {
325
321
  outdated.push(TARGET_NAMES[key]);
326
322
  }
327
323
  } catch {
328
324
  // Not installed — skip
329
325
  }
330
- }
331
326
  }
332
327
  const promptMsg = outdated.length > 0
333
328
  ? `Where should the jsn skill be installed? (${outdated.length} outdated)`
@@ -366,12 +361,12 @@ export function skillCmd(wrap) {
366
361
  if (key === 'cursor') {
367
362
  fs.mkdirSync(dir, { recursive: true });
368
363
  const targetPath = path.join(dir, `${SKILL_NAME}.mdc`);
369
- fs.writeFileSync(targetPath, content, 'utf-8');
364
+ replaceSkill(targetPath, content);
370
365
  targets.push({ name: TARGET_NAMES[key], path: targetPath });
371
366
  } else {
372
367
  fs.mkdirSync(dir, { recursive: true });
373
368
  const targetPath = path.join(dir, 'SKILL.md');
374
- fs.writeFileSync(targetPath, content, 'utf-8');
369
+ replaceSkill(targetPath, content);
375
370
  targets.push({ name: TARGET_NAMES[key], path: targetPath });
376
371
  }
377
372
  }
@@ -380,7 +375,7 @@ export function skillCmd(wrap) {
380
375
  const dir = AGENT_SKILL_DIRS.hermes;
381
376
  fs.mkdirSync(dir, { recursive: true });
382
377
  const targetPath = path.join(dir, 'SKILL.md');
383
- fs.writeFileSync(targetPath, content, 'utf-8');
378
+ replaceSkill(targetPath, content);
384
379
  targets.push({ name: TARGET_NAMES.hermes, path: targetPath });
385
380
  }
386
381
 
@@ -389,7 +384,7 @@ export function skillCmd(wrap) {
389
384
  }
390
385
 
391
386
  // Record install location so checkSkill knows where to look
392
- recordSkillInstall(targets[0].path);
387
+ recordSkillInstall(targets.map(target => target.path), content);
393
388
 
394
389
  const installed = targets.reduce((acc, t) => { acc[t.name] = t.path; return acc; }, {});
395
390
  const summary = targets.length === 1
@@ -399,7 +394,7 @@ export function skillCmd(wrap) {
399
394
  const okOpts = {
400
395
  summary,
401
396
  breadcrumbs: [
402
- { action: 'reinstall', cmd: 'jsn skill install', description: 'Re-download and reinstall to default target' },
397
+ { action: 'reinstall', cmd: 'jsn skill install', description: 'Replace recorded copies with the packaged skill' },
403
398
  ],
404
399
  };
405
400
 
@@ -407,7 +402,7 @@ export function skillCmd(wrap) {
407
402
  okOpts.notice = 'Installed to multiple agents. Restart or reload your agent to pick up the skill.';
408
403
  }
409
404
 
410
- app.ok({ installed, from: SKILL_RAW_URL }, okOpts);
405
+ app.ok({ installed, from: SKILL_REPO_PATH }, okOpts);
411
406
  }),
412
407
  });
413
408
  },
@@ -416,7 +411,8 @@ export function skillCmd(wrap) {
416
411
  console.log('');
417
412
  console.log('Available subcommands:');
418
413
  console.log(' show Show the bundled skill file');
419
- console.log(' fetch Download the latest skill from GitHub');
414
+ console.log(' fetch Print the packaged skill (offline)');
415
+ console.log(' check Show whether installed contents differ');
420
416
  console.log(' path Show skill file locations');
421
417
  console.log(' install Install skill file to agents directory');
422
418
  console.log('');
@@ -1,7 +1,5 @@
1
- import { buildTicketCommands } from './_ticket.js';
2
-
3
- const taskDefaultColumns = ['number', 'short_description', 'state', 'assigned_to'];
1
+ import { buildProfileCommand } from '../record-profiles.js';
4
2
 
5
3
  export function tasksCmd(wrap) {
6
- return buildTicketCommands('sc_task', 'tasks', 'task', taskDefaultColumns, {}, null, wrap);
4
+ return buildProfileCommand('tasks', wrap);
7
5
  }
@@ -1,5 +1,7 @@
1
- import { formatRecordForDisplay, getStringField, buildQuerySuffix, resolveFieldsParam } from '../helpers.js';
2
- import { resolveRecord, resolveSysId } from '../resolve-record.js';
1
+ import { enrichRelatedLists, relatedListOptions } from '../related-lists.js';
2
+ import { formatRecordForDisplay, getStringField, buildQuerySuffix, resolveFieldsParam, buildRecordPayload, confirmDelete } from '../helpers.js';
3
+ import { getRecord, listRecords, recordListParams, createRecord, updateRecord, deleteRecord, recordOptions } from '../records-engine.js';
4
+ import { resolveSysId } from '../resolve-record.js';
3
5
  import { declareCapabilities } from '../capabilities.js';
4
6
 
5
7
  declareCapabilities('tickets', { mutationSubcommands: ['create', 'update', 'delete'] });
@@ -15,25 +17,20 @@ export function ticketsCmd(wrap) {
15
17
  command: 'list',
16
18
  aliases: ['ls'],
17
19
  describe: 'List tickets',
18
- builder: (y) => y
20
+ builder: (y) => recordOptions(y)
19
21
  .option('query', { type: 'string', describe: 'Encoded query (e.g. "active=true^priority=1")' })
20
22
  .option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description,priority")' })
21
23
  .option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Max records' })
22
24
  .option('offset', { alias: 'o', type: 'number', default: 0, describe: 'Offset' }),
23
25
  handler: wrap(async (argv, app) => {
24
26
  const columns = argv.columns ? argv.columns.split(',') : ['number', 'short_description', 'state', 'assigned_to'];
25
- const params = new URLSearchParams();
26
- params.set('sysparm_limit', String(argv.limit));
27
- params.set('sysparm_offset', String(argv.offset));
28
- params.set('sysparm_display_value', 'all');
29
27
  const fields = resolveFieldsParam(columns);
30
- if (fields) params.set('sysparm_fields', fields);
31
28
  const q = argv.query ? argv.query + '^ORDERBYDESCsys_updated_on' : 'ORDERBYDESCsys_updated_on';
32
- params.set('sysparm_query', q);
33
- const records = await app.sdk.list('task', params);
34
- const displayRecords = fields ? records.map(r => formatRecordForDisplay(r, columns)) : records;
29
+ const params = recordListParams({ limit: argv.limit, offset: argv.offset, fields, query: q });
30
+ const records = await listRecords(app.sdk, { table: 'task', params, displayValues: argv.displayValues });
31
+ const displayRecords = fields && !argv.displayValues ? records.map(r => formatRecordForDisplay(r, columns)) : records;
35
32
  const breadcrumbs = [
36
- { action: 'create', cmd: 'jsn tickets create --data \'{...}\'', description: 'Create a new ticket' },
33
+ { action: 'create', cmd: 'jsn tickets create -f short_description=New ticket', description: 'Create a new ticket' },
37
34
  ];
38
35
  if (records.length === argv.limit) {
39
36
  breadcrumbs.push({
@@ -63,38 +60,50 @@ export function ticketsCmd(wrap) {
63
60
  command: 'show <number>',
64
61
  aliases: ['get'],
65
62
  describe: 'Show a ticket',
63
+ builder: y => relatedListOptions(recordOptions(y)),
66
64
  handler: wrap(async (argv, app) => {
67
- const record = await resolveRecord(app.sdk, { table: 'task', identifier: argv.number, matchField: 'number', resource: 'Ticket' });
65
+ const record = await getRecord(app.sdk, { table: 'task', identifier: argv.number, identifierField: argv.identifierField, displayValues: argv.displayValues, matchField: 'number', resource: 'Ticket' });
66
+ await enrichRelatedLists(record, { table: 'task', argv, app });
68
67
  app.ok(record, { summary: `Ticket ${argv.number}` });
69
68
  }),
70
69
  })
71
70
  .command({
72
71
  command: 'create',
73
72
  describe: 'Create a new ticket',
74
- builder: (y) => y.option('data', { type: 'string', demandOption: true, describe: 'JSON fields (e.g. \'{"state":"2","priority":"1"}\')' }),
73
+ builder: (y) => recordOptions(y, false)
74
+ .option('data', { type: 'string', describe: 'JSON fields' })
75
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
76
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
77
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
75
78
  handler: wrap(async (argv, app) => {
76
- const recordData = JSON.parse(argv.data);
77
- const record = await app.sdk.create('task', recordData);
79
+ const recordData = buildRecordPayload(argv);
80
+ const record = await createRecord(app.sdk, { table: 'task', data: recordData });
78
81
  app.ok(record, { summary: `Created ticket ${getStringField(record, 'number')}` });
79
82
  }),
80
83
  })
81
84
  .command({
82
85
  command: 'update <number>',
83
86
  describe: 'Update a ticket',
84
- builder: (y) => y.option('data', { type: 'string', demandOption: true, describe: 'JSON fields (e.g. \'{"state":"2","priority":"1"}\')' }),
87
+ builder: (y) => recordOptions(y, false)
88
+ .option('data', { type: 'string', describe: 'JSON fields' })
89
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
90
+ .option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
91
+ .option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
85
92
  handler: wrap(async (argv, app) => {
86
- const recordData = JSON.parse(argv.data);
87
- const sysID = await resolveSysId(app.sdk, { table: 'task', identifier: argv.number, matchField: 'number', resource: 'Ticket' });
88
- const updated = await app.sdk.update('task', sysID, recordData);
93
+ const recordData = buildRecordPayload(argv);
94
+ const sysID = await resolveSysId(app.sdk, { table: 'task', identifier: argv.number, identifierField: argv.identifierField, displayValues: argv.displayValues, matchField: 'number', resource: 'Ticket' });
95
+ const updated = await updateRecord(app.sdk, { table: 'task', record: { sys_id: sysID }, data: recordData });
89
96
  app.ok(updated, { summary: `Updated ticket ${argv.number}` });
90
97
  }),
91
98
  })
92
99
  .command({
93
100
  command: 'delete <number>',
94
101
  describe: 'Delete a ticket',
102
+ builder: y => recordOptions(y, false).option('force', { type: 'boolean', default: false, describe: 'Skip confirmation after approval' }),
95
103
  handler: wrap(async (argv, app) => {
96
- const sysID = await resolveSysId(app.sdk, { table: 'task', identifier: argv.number, matchField: 'number', resource: 'Ticket' });
97
- await app.sdk.delete('task', sysID);
104
+ await confirmDelete(app, argv, `Delete ticket ${argv.number}`);
105
+ const sysID = await resolveSysId(app.sdk, { table: 'task', identifier: argv.number, identifierField: argv.identifierField, displayValues: argv.displayValues, matchField: 'number', resource: 'Ticket' });
106
+ await deleteRecord(app.sdk, { table: 'task', record: { sys_id: sysID } });
98
107
  app.ok({ number: argv.number, message: 'Ticket deleted' }, { summary: `Deleted ticket ${argv.number}` });
99
108
  }),
100
109
  })
@@ -1,28 +1,3 @@
1
- import { buildDevCmd } from './_generic.js';
2
- import { getStringField } from '../helpers.js';
1
+ import { buildProfileCommand } from '../record-profiles.js';
3
2
 
4
- export const uipoliciesCmd = (wrap) => buildDevCmd('uipolicies', 'sys_ui_policy', ['uipolicy', 'up'],
5
- ['short_description', 'table', 'sys_class_name', 'active'], wrap, {
6
- singular: 'UI policy',
7
- scopeValidation: true,
8
- onShow: async (record, app) => {
9
- const sysID = getStringField(record, 'sys_id') || '';
10
- if (!sysID) return;
11
- const mp = new URLSearchParams();
12
- mp.set('sysparm_query', `ui_policy=${sysID}^ORDERBYorder`);
13
- mp.set('sysparm_limit', '100'); mp.set('sysparm_display_value', 'all');
14
- mp.set('sysparm_fields', 'name,table,field_name,read_only,mandatory,visible,sys_id');
15
- const actions = await app.sdk.list('sys_ui_policy_action', mp);
16
- if (actions.length > 0) {
17
- console.log(` Actions (${actions.length}):`);
18
- for (const a of actions) {
19
- const f = getStringField(a, 'field_name') || '(all fields)';
20
- const flags = [];
21
- if (getStringField(a, 'mandatory') === 'true') flags.push('mandatory');
22
- if (getStringField(a, 'read_only') === 'true') flags.push('read-only');
23
- if (getStringField(a, 'visible') === 'false') flags.push('hidden');
24
- console.log(` ${f} ${flags.length ? `[${flags.join(', ')}]` : ''}`);
25
- }
26
- }
27
- },
28
- });
3
+ export const uipoliciesCmd = wrap => buildProfileCommand('uipolicies', wrap);