@jacebenson/jsn 0.0.9 → 0.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.
@@ -1,4 +1,4 @@
1
- import { formatRecordForDisplay } from '../helpers.js';
1
+ import { formatRecordForDisplay, getStringField } from '../helpers.js';
2
2
 
3
3
  export function groupRolesCmd(wrap) {
4
4
  return {
@@ -32,7 +32,120 @@ export function groupRolesCmd(wrap) {
32
32
  }, { summary: `${records.length} group role(s)` });
33
33
  }),
34
34
  })
35
+ .command({
36
+ command: 'add',
37
+ describe: 'Add a role to a group',
38
+ builder: (y) => y
39
+ .option('group', { alias: 'g', type: 'string', demandOption: true, describe: 'Group name or sys_id (required)' })
40
+ .option('role', { alias: 'r', type: 'string', demandOption: true, describe: 'Role name or sys_id (required)' }),
41
+ handler: wrap(async (argv, app) => {
42
+ const groupName = argv.group;
43
+ const roleName = argv.role;
44
+
45
+ // Resolve group sys_id
46
+ const isGroupSysID = groupName.length === 32 && /^[0-9a-fA-F]+$/.test(groupName);
47
+ const groupParams = new URLSearchParams();
48
+ groupParams.set('sysparm_query', isGroupSysID ? `sys_id=${groupName}` : `name=${groupName}`);
49
+ groupParams.set('sysparm_limit', '1');
50
+ groupParams.set('sysparm_fields', 'sys_id');
51
+ const groupRecords = await app.sdk.list('sys_user_group', groupParams);
52
+ if (groupRecords.length === 0) {
53
+ throw new Error(`Group not found: ${groupName}`);
54
+ }
55
+ const groupSysID = getStringField(groupRecords[0], 'sys_id');
56
+
57
+ // Resolve role sys_id
58
+ const isRoleSysID = roleName.length === 32 && /^[0-9a-fA-F]+$/.test(roleName);
59
+ const roleParams = new URLSearchParams();
60
+ roleParams.set('sysparm_query', isRoleSysID ? `sys_id=${roleName}` : `name=${roleName}`);
61
+ roleParams.set('sysparm_limit', '1');
62
+ roleParams.set('sysparm_fields', 'sys_id');
63
+ const roleRecords = await app.sdk.list('sys_user_role', roleParams);
64
+ if (roleRecords.length === 0) {
65
+ throw new Error(`Role not found: ${roleName}`);
66
+ }
67
+ const roleSysID = getStringField(roleRecords[0], 'sys_id');
68
+
69
+ // Check if role assignment already exists
70
+ const checkParams = new URLSearchParams();
71
+ checkParams.set('sysparm_query', `group=${groupSysID}^role=${roleSysID}`);
72
+ checkParams.set('sysparm_limit', '1');
73
+ checkParams.set('sysparm_fields', 'sys_id');
74
+ const existing = await app.sdk.list('sys_group_has_role', checkParams);
75
+ if (existing.length > 0) {
76
+ throw new Error(`Role ${roleName} is already assigned to group ${groupName}`);
77
+ }
78
+
79
+ // Create the role assignment
80
+ const record = await app.sdk.create('sys_group_has_role', {
81
+ group: groupSysID,
82
+ role: roleSysID,
83
+ });
84
+ app.ok(record, {
85
+ summary: `Added role ${roleName} to ${groupName}`,
86
+ breadcrumbs: [
87
+ { action: 'list', cmd: `jsn grouproles list --query "group.name=${groupName}"`, description: 'List group roles' },
88
+ { action: 'remove', cmd: `jsn grouproles remove --group "${groupName}" --role "${roleName}"`, description: 'Remove this role' },
89
+ ],
90
+ });
91
+ }),
92
+ })
93
+ .command({
94
+ command: 'remove',
95
+ describe: 'Remove a role from a group',
96
+ builder: (y) => y
97
+ .option('group', { alias: 'g', type: 'string', demandOption: true, describe: 'Group name or sys_id (required)' })
98
+ .option('role', { alias: 'r', type: 'string', demandOption: true, describe: 'Role name or sys_id (required)' }),
99
+ handler: wrap(async (argv, app) => {
100
+ const groupName = argv.group;
101
+ const roleName = argv.role;
35
102
 
103
+ // Resolve group sys_id
104
+ const isGroupSysID = groupName.length === 32 && /^[0-9a-fA-F]+$/.test(groupName);
105
+ const groupParams = new URLSearchParams();
106
+ groupParams.set('sysparm_query', isGroupSysID ? `sys_id=${groupName}` : `name=${groupName}`);
107
+ groupParams.set('sysparm_limit', '1');
108
+ groupParams.set('sysparm_fields', 'sys_id');
109
+ const groupRecords = await app.sdk.list('sys_user_group', groupParams);
110
+ if (groupRecords.length === 0) {
111
+ throw new Error(`Group not found: ${groupName}`);
112
+ }
113
+ const groupSysID = getStringField(groupRecords[0], 'sys_id');
114
+
115
+ // Resolve role sys_id
116
+ const isRoleSysID = roleName.length === 32 && /^[0-9a-fA-F]+$/.test(roleName);
117
+ const roleParams = new URLSearchParams();
118
+ roleParams.set('sysparm_query', isRoleSysID ? `sys_id=${roleName}` : `name=${roleName}`);
119
+ roleParams.set('sysparm_limit', '1');
120
+ roleParams.set('sysparm_fields', 'sys_id');
121
+ const roleRecords = await app.sdk.list('sys_user_role', roleParams);
122
+ if (roleRecords.length === 0) {
123
+ throw new Error(`Role not found: ${roleName}`);
124
+ }
125
+ const roleSysID = getStringField(roleRecords[0], 'sys_id');
126
+
127
+ // Find the role assignment record
128
+ const findParams = new URLSearchParams();
129
+ findParams.set('sysparm_query', `group=${groupSysID}^role=${roleSysID}`);
130
+ findParams.set('sysparm_limit', '1');
131
+ findParams.set('sysparm_fields', 'sys_id');
132
+ const assignmentRecords = await app.sdk.list('sys_group_has_role', findParams);
133
+ if (assignmentRecords.length === 0) {
134
+ throw new Error(`Role ${roleName} is not assigned to group ${groupName}`);
135
+ }
136
+ const assignmentSysID = getStringField(assignmentRecords[0], 'sys_id');
137
+
138
+ // Delete the role assignment
139
+ await app.sdk.delete('sys_group_has_role', assignmentSysID);
140
+ app.ok({ group: groupName, role: roleName }, {
141
+ summary: `Removed role ${roleName} from ${groupName}`,
142
+ breadcrumbs: [
143
+ { action: 'list', cmd: `jsn grouproles list --query "group.name=${groupName}"`, description: 'List group roles' },
144
+ { action: 'add', cmd: `jsn grouproles add --group "${groupName}" --role "${roleName}"`, description: 'Add role back to group' },
145
+ ],
146
+ });
147
+ }),
148
+ });
36
149
  },
37
150
  handler: () => {},
38
151
  };
@@ -1,10 +1,10 @@
1
- import { formatRecordForDisplay } from '../helpers.js';
1
+ import { formatRecordForDisplay, getStringField, interactiveList } from '../helpers.js';
2
2
 
3
3
  export function groupsCmd(wrap) {
4
4
  return {
5
5
  command: 'groups [subcommand]',
6
6
  aliases: ['group'],
7
- describe: 'Search and display groups',
7
+ describe: 'Manage ServiceNow groups',
8
8
  builder: (yargs) => {
9
9
  return yargs
10
10
  .command({
@@ -17,6 +17,17 @@ export function groupsCmd(wrap) {
17
17
  .option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Max records' }),
18
18
  handler: wrap(async (argv, app) => {
19
19
  const columns = argv.columns ? argv.columns.split(',') : ['name', 'manager', 'email'];
20
+ const query = argv.query || '';
21
+
22
+ const picked = await interactiveList({
23
+ app, table: 'sys_user_group', singular: 'group', columns, limit: argv.limit, query, labelField: 'name',
24
+ formatLabel: r => `${getStringField(r, 'name')} ${getStringField(r, 'manager') ? '→ ' + getStringField(r, 'manager') : ''}`,
25
+ });
26
+ if (picked) {
27
+ picked._context = { instance_url: app.getEffectiveInstance(), table: 'sys_user_group' };
28
+ return app.ok(picked, { summary: `Group: ${getStringField(picked, 'name')}` });
29
+ }
30
+
20
31
  const params = new URLSearchParams();
21
32
  params.set('sysparm_limit', String(argv.limit));
22
33
  params.set('sysparm_display_value', 'all');
@@ -50,7 +61,80 @@ export function groupsCmd(wrap) {
50
61
  app.ok(records[0], { summary: `Group ${id}` });
51
62
  }),
52
63
  })
53
-
64
+ .command({
65
+ command: 'create',
66
+ describe: 'Create a new group',
67
+ builder: (y) => y
68
+ .option('name', { alias: 'n', type: 'string', describe: 'Group name' })
69
+ .option('manager', { type: 'string', describe: 'Manager name or sys_id' })
70
+ .option('data', { type: 'string', describe: 'JSON data for additional fields' }),
71
+ handler: wrap(async (argv, app) => {
72
+ const recordData = {};
73
+ if (argv.data) {
74
+ Object.assign(recordData, JSON.parse(argv.data));
75
+ }
76
+ if (argv.name) recordData.name = argv.name;
77
+ if (argv.manager) recordData.manager = argv.manager;
78
+ if (!recordData.name) {
79
+ throw new Error('name is required (use --name or --data)');
80
+ }
81
+ const record = await app.sdk.create('sys_user_group', recordData);
82
+ app.ok(record, {
83
+ summary: `Created group ${getStringField(record, 'name')}`,
84
+ breadcrumbs: [
85
+ { action: 'view', cmd: `jsn groups show ${getStringField(record, 'name')}`, description: `View the new group` },
86
+ ],
87
+ });
88
+ }),
89
+ })
90
+ .command({
91
+ command: 'update <name>',
92
+ describe: 'Update a group by name or sys_id',
93
+ builder: (y) => y
94
+ .option('data', { type: 'string', demandOption: true, describe: 'JSON data to update' }),
95
+ handler: wrap(async (argv, app) => {
96
+ const id = argv.name;
97
+ const recordData = JSON.parse(argv.data);
98
+ const isSysID = id.length === 32 && /^[0-9a-fA-F]+$/.test(id);
99
+ const params = new URLSearchParams();
100
+ params.set('sysparm_query', isSysID ? `sys_id=${id}` : `name=${id}`);
101
+ params.set('sysparm_limit', '1');
102
+ params.set('sysparm_fields', 'sys_id');
103
+ const records = await app.sdk.list('sys_user_group', params);
104
+ if (records.length === 0) {
105
+ throw new Error(`Group not found: ${id}`);
106
+ }
107
+ const sysID = getStringField(records[0], 'sys_id');
108
+ const updated = await app.sdk.update('sys_user_group', sysID, recordData);
109
+ app.ok(updated, {
110
+ summary: `Updated group ${id}`,
111
+ breadcrumbs: [
112
+ { action: 'view', cmd: `jsn groups show ${id}`, description: `View the updated group` },
113
+ ],
114
+ });
115
+ }),
116
+ })
117
+ .command({
118
+ command: 'delete <name>',
119
+ describe: 'Delete a group by name or sys_id',
120
+ handler: wrap(async (argv, app) => {
121
+ const id = argv.name;
122
+ const isSysID = id.length === 32 && /^[0-9a-fA-F]+$/.test(id);
123
+ const params = new URLSearchParams();
124
+ params.set('sysparm_query', isSysID ? `sys_id=${id}` : `name=${id}`);
125
+ params.set('sysparm_limit', '1');
126
+ params.set('sysparm_fields', 'sys_id');
127
+ const records = await app.sdk.list('sys_user_group', params);
128
+ if (records.length === 0) {
129
+ throw new Error(`Group not found: ${id}`);
130
+ }
131
+ const sysID = getStringField(records[0], 'sys_id');
132
+ await app.sdk.delete('sys_user_group', sysID);
133
+ app.ok({ identifier: id, message: 'Group deleted' }, {
134
+ summary: `Deleted group ${id}`,
135
+ });
136
+ }),
137
+ });
54
138
  },
55
139
  handler: () => {},
56
140
  };
@@ -2,7 +2,7 @@ import { saveConfig } from '../config.js';
2
2
 
3
3
  export function profilesCmd(wrap) {
4
4
  return {
5
- command: 'profiles <subcommand>',
5
+ command: 'profiles [subcommand]',
6
6
  aliases: ['profile'],
7
7
  describe: 'Manage configuration profiles',
8
8
  builder: (yargs) => {
@@ -74,6 +74,18 @@ export function profilesCmd(wrap) {
74
74
  })
75
75
 
76
76
  },
77
- handler: () => {},
77
+ handler: (argv) => {
78
+ // When no subcommand is given, show help
79
+ if (!argv._[1]) {
80
+ console.log('Manage your ServiceNow instance profiles.\n');
81
+ console.log('Usage: jsn profiles <list|show|use|remove> [options]\n');
82
+ console.log('Commands:');
83
+ console.log(' list List all profiles');
84
+ console.log(' show [name] Show profile details');
85
+ console.log(' use <name> Switch to a different profile');
86
+ console.log(' remove <name> Remove a profile');
87
+ console.log('\nRun "jsn profiles <command> --help" for more details.');
88
+ }
89
+ },
78
90
  };
79
91
  }
@@ -1,4 +1,4 @@
1
- import { formatRecordForDisplay, buildQuerySuffix } from '../helpers.js';
1
+ import { formatRecordForDisplay, buildQuerySuffix, parseDataArg, getStringField, interactiveList } from '../helpers.js';
2
2
 
3
3
  const tableDefaultColumns = {
4
4
  incident: ['number', 'short_description', 'priority', 'state', 'assigned_to'],
@@ -22,7 +22,7 @@ function getDefaultColumns(table) {
22
22
  export function recordsCmd(wrap) {
23
23
  return {
24
24
  command: 'records <subcommand>',
25
- describe: 'Query and manage records in any table',
25
+ describe: 'Query and manage records in any table (e.g. "records list --table incident --query priority=1")',
26
26
  builder: (yargs) => {
27
27
  return yargs
28
28
  .command({
@@ -37,6 +37,22 @@ export function recordsCmd(wrap) {
37
37
  handler: wrap(async (argv, app) => {
38
38
  const table = argv.table;
39
39
  const columns = argv.columns ? argv.columns.split(',') : getDefaultColumns(table);
40
+ const query = argv.query || '';
41
+
42
+ // Interactive picker
43
+ const picked = await interactiveList({
44
+ app, table, singular: 'record', columns, limit: argv.limit, query, labelField: 'sys_id',
45
+ formatLabel: r => {
46
+ const cols = getDefaultColumns(table);
47
+ return cols.map(c => `${c}: ${getStringField(r, c) || '-'}`).join(' | ');
48
+ },
49
+ });
50
+ if (picked) {
51
+ picked._context = { instance_url: app.getEffectiveInstance(), table };
52
+ return app.ok(picked, { summary: `Record from ${table}` });
53
+ }
54
+
55
+ // Text/table fallback
40
56
  const params = new URLSearchParams();
41
57
  params.set('sysparm_limit', String(argv.limit));
42
58
  params.set('sysparm_offset', String(argv.offset));
@@ -99,9 +115,10 @@ export function recordsCmd(wrap) {
99
115
  describe: 'Create a new record',
100
116
  builder: (y) => y
101
117
  .option('table', { type: 'string', demandOption: true, describe: 'Table name' })
102
- .option('data', { type: 'string', demandOption: true, describe: 'JSON fields (e.g. \'{"state":"2"}\')' }),
118
+ .option('data', { type: 'string', describe: 'JSON fields (e.g. \'{"state":"2"}\')' })
119
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' }),
103
120
  handler: wrap(async (argv, app) => {
104
- const recordData = JSON.parse(argv.data);
121
+ const recordData = parseDataArg(argv);
105
122
  const record = await app.sdk.create(argv.table, recordData);
106
123
  app.ok(record, { summary: `Created record in ${argv.table}` });
107
124
  }),
@@ -112,9 +129,10 @@ export function recordsCmd(wrap) {
112
129
  builder: (y) => y
113
130
  .option('table', { type: 'string', demandOption: true, describe: 'Table name' })
114
131
  .option('sys-id', { type: 'string', demandOption: true, describe: 'Record sys_id' })
115
- .option('data', { type: 'string', demandOption: true, describe: 'JSON fields (e.g. \'{"state":"2"}\')' }),
132
+ .option('data', { type: 'string', describe: 'JSON fields (e.g. \'{"state":"2"}\')' })
133
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' }),
116
134
  handler: wrap(async (argv, app) => {
117
- const recordData = JSON.parse(argv.data);
135
+ const recordData = parseDataArg(argv);
118
136
  const record = await app.sdk.update(argv.table, argv['sys-id'], recordData);
119
137
  app.ok(record, { summary: `Updated record in ${argv.table}` });
120
138
  }),
@@ -1,7 +1,59 @@
1
1
  import { buildTicketCommands } from './_ticket.js';
2
+ import { getStringField } from '../helpers.js';
2
3
 
3
4
  const requestDefaultColumns = ['number', 'short_description', 'stage', 'assigned_to'];
4
5
 
5
6
  export function requestsCmd(wrap) {
6
- return buildTicketCommands('sc_req_item', 'requests', 'req', requestDefaultColumns, {}, null, wrap);
7
+ // Get the base command definition from the shared ticket builder
8
+ const baseCmd = buildTicketCommands('sc_req_item', 'requests', 'req', requestDefaultColumns, {}, null, wrap);
9
+
10
+ // Override the builder to inject our enriched show handler.
11
+ // yargs allows re-registration — the last .command() call wins the slot.
12
+ const originalBuilder = baseCmd.builder;
13
+ baseCmd.builder = (yargs) => {
14
+ const y = originalBuilder(yargs);
15
+ return y.command({
16
+ command: 'show <number>',
17
+ aliases: ['get'],
18
+ describe: 'Show a specific request with attachments and catalog variables',
19
+ handler: wrap(enrichedShowHandler),
20
+ });
21
+ };
22
+
23
+ return baseCmd;
24
+ }
25
+
26
+ async function enrichedShowHandler(argv, app) {
27
+ const number = argv.number;
28
+ const params = new URLSearchParams();
29
+ params.set('sysparm_query', `number=${number}`);
30
+ params.set('sysparm_display_value', 'all');
31
+ params.set('sysparm_limit', '1');
32
+ const records = await app.sdk.list('sc_req_item', params);
33
+ if (records.length === 0) {
34
+ throw new Error(`Request not found: ${number}`);
35
+ }
36
+ const record = records[0];
37
+ const sysID = getStringField(record, 'sys_id');
38
+
39
+ // Fetch attachments and catalog variables in parallel
40
+ const [attachments, variables] = await Promise.all([
41
+ app.sdk.fetchAttachments('sc_req_item', sysID).catch(() => []),
42
+ app.sdk.fetchCatalogVariables(sysID).catch(() => []),
43
+ ]);
44
+
45
+ record._attachments = attachments;
46
+ record._variables = variables;
47
+ record._context = {
48
+ instance_url: app.getEffectiveInstance(),
49
+ table: 'sc_req_item',
50
+ };
51
+
52
+ app.ok(record, {
53
+ summary: `Request ${number}`,
54
+ breadcrumbs: [
55
+ { action: 'update', cmd: `jsn req update ${number} --data '{...}'`, description: 'Update this request' },
56
+ { action: 'list', cmd: `jsn req list`, description: 'Back to all requests' },
57
+ ],
58
+ });
7
59
  }
@@ -0,0 +1,109 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const SKILL_REPO_PATH = path.join(
7
+ path.dirname(fileURLToPath(import.meta.url)),
8
+ '..', '..', 'skills', 'servicenow', 'SKILL.md'
9
+ );
10
+
11
+ const SKILL_RAW_URL = 'https://raw.githubusercontent.com/jacebenson/jsn/nodejs/skills/servicenow/SKILL.md';
12
+
13
+ function readBundledSkill() {
14
+ try {
15
+ return fs.readFileSync(SKILL_REPO_PATH, 'utf-8');
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ export function skillCmd(wrap) {
22
+ return {
23
+ command: 'skill',
24
+ describe: 'Manage the jsn AI agent skill file (for Hermes, Claude Code, Cursor, etc.)',
25
+ builder: (y) => {
26
+ return y
27
+ .command({
28
+ command: 'show',
29
+ describe: 'Show the bundled skill file content',
30
+ handler: wrap(async (_argv, app) => {
31
+ const content = readBundledSkill();
32
+ if (!content) {
33
+ throw new Error('Skill file not found in package (expected at skills/servicenow/SKILL.md)');
34
+ }
35
+ app.ok({
36
+ content,
37
+ bundled: SKILL_REPO_PATH,
38
+ }, {
39
+ summary: 'jsn AI agent skill file (bundled)',
40
+ });
41
+ }),
42
+ })
43
+ .command({
44
+ command: 'fetch',
45
+ describe: 'Download the latest skill file from GitHub to stdout',
46
+ handler: wrap(async (_argv, _app) => {
47
+ const res = await fetch(SKILL_RAW_URL);
48
+ if (!res.ok) throw new Error(`Failed to fetch skill: ${res.status} ${res.statusText}`);
49
+ const content = await res.text();
50
+ // Print raw content to stdout for pipe/save usage
51
+ process.stdout.write(content);
52
+ }),
53
+ })
54
+ .command({
55
+ command: 'path',
56
+ describe: 'Show skill file locations and install targets',
57
+ handler: wrap(async (_argv, app) => {
58
+ const hermesDir = path.join(os.homedir(), '.hermes', 'skills', 'servicenow');
59
+ const hermesPath = path.join(hermesDir, 'SKILL.md');
60
+ app.ok({
61
+ bundled: SKILL_REPO_PATH,
62
+ hermes: hermesPath,
63
+ raw_url: SKILL_RAW_URL,
64
+ }, {
65
+ summary: 'Skill file locations',
66
+ breadcrumbs: [
67
+ { action: 'view', cmd: 'jsn skill show', description: 'Show bundled skill content' },
68
+ { action: 'fetch', cmd: 'jsn skill fetch', description: 'Download latest from GitHub' },
69
+ { action: 'install', cmd: 'jsn skill install', description: 'Download + save to Hermes' },
70
+ ],
71
+ });
72
+ }),
73
+ })
74
+ .command({
75
+ command: 'install [dir]',
76
+ describe: 'Download and save the latest skill file',
77
+ builder: (y) => y
78
+ .positional('dir', {
79
+ type: 'string',
80
+ describe: 'Target directory (default: ~/.hermes/skills/servicenow/)',
81
+ }),
82
+ handler: wrap(async (argv, app) => {
83
+ const targetDir = argv.dir || path.join(os.homedir(), '.hermes', 'skills', 'servicenow');
84
+ const targetPath = path.join(targetDir, 'SKILL.md');
85
+
86
+ fs.mkdirSync(targetDir, { recursive: true });
87
+
88
+ const res = await fetch(SKILL_RAW_URL);
89
+ if (!res.ok) throw new Error(`Failed to fetch skill: ${res.status} ${res.statusText}`);
90
+ const content = await res.text();
91
+
92
+ fs.writeFileSync(targetPath, content, 'utf-8');
93
+
94
+ app.ok({
95
+ installed: targetPath,
96
+ from: SKILL_RAW_URL,
97
+ }, {
98
+ summary: `Skill installed to ${targetPath}`,
99
+ breadcrumbs: [
100
+ { action: 'verify', cmd: `head -30 ${targetPath}`, description: 'Verify the installed skill' },
101
+ { action: 'reinstall', cmd: 'jsn skill install', description: 'Re-download and reinstall' },
102
+ ],
103
+ });
104
+ }),
105
+ });
106
+ },
107
+ handler: () => {}, // Handled by subcommands
108
+ };
109
+ }
@@ -4,7 +4,7 @@ export function ticketsCmd(wrap) {
4
4
  return {
5
5
  command: 'tickets [subcommand]',
6
6
  aliases: ['ticket'],
7
- describe: 'Query generic tickets',
7
+ describe: 'Query generic tickets (e.g. "tickets list --query active=true")',
8
8
  builder: (yargs) => {
9
9
  return yargs
10
10
  .command({
@@ -1,10 +1,10 @@
1
- import { formatRecordForDisplay } from '../helpers.js';
1
+ import { formatRecordForDisplay, getStringField, interactiveList } from '../helpers.js';
2
2
 
3
3
  export function usersCmd(wrap) {
4
4
  return {
5
5
  command: 'users [subcommand]',
6
6
  aliases: ['user'],
7
- describe: 'Search and display users',
7
+ describe: 'Manage ServiceNow users',
8
8
  builder: (yargs) => {
9
9
  return yargs
10
10
  .command({
@@ -17,6 +17,17 @@ export function usersCmd(wrap) {
17
17
  .option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Max records' }),
18
18
  handler: wrap(async (argv, app) => {
19
19
  const columns = argv.columns ? argv.columns.split(',') : ['user_name', 'name', 'email', 'active'];
20
+ const query = argv.query || '';
21
+
22
+ const picked = await interactiveList({
23
+ app, table: 'sys_user', singular: 'user', columns, limit: argv.limit, query, labelField: 'user_name',
24
+ formatLabel: r => `${getStringField(r, 'user_name')} (${getStringField(r, 'name') || '-'})`,
25
+ });
26
+ if (picked) {
27
+ picked._context = { instance_url: app.getEffectiveInstance(), table: 'sys_user' };
28
+ return app.ok(picked, { summary: `User: ${getStringField(picked, 'user_name')}` });
29
+ }
30
+
20
31
  const params = new URLSearchParams();
21
32
  params.set('sysparm_limit', String(argv.limit));
22
33
  params.set('sysparm_display_value', 'all');
@@ -50,7 +61,83 @@ export function usersCmd(wrap) {
50
61
  app.ok(records[0], { summary: `User ${id}` });
51
62
  }),
52
63
  })
53
-
64
+ .command({
65
+ command: 'create',
66
+ describe: 'Create a new user',
67
+ builder: (y) => y
68
+ .option('username', { alias: 'u', type: 'string', describe: 'Username (e.g. john.doe)' })
69
+ .option('name', { alias: 'n', type: 'string', describe: 'Full name (e.g. John Doe)' })
70
+ .option('email', { alias: 'e', type: 'string', describe: 'Email address' })
71
+ .option('data', { type: 'string', describe: 'JSON data for additional fields' }),
72
+ handler: wrap(async (argv, app) => {
73
+ const recordData = {};
74
+ if (argv.data) {
75
+ Object.assign(recordData, JSON.parse(argv.data));
76
+ }
77
+ if (argv.username) recordData.user_name = argv.username;
78
+ if (argv.name) recordData.name = argv.name;
79
+ if (argv.email) recordData.email = argv.email;
80
+ if (!recordData.user_name) {
81
+ throw new Error('user_name is required (use --username or --data)');
82
+ }
83
+ const record = await app.sdk.create('sys_user', recordData);
84
+ app.ok(record, {
85
+ summary: `Created user ${getStringField(record, 'user_name')}`,
86
+ breadcrumbs: [
87
+ { action: 'view', cmd: `jsn users show ${getStringField(record, 'user_name')}`, description: `View the new user` },
88
+ ],
89
+ });
90
+ }),
91
+ })
92
+ .command({
93
+ command: 'update <identifier>',
94
+ describe: 'Update a user by user_name or sys_id',
95
+ builder: (y) => y
96
+ .option('data', { type: 'string', demandOption: true, describe: 'JSON data to update' }),
97
+ handler: wrap(async (argv, app) => {
98
+ const id = argv.identifier;
99
+ const recordData = JSON.parse(argv.data);
100
+ const isSysID = id.length === 32 && /^[0-9a-fA-F]+$/.test(id);
101
+ const params = new URLSearchParams();
102
+ params.set('sysparm_query', isSysID ? `sys_id=${id}` : `user_name=${id}`);
103
+ params.set('sysparm_limit', '1');
104
+ params.set('sysparm_display_value', 'all');
105
+ params.set('sysparm_fields', 'sys_id');
106
+ const records = await app.sdk.list('sys_user', params);
107
+ if (records.length === 0) {
108
+ throw new Error(`User not found: ${id}`);
109
+ }
110
+ const sysID = getStringField(records[0], 'sys_id');
111
+ const updated = await app.sdk.update('sys_user', sysID, recordData);
112
+ app.ok(updated, {
113
+ summary: `Updated user ${id}`,
114
+ breadcrumbs: [
115
+ { action: 'view', cmd: `jsn users show ${id}`, description: `View the updated user` },
116
+ ],
117
+ });
118
+ }),
119
+ })
120
+ .command({
121
+ command: 'delete <identifier>',
122
+ describe: 'Delete a user by user_name or sys_id',
123
+ handler: wrap(async (argv, app) => {
124
+ const id = argv.identifier;
125
+ const isSysID = id.length === 32 && /^[0-9a-fA-F]+$/.test(id);
126
+ const params = new URLSearchParams();
127
+ params.set('sysparm_query', isSysID ? `sys_id=${id}` : `user_name=${id}`);
128
+ params.set('sysparm_limit', '1');
129
+ params.set('sysparm_fields', 'sys_id');
130
+ const records = await app.sdk.list('sys_user', params);
131
+ if (records.length === 0) {
132
+ throw new Error(`User not found: ${id}`);
133
+ }
134
+ const sysID = getStringField(records[0], 'sys_id');
135
+ await app.sdk.delete('sys_user', sysID);
136
+ app.ok({ identifier: id, message: 'User deleted' }, {
137
+ summary: `Deleted user ${id}`,
138
+ });
139
+ }),
140
+ });
54
141
  },
55
142
  handler: () => {},
56
143
  };