@jacebenson/jsn 4.3.3 → 4.3.4-dev.5
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/README.md +8 -5
- package/bin/jsn.js +7 -0
- package/package.json +4 -6
- package/src/cli-adapter.js +251 -0
- package/src/cli.js +5 -7
- package/src/commands/docs/community.js +3 -3
- package/src/commands/docs/ingest.js +2 -2
- package/src/commands/docs/refresh.js +2 -2
- package/src/commands/setup.js +40 -12
- package/src/commands/skill.js +1 -1
- package/src/frontmatter.js +48 -0
- package/src/sdk.js +33 -45
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ Works standalone or with AI agents (Claude Code, OpenCode, Hermes, Cursor, Copil
|
|
|
10
10
|
npm install -g @jacebenson/jsn
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
Node.js
|
|
13
|
+
Node.js 22.5.0 or newer. macOS, Linux, Windows.
|
|
14
14
|
|
|
15
15
|
The install also copies an AI agent skill file to `~/.agents/skills/servicenow/SKILL.md`.
|
|
16
16
|
|
|
@@ -63,7 +63,7 @@ jsn rules list --query "collection=incident"
|
|
|
63
63
|
jsn updatesets set "My Feature"
|
|
64
64
|
|
|
65
65
|
# Generic table access
|
|
66
|
-
jsn records list --table incident --limit 50 --json | jq '.[].number'
|
|
66
|
+
jsn records list --table incident --limit 50 --json | jq '.data.records[].number'
|
|
67
67
|
jsn records create --table incident --data '{"short_description":"test"}'
|
|
68
68
|
jsn records list --table incident --limit 50 --get "data.records.0.number" # no jq needed
|
|
69
69
|
jsn records list --table incident --query "active=true" # totals included by default
|
|
@@ -145,12 +145,15 @@ jsn auth modify dev12345 # Toggle read-only / skip conf
|
|
|
145
145
|
|
|
146
146
|
## Authentication
|
|
147
147
|
|
|
148
|
-
OAuth 2.0 with PKCE. Credentials in `~/.config/servicenow/credentials
|
|
148
|
+
JSN supports OAuth 2.0 with PKCE, Basic Auth, and browser-session auth. Credentials are stored in the operating system's credential store, with a file fallback under `~/.config/servicenow/credentials/` when the keyring is unavailable.
|
|
149
149
|
|
|
150
150
|
```bash
|
|
151
151
|
jsn auth login https://dev12345.service-now.com
|
|
152
152
|
```
|
|
153
153
|
|
|
154
|
+
For Basic Auth, set `SN_USERNAME` and `SN_PASSWORD`, then run `jsn auth login --basic <instance>`.
|
|
155
|
+
For browser-session auth, run `jsn auth login --gck <instance>` and paste the request headers when prompted.
|
|
156
|
+
|
|
154
157
|
For CI/CD, set environment variables:
|
|
155
158
|
|
|
156
159
|
```bash
|
|
@@ -164,7 +167,7 @@ jsn incidents list
|
|
|
164
167
|
JSN stores durable local data under `~/.jsn/`. Documentation lives in
|
|
165
168
|
`~/.jsn/docs/`, including `docs.db`, the ServiceNowDocs source checkout, and
|
|
166
169
|
community markdown. Configuration stays under `~/.config/servicenow/` and
|
|
167
|
-
credentials
|
|
170
|
+
credentials use the operating system's credential store, with a file fallback under `~/.config/servicenow/credentials/`.
|
|
168
171
|
|
|
169
172
|
Existing installations migrate documentation from
|
|
170
173
|
`~/.cache/servicenow-cli/docs/` automatically the first time the docs data is
|
|
@@ -190,7 +193,7 @@ npm install
|
|
|
190
193
|
npm test
|
|
191
194
|
```
|
|
192
195
|
|
|
193
|
-
Releases
|
|
196
|
+
Releases are manual. After changes land on `main`, run `npm run release -- patch` (or `minor`, `major`) to test, bump the version, and push the commit and tag. When ready, publish to npm with `npm publish --access public`. Git tags mark releases; GitHub Release entries are not required.
|
|
194
197
|
|
|
195
198
|
## License
|
|
196
199
|
|
package/bin/jsn.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
// A pipeline consumer may exit before JSN finishes writing. Treat that
|
|
4
|
+
// expected closed-pipe condition as a clean CLI exit, not an unhandled error.
|
|
5
|
+
process.stdout.on('error', (err) => {
|
|
6
|
+
if (err.code === 'EPIPE') process.exit(0);
|
|
7
|
+
throw err;
|
|
8
|
+
});
|
|
9
|
+
|
|
3
10
|
// Suppress the node:sqlite experimental warning on Node 22.x.
|
|
4
11
|
// SQLite is stable in Node 24+. The patch must be applied before any
|
|
5
12
|
// module imports node:sqlite, so we use a dynamic import after patching.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jacebenson/jsn",
|
|
3
|
-
"version": "4.3.
|
|
3
|
+
"version": "4.3.4-dev.5",
|
|
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": {
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"automation"
|
|
24
24
|
],
|
|
25
25
|
"author": "Jace Benson",
|
|
26
|
+
"repository": "https://github.com/jacebenson/jsn",
|
|
26
27
|
"license": "MIT",
|
|
27
28
|
"engines": {
|
|
28
29
|
"node": ">=22.5.0"
|
|
@@ -31,14 +32,11 @@
|
|
|
31
32
|
"@inquirer/core": "^10.1.0",
|
|
32
33
|
"@inquirer/prompts": "^7.5.1",
|
|
33
34
|
"better-sqlite3": "^13.0.3",
|
|
34
|
-
"
|
|
35
|
-
"turndown": "^7.2.4"
|
|
36
|
-
"yargs": "^17.7.2"
|
|
35
|
+
"js-yaml": "^4.1.1",
|
|
36
|
+
"turndown": "^7.2.4"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
|
-
"@11ty/eleventy": "^3.1.6",
|
|
40
39
|
"@eslint/js": "^10.0.1",
|
|
41
|
-
"@inquirer/search": "^4.2.1",
|
|
42
40
|
"globals": "^17.6.0"
|
|
43
41
|
},
|
|
44
42
|
"files": [
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
|
|
4
|
+
const camel = (name) => name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
5
|
+
const names = (x) => typeof x === 'string' ? [x] : (x || []);
|
|
6
|
+
const commandName = (command) => command.trim().split(/\s+/)[0];
|
|
7
|
+
const positionSpec = (command) => [...command.matchAll(/([<[])([^>\]]+)[>\]]/g)].map((m) => ({ name: m[2], required: m[1] === '<' }));
|
|
8
|
+
|
|
9
|
+
class CommandNode {
|
|
10
|
+
constructor(definition) {
|
|
11
|
+
this.definition = typeof definition === 'string' ? { command: definition } : definition;
|
|
12
|
+
this.name = commandName(this.definition.command || '');
|
|
13
|
+
this.aliases = names(this.definition.aliases || this.definition.alias);
|
|
14
|
+
this.children = [];
|
|
15
|
+
this.options = new Map();
|
|
16
|
+
this.positionals = new Map();
|
|
17
|
+
this.middlewares = [];
|
|
18
|
+
this.built = false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
build() {
|
|
22
|
+
if (this.built) return;
|
|
23
|
+
this.built = true;
|
|
24
|
+
if (typeof this.definition.builder === 'function') this.definition.builder(this.api());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
api() {
|
|
28
|
+
const node = this;
|
|
29
|
+
return {
|
|
30
|
+
command(definition, builder, handler) {
|
|
31
|
+
node.children.push(new CommandNode(typeof definition === 'string' ? { command: definition, builder, handler } : definition));
|
|
32
|
+
return this;
|
|
33
|
+
},
|
|
34
|
+
option(name, config = {}) { node.options.set(name, { ...config, name }); return this; },
|
|
35
|
+
options(config = {}) { for (const [name, value] of Object.entries(config)) this.option(name, value); return this; },
|
|
36
|
+
positional(name, config = {}) { node.positionals.set(name, { ...config, name }); return this; },
|
|
37
|
+
demandCommand(count, message) { node.demand = { count, message }; return this; },
|
|
38
|
+
middleware(fn) { node.middlewares.push(...(Array.isArray(fn) ? fn : [fn])); return this; },
|
|
39
|
+
strict() { return this; },
|
|
40
|
+
check() { return this; },
|
|
41
|
+
usage(value) { node.usageText = value; return this; },
|
|
42
|
+
epilogue(value) { node.epilogueText = value; return this; },
|
|
43
|
+
help() { return this; },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class LocalCLI {
|
|
49
|
+
constructor(args = []) {
|
|
50
|
+
this.args = [...args];
|
|
51
|
+
this.root = new CommandNode({ command: '' });
|
|
52
|
+
this.root.build = () => {};
|
|
53
|
+
this.root.api = this.root.api.bind(this.root);
|
|
54
|
+
this.middlewares = [];
|
|
55
|
+
this.failHandler = null;
|
|
56
|
+
this.script = 'jsn';
|
|
57
|
+
this.completionCommand = '__completion';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
scriptName(value) { this.script = value; return this; }
|
|
61
|
+
usage(value) { this.root.usageText = value; return this; }
|
|
62
|
+
middleware(fn) { this.middlewares.push(...(Array.isArray(fn) ? fn : [fn])); return this; }
|
|
63
|
+
option(name, config) { this.root.options.set(name, { ...config, name }); return this; }
|
|
64
|
+
options(config) { for (const [name, value] of Object.entries(config)) this.option(name, value); return this; }
|
|
65
|
+
command(definition, builder, handler) {
|
|
66
|
+
const node = new CommandNode(typeof definition === 'string' ? { command: definition, builder, handler } : definition);
|
|
67
|
+
this.root.children.push(node);
|
|
68
|
+
return this;
|
|
69
|
+
}
|
|
70
|
+
demandCommand(count, message) { this.root.demand = { count, message }; return this; }
|
|
71
|
+
completion(name, _description, handler) { this.completionCommand = name; this.completionHandler = handler; return this; }
|
|
72
|
+
help() { this.helpEnabled = true; return this; }
|
|
73
|
+
version() { return this; }
|
|
74
|
+
strictCommands() { this.strictCmd = true; return this; }
|
|
75
|
+
strictOptions() { return this; }
|
|
76
|
+
fail(fn) { this.failHandler = fn; return this; }
|
|
77
|
+
getInternalMethods() {
|
|
78
|
+
const all = this.root.children;
|
|
79
|
+
return { getCommandInstance: () => ({
|
|
80
|
+
getCommands: () => all.flatMap((n) => [n.name, ...n.aliases]),
|
|
81
|
+
getCommandHandlers: () => Object.fromEntries(all.map((n) => [n.name, n.definition.handler])),
|
|
82
|
+
}) };
|
|
83
|
+
}
|
|
84
|
+
showCompletionScript() {
|
|
85
|
+
process.stdout.write(`###-begin-jsn-completions-###\n_jsn_yargs_completions() {\n local completions=$(jsn --get-yargs-completions "$@")\n COMPREPLY=( $(compgen -W "$completions" -- "\${COMP_WORDS[COMP_CWORD]}") )\n}\ncomplete -F _jsn_yargs_completions jsn\n###-end-jsn-completions-###\n`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
_lookup(list, value) { return list.find((n) => n.name === value || n.aliases.includes(value)); }
|
|
89
|
+
_knownOptions(nodes) {
|
|
90
|
+
const map = new Map();
|
|
91
|
+
for (const node of [this.root, ...nodes]) for (const [name, spec] of node.options) {
|
|
92
|
+
map.set(name, spec); for (const alias of names(spec.alias)) map.set(alias, spec);
|
|
93
|
+
}
|
|
94
|
+
return map;
|
|
95
|
+
}
|
|
96
|
+
_findPath(args) {
|
|
97
|
+
const path = [], nodes = [this.root], consumed = new Set(), discoveryConsumed = new Set(); let i = 0;
|
|
98
|
+
while (i < args.length) {
|
|
99
|
+
const token = args[i];
|
|
100
|
+
if (token === '--') break;
|
|
101
|
+
if (token.startsWith('-')) {
|
|
102
|
+
const key = token.replace(/^-+/, '').split('=')[0];
|
|
103
|
+
const spec = this._knownOptions(path).get(key);
|
|
104
|
+
if (!token.includes('=') && (!spec || spec.type !== 'boolean') && args[i + 1] && !args[i + 1].startsWith('-')) {
|
|
105
|
+
discoveryConsumed.add(i + 1);
|
|
106
|
+
i++;
|
|
107
|
+
}
|
|
108
|
+
discoveryConsumed.add(i);
|
|
109
|
+
i++; continue;
|
|
110
|
+
}
|
|
111
|
+
const node = this._lookup(nodes[nodes.length - 1].children, token);
|
|
112
|
+
if (!node) break;
|
|
113
|
+
consumed.add(i);
|
|
114
|
+
path.push(node); nodes.push(node); node.build(); i++;
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
path,
|
|
118
|
+
commandArgs: args.filter((_, index) => !consumed.has(index)),
|
|
119
|
+
discoveryConsumed: new Set([...consumed, ...discoveryConsumed]),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
_optionConfig(path) { return this._knownOptions(path); }
|
|
123
|
+
_specFor(options, key, value) {
|
|
124
|
+
return options.get(key) || [...options.values()].find((spec) => spec.name === key || names(spec.alias).includes(key))
|
|
125
|
+
|| { name: key, type: typeof value === 'boolean' ? 'boolean' : 'string' };
|
|
126
|
+
}
|
|
127
|
+
_assign(argv, key, value, spec) {
|
|
128
|
+
const actual = spec?.name || key;
|
|
129
|
+
const values = spec?.type === 'array' ? (Array.isArray(value) ? value : [value]) : [value];
|
|
130
|
+
if (spec?.choices && values.some((item) => !spec.choices.includes(item))) {
|
|
131
|
+
this._fail(`Invalid value for --${actual}: ${value}. Choose from: ${spec.choices.join(', ')}`);
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
if (spec?.type === 'number' && values.some((item) => item === '' || !Number.isFinite(Number(item)))) {
|
|
135
|
+
this._fail(`Invalid number for --${actual}: ${value}`);
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
const converted = spec?.type === 'boolean'
|
|
139
|
+
? (value === 'true' ? true : value === 'false' ? false : value)
|
|
140
|
+
: spec?.type === 'number' ? Number(value) : value;
|
|
141
|
+
if (spec?.type === 'array') argv[actual] = [...(argv[actual] || []), ...values];
|
|
142
|
+
else argv[actual] = converted;
|
|
143
|
+
argv[camel(actual)] = argv[actual];
|
|
144
|
+
for (const alias of names(spec?.alias)) argv[alias] = argv[actual];
|
|
145
|
+
}
|
|
146
|
+
_parseOptions(args, path) {
|
|
147
|
+
const options = this._optionConfig(path);
|
|
148
|
+
const defs = {};
|
|
149
|
+
for (const [key, spec] of options) defs[key] = {
|
|
150
|
+
type: spec.type === 'number' || spec.type === 'array' ? 'string' : (spec.type || 'boolean'),
|
|
151
|
+
multiple: spec.type === 'array',
|
|
152
|
+
};
|
|
153
|
+
args = args.map((arg) => {
|
|
154
|
+
if (/^-[^-]=/.test(arg)) return `--${arg.slice(1)}`;
|
|
155
|
+
if (!arg.startsWith('--no-')) return arg;
|
|
156
|
+
const [key, rawValue] = arg.slice(5).split('=', 2);
|
|
157
|
+
const spec = this._specFor(options, key);
|
|
158
|
+
if (spec.type !== 'boolean') return arg;
|
|
159
|
+
const value = rawValue === undefined ? 'false' : rawValue === 'false' ? 'true' : 'false';
|
|
160
|
+
return `--${key}=${value}`;
|
|
161
|
+
});
|
|
162
|
+
for (const [key, spec] of options) for (const alias of names(spec.alias)) if (!defs[alias]) defs[alias] = defs[key];
|
|
163
|
+
let parsed;
|
|
164
|
+
try { parsed = parseArgs({ args, options: defs, allowPositionals: true, strict: false, tokens: true }); }
|
|
165
|
+
catch (err) { this._fail(err.message, err); return { values: {}, positionals: [] }; }
|
|
166
|
+
const argv = { _: path.map((n) => n.name) };
|
|
167
|
+
for (const [key, value] of Object.entries(parsed.values)) {
|
|
168
|
+
const spec = this._specFor(options, key, value);
|
|
169
|
+
if (this._assign(argv, key, value, spec) === false) return null;
|
|
170
|
+
}
|
|
171
|
+
for (const [key, spec] of options) if (argv[spec.name] === undefined && spec.default !== undefined) this._assign(argv, key, spec.default, spec);
|
|
172
|
+
return { values: argv, positionals: parsed.positionals };
|
|
173
|
+
}
|
|
174
|
+
_fail(message, error) {
|
|
175
|
+
if (this.failHandler) return this.failHandler(message, error);
|
|
176
|
+
throw error || new Error(message);
|
|
177
|
+
}
|
|
178
|
+
_help(node, path = []) {
|
|
179
|
+
const lines = [node.usageText || `Usage: ${this.script} ${path.join(' ') || '<command>'}`];
|
|
180
|
+
if (node.definition.describe) lines.push(`\n${node.definition.describe}`);
|
|
181
|
+
if (node.children.length) lines.push('\nCommands:\n' + node.children.filter((n) => n.name !== this.completionCommand).map((n) => ` ${n.definition.command} ${n.definition.describe || ''}`).join('\n'));
|
|
182
|
+
if (node.options.size) lines.push('\nOptions:\n' + [...node.options].map(([name, s]) => ` --${name}${s.alias ? `, -${names(s.alias).join(', -')}` : ''} ${s.describe || s.description || ''}`).join('\n'));
|
|
183
|
+
if (node.epilogueText) lines.push(`\n${node.epilogueText}`);
|
|
184
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
185
|
+
}
|
|
186
|
+
async parse() {
|
|
187
|
+
if (this.args.includes('--get-yargs-completions')) {
|
|
188
|
+
const prefix = this.args.at(-1) || '';
|
|
189
|
+
let completionArgs = this.args.slice(this.args.indexOf('--get-yargs-completions') + 1, -1);
|
|
190
|
+
if (completionArgs[0] === this.script) completionArgs = completionArgs.slice(1);
|
|
191
|
+
const { path } = this._findPath(completionArgs);
|
|
192
|
+
const node = path.at(-1) || this.root;
|
|
193
|
+
node.build();
|
|
194
|
+
const argv = { _: path.map((n) => n.name) };
|
|
195
|
+
const completionFilter = (callback) => {
|
|
196
|
+
const values = [...node.children].filter((n) => n.name !== this.completionCommand)
|
|
197
|
+
.flatMap((n) => path.length ? [n.name] : [n.name, ...n.aliases]);
|
|
198
|
+
const opts = [...this._optionConfig(path)].map(([name]) => `--${name}`);
|
|
199
|
+
callback(null, [...new Set([...values, ...opts])]);
|
|
200
|
+
};
|
|
201
|
+
const finish = (values) => process.stdout.write([...new Set(values || [])]
|
|
202
|
+
.filter((x) => x.startsWith(prefix) || prefix.startsWith('-') && x.startsWith(prefix)).join('\n') + '\n');
|
|
203
|
+
if (this.completionHandler) {
|
|
204
|
+
this.completionHandler(prefix, argv, completionFilter, finish);
|
|
205
|
+
} else {
|
|
206
|
+
completionFilter((err, values) => finish(values));
|
|
207
|
+
}
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (this.args[0] === 'help') {
|
|
211
|
+
const target = this._findPath(this.args.slice(1));
|
|
212
|
+
if (this.args.length === 1 || target.path.length) {
|
|
213
|
+
const node = target.path.at(-1) || this.root;
|
|
214
|
+
node.build();
|
|
215
|
+
this._help(node, target.path.map((n) => n.name));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const { path, commandArgs, discoveryConsumed } = this._findPath(this.args);
|
|
220
|
+
const node = path.at(-1) || this.root;
|
|
221
|
+
node.build();
|
|
222
|
+
if (commandArgs.includes('--help') || commandArgs.includes('-h')) { this._help(node, path.map((n) => n.name)); return; }
|
|
223
|
+
const firstWord = this.args.find((arg, index) => !discoveryConsumed.has(index) && !arg.startsWith('-'));
|
|
224
|
+
if (this.strictCmd && !path.length && firstWord && !this._lookup(this.root.children, firstWord)) {
|
|
225
|
+
return this._fail(`Unknown command: ${firstWord}`);
|
|
226
|
+
}
|
|
227
|
+
if (this.strictCmd && path.length && node.children.length && firstWord
|
|
228
|
+
&& !this._lookup(node.children, firstWord)) {
|
|
229
|
+
return this._fail(`Unknown command: ${firstWord}`);
|
|
230
|
+
}
|
|
231
|
+
if (!path.length && this.root.demand) return this._fail(this.root.demand.message);
|
|
232
|
+
const parsed = this._parseOptions(commandArgs, path);
|
|
233
|
+
if (!parsed) return;
|
|
234
|
+
const argv = parsed.values;
|
|
235
|
+
const specs = [...this._optionConfig(path).values()];
|
|
236
|
+
const positions = positionSpec(node.definition.command || '').map((position) => ({
|
|
237
|
+
...position, ...node.positionals.get(position.name), name: position.name,
|
|
238
|
+
}));
|
|
239
|
+
parsed.positionals.forEach((value, i) => { if (positions[i]) argv[positions[i].name] = value; });
|
|
240
|
+
for (const spec of specs) if (spec.demandOption && argv[spec.name] === undefined) return this._fail(`Missing required argument: --${spec.name}`);
|
|
241
|
+
for (const pos of positions) if (pos.required && argv[pos.name] === undefined) return this._fail(`Missing required argument: ${pos.name}`);
|
|
242
|
+
for (const middleware of this.middlewares) await middleware(argv);
|
|
243
|
+
for (const commandNode of path) for (const middleware of commandNode.middlewares) await middleware(argv);
|
|
244
|
+
if (node.definition.handler) await node.definition.handler(argv);
|
|
245
|
+
else if (node.demand?.count && !path.length) return this._fail(node.demand.message);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function yargs(args = []) { return new LocalCLI(args); }
|
|
250
|
+
export function hideBin(argv) { return argv.slice(2); }
|
|
251
|
+
export default yargs;
|
package/src/cli.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
// Root CLI using yargs
|
|
1
|
+
// Root CLI using the local yargs-compatible adapter
|
|
2
2
|
|
|
3
|
-
import yargs from '
|
|
4
|
-
import { hideBin } from 'yargs/helpers';
|
|
3
|
+
import yargs, { hideBin } from './cli-adapter.js';
|
|
5
4
|
import process from 'node:process';
|
|
6
5
|
import path from 'node:path';
|
|
7
6
|
import fs from 'node:fs';
|
|
@@ -387,14 +386,13 @@ export function buildCLI() {
|
|
|
387
386
|
// 2. When the current word is an exact ALIAS (e.g. "inc" for incidents),
|
|
388
387
|
// yargs descends into that command's subcommands, so a still-being-
|
|
389
388
|
// typed "jsn inc<TAB>" offers subcommands that can't match the prefix.
|
|
390
|
-
// When completing the FIRST word
|
|
391
|
-
//
|
|
392
|
-
// final list anyway.
|
|
389
|
+
// When completing the FIRST word, merge in the root command list — the
|
|
390
|
+
// shell's compgen prefix-filters the final list anyway.
|
|
393
391
|
.completion('__completion', false, (current, argv, completionFilter, done) => {
|
|
394
392
|
completionFilter((err, completions) => {
|
|
395
393
|
let out = completions || [];
|
|
396
394
|
const depth = (argv._ || []).length;
|
|
397
|
-
if (current && !current.startsWith('-') && depth
|
|
395
|
+
if (current && !current.startsWith('-') && depth === 0) {
|
|
398
396
|
out = [...new Set([...out, ...rootCommands])];
|
|
399
397
|
}
|
|
400
398
|
done([...new Set(out)]);
|
|
@@ -8,7 +8,7 @@ import fs from 'node:fs';
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import process from 'node:process';
|
|
10
10
|
import TurndownService from 'turndown';
|
|
11
|
-
import
|
|
11
|
+
import { parseFrontmatter, stringifyFrontmatter } from '../../frontmatter.js';
|
|
12
12
|
import Database from 'better-sqlite3';
|
|
13
13
|
import { getDocsCommunityDir, getDocsDbPath, docsDbExists } from './db.js';
|
|
14
14
|
import { refreshDocs } from './refresh.js';
|
|
@@ -166,7 +166,7 @@ export function slugify(title, fallback = 'untitled') {
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
function frontmatter(body, data) {
|
|
169
|
-
return
|
|
169
|
+
return stringifyFrontmatter(body, data);
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
// --- Write a community doc to disk ---
|
|
@@ -311,7 +311,7 @@ function buildUrlIndex() {
|
|
|
311
311
|
if (entry.isDirectory()) walk(full);
|
|
312
312
|
else if (entry.name.toLowerCase().endsWith('.md')) {
|
|
313
313
|
try {
|
|
314
|
-
const parsed =
|
|
314
|
+
const parsed = parseFrontmatter(fs.readFileSync(full, 'utf8'));
|
|
315
315
|
if (parsed.data.canonical_url) index.set(parsed.data.canonical_url, full);
|
|
316
316
|
} catch { /* ignore */ }
|
|
317
317
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
5
|
-
import
|
|
5
|
+
import { parseFrontmatter } from '../../frontmatter.js';
|
|
6
6
|
import { openDocsDb, closeDocsDb, initDocsSchema, getDocsDbPath, walkDocs, defaultDocsRoots } from './db.js';
|
|
7
7
|
import { encodeText, phasesToBytes, docSurface, DEFAULT_DIM } from './hrr.js';
|
|
8
8
|
|
|
@@ -35,7 +35,7 @@ export function ingestDocs(opts = {}) {
|
|
|
35
35
|
let data = {};
|
|
36
36
|
let body = raw;
|
|
37
37
|
try {
|
|
38
|
-
const parsed =
|
|
38
|
+
const parsed = parseFrontmatter(raw);
|
|
39
39
|
data = parsed.data || {};
|
|
40
40
|
body = parsed.content || '';
|
|
41
41
|
} catch {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
|
-
import
|
|
6
|
+
import { parseFrontmatter } from '../../frontmatter.js';
|
|
7
7
|
import { openDocsDb, closeDocsDb, getDocsDbPath, walkDocs, defaultDocsRoots } from './db.js';
|
|
8
8
|
import { encodeText, phasesToBytes, docSurface, DEFAULT_DIM } from './hrr.js';
|
|
9
9
|
|
|
@@ -72,7 +72,7 @@ export function refreshDocs(opts = {}) {
|
|
|
72
72
|
const stat = fs.statSync(file);
|
|
73
73
|
let data = {}, body = raw;
|
|
74
74
|
try {
|
|
75
|
-
const parsed =
|
|
75
|
+
const parsed = parseFrontmatter(raw);
|
|
76
76
|
data = parsed.data || {};
|
|
77
77
|
body = parsed.content || '';
|
|
78
78
|
} catch {
|
package/src/commands/setup.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createPrompt, useKeypress, usePagination, useState, isDownKey, isEnterKey, isUpKey } from '@inquirer/core';
|
|
1
2
|
import { loginWizard, modifyProfile, removeProfile, pickProfile } from './auth.js';
|
|
2
3
|
import { setActiveProfile } from '../config.js';
|
|
3
4
|
import { errUsage } from '../errors.js';
|
|
@@ -7,18 +8,44 @@ import { declareCapabilities } from '../capabilities.js';
|
|
|
7
8
|
// (Not in the daily-check skip-list: legacy behavior checks for updates here.)
|
|
8
9
|
declareCapabilities('setup', { noInstance: true });
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
11
|
+
const HUB_CHOICES = [
|
|
12
|
+
{ name: 'Add a new instance', value: 'add' },
|
|
13
|
+
{ name: 'Switch to a different instance', value: 'switch' },
|
|
14
|
+
{ name: 'Remove an instance', value: 'remove' },
|
|
15
|
+
{ name: 'Modify an instance', value: 'modify' },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
/** The setup hub prompt; q and Escape resolve with no selected action. */
|
|
19
|
+
export const authHubPrompt = createPrompt((config, done) => {
|
|
20
|
+
const [active, setActive] = useState(0);
|
|
21
|
+
const choices = config.choices;
|
|
22
|
+
|
|
23
|
+
useKeypress((key) => {
|
|
24
|
+
if (key.name === 'q' || key.name === 'escape') {
|
|
25
|
+
done(undefined);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (isEnterKey(key)) {
|
|
29
|
+
done(choices[active].value);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (isUpKey(key) && active > 0) setActive(active - 1);
|
|
33
|
+
if (isDownKey(key) && active < choices.length - 1) setActive(active + 1);
|
|
21
34
|
});
|
|
35
|
+
|
|
36
|
+
const page = usePagination({
|
|
37
|
+
items: choices,
|
|
38
|
+
active,
|
|
39
|
+
pageSize: choices.length,
|
|
40
|
+
loop: false,
|
|
41
|
+
renderItem: ({ item, isActive }) => `${isActive ? '❯' : ' '} ${item.name}`,
|
|
42
|
+
});
|
|
43
|
+
return `${config.message}\n${page}\n\n↑↓ navigate • ⏎ select • q/Esc exit`;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** Interactive hub: what do you want to do? Returns an action or undefined on exit. */
|
|
47
|
+
export async function authHubMenu({ promptFn = authHubPrompt, input, output } = {}) {
|
|
48
|
+
return promptFn({ message: 'What would you like to do?', choices: HUB_CHOICES }, { input, output });
|
|
22
49
|
}
|
|
23
50
|
|
|
24
51
|
/**
|
|
@@ -36,7 +63,7 @@ export async function dispatchSetupAction(app, argv, action) {
|
|
|
36
63
|
} else if (action === 'remove') {
|
|
37
64
|
const name = await pickProfile(app, 'Remove which instance?');
|
|
38
65
|
await removeProfile(app, name);
|
|
39
|
-
} else
|
|
66
|
+
} else if (action === 'modify') {
|
|
40
67
|
await modifyProfile(app, argv);
|
|
41
68
|
}
|
|
42
69
|
}
|
|
@@ -60,6 +87,7 @@ export function setupCmd(wrap) {
|
|
|
60
87
|
return;
|
|
61
88
|
}
|
|
62
89
|
const action = await authHubMenu();
|
|
90
|
+
if (action === undefined) return;
|
|
63
91
|
await dispatchSetupAction(app, argv, action);
|
|
64
92
|
}),
|
|
65
93
|
};
|
package/src/commands/skill.js
CHANGED
|
@@ -330,7 +330,7 @@ export function skillCmd(wrap) {
|
|
|
330
330
|
? `Where should the jsn skill be installed? (${outdated.length} outdated)`
|
|
331
331
|
: 'Where should the jsn skill be installed?';
|
|
332
332
|
|
|
333
|
-
const {
|
|
333
|
+
const { checkbox } = await import('@inquirer/prompts');
|
|
334
334
|
const choices = Object.entries(TARGET_NAMES).map(([key, label]) => {
|
|
335
335
|
const dir = AGENT_SKILL_DIRS[key];
|
|
336
336
|
const targetPath = key === 'cursor'
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import yaml from 'js-yaml';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_DELIMITERS = ['---', '---'];
|
|
4
|
+
|
|
5
|
+
function delimiters(options = {}) {
|
|
6
|
+
const value = options.delimiters || DEFAULT_DELIMITERS;
|
|
7
|
+
if (!Array.isArray(value) || value.length !== 2 || value.some((item) => typeof item !== 'string')) {
|
|
8
|
+
throw new TypeError('delimiters must be a two-item string array');
|
|
9
|
+
}
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Parse the YAML frontmatter format used by the docs commands. */
|
|
14
|
+
export function parseFrontmatter(input, options = {}) {
|
|
15
|
+
const source = String(input).replace(/^\uFEFF/, '');
|
|
16
|
+
const [open, close] = delimiters(options);
|
|
17
|
+
const empty = { data: {}, content: source };
|
|
18
|
+
|
|
19
|
+
if (!source.startsWith(open) || source.charAt(open.length) === open.slice(-1)) return empty;
|
|
20
|
+
|
|
21
|
+
let frontmatter = source.slice(open.length);
|
|
22
|
+
// gray-matter accepts an optional language suffix, which is only relevant
|
|
23
|
+
// here for the YAML spelling (`---yaml`).
|
|
24
|
+
if (frontmatter.startsWith('yaml\r\n') || frontmatter.startsWith('yaml\n')) {
|
|
25
|
+
frontmatter = frontmatter.slice(4);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const closeIndex = frontmatter.indexOf(`\n${close}`);
|
|
29
|
+
const raw = closeIndex === -1 ? frontmatter : frontmatter.slice(0, closeIndex);
|
|
30
|
+
const parsed = yaml.load(raw);
|
|
31
|
+
const data = parsed === undefined ? {} : parsed;
|
|
32
|
+
|
|
33
|
+
if (closeIndex === -1) return { data, content: '' };
|
|
34
|
+
|
|
35
|
+
let content = frontmatter.slice(closeIndex + close.length + 1);
|
|
36
|
+
if (content.startsWith('\r')) content = content.slice(1);
|
|
37
|
+
if (content.startsWith('\n')) content = content.slice(1);
|
|
38
|
+
return { data, content };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Serialize docs metadata and body using gray-matter-compatible YAML framing. */
|
|
42
|
+
export function stringifyFrontmatter(body, data, options = {}) {
|
|
43
|
+
const [open, close] = delimiters(options);
|
|
44
|
+
const yamlText = yaml.dump(data, options).trim();
|
|
45
|
+
const prefix = yamlText === '{}' ? '' : `${open}\n${yamlText}\n${close}\n`;
|
|
46
|
+
const content = String(body);
|
|
47
|
+
return prefix + (content.endsWith('\n') ? content : `${content}\n`);
|
|
48
|
+
}
|
package/src/sdk.js
CHANGED
|
@@ -75,7 +75,11 @@ export class SDKClient {
|
|
|
75
75
|
case 'oauth':
|
|
76
76
|
if (!skipOAuthSession) {
|
|
77
77
|
const cookies = await this._warmSession();
|
|
78
|
-
if (cookies
|
|
78
|
+
if (cookies && this.sessionUserToken) {
|
|
79
|
+
req.headers.set('Cookie', cookies);
|
|
80
|
+
req.headers.set('X-UserToken', this.sessionUserToken);
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
79
83
|
}
|
|
80
84
|
req.headers.set('Authorization', `Bearer ${creds.access_token}`);
|
|
81
85
|
break;
|
|
@@ -262,6 +266,8 @@ export class SDKClient {
|
|
|
262
266
|
|
|
263
267
|
/**
|
|
264
268
|
* Upload a file as a new attachment on a record.
|
|
269
|
+
* Uses ServiceNow's binary attachment endpoint so file bytes are not expanded
|
|
270
|
+
* into base64 or embedded in a sys.scripts.do form body.
|
|
265
271
|
* @param {string} table - parent table name (e.g. 'incident')
|
|
266
272
|
* @param {string} sysID - parent record sys_id
|
|
267
273
|
* @param {Buffer|string} content - file bytes or path
|
|
@@ -270,29 +276,30 @@ export class SDKClient {
|
|
|
270
276
|
*/
|
|
271
277
|
async addAttachment(table, sysID, content, fileName) {
|
|
272
278
|
const contentBuffer = Buffer.isBuffer(content) ? content : Buffer.from(String(content));
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
279
|
+
const params = new URLSearchParams({
|
|
280
|
+
table_name: String(table),
|
|
281
|
+
table_sys_id: String(sysID),
|
|
282
|
+
file_name: String(fileName),
|
|
283
|
+
});
|
|
284
|
+
const endpoint = `${this.baseURL}/api/now/attachment/file?${params.toString()}`;
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
const response = await this.request(endpoint, {
|
|
288
|
+
method: 'POST',
|
|
289
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
290
|
+
body: contentBuffer,
|
|
291
|
+
});
|
|
292
|
+
const result = response?.result;
|
|
293
|
+
if (!result?.sys_id) {
|
|
294
|
+
throw new Error('Attachment upload returned no attachment sys_id');
|
|
295
|
+
}
|
|
296
|
+
return result;
|
|
297
|
+
} catch (err) {
|
|
298
|
+
if (err?.status === 413) {
|
|
299
|
+
throw errAPI(413, 'Attachment is too large for the ServiceNow attachment endpoint. Reduce the file size or increase the instance attachment limit.');
|
|
300
|
+
}
|
|
301
|
+
throw err;
|
|
294
302
|
}
|
|
295
|
-
return result;
|
|
296
303
|
}
|
|
297
304
|
|
|
298
305
|
async getCurrentUser({ touchLastSeen = true } = {}) {
|
|
@@ -824,10 +831,12 @@ export class SDKClient {
|
|
|
824
831
|
method: 'GET',
|
|
825
832
|
headers: serializeCookies(cookieJar) ? { Cookie: serializeCookies(cookieJar) } : {},
|
|
826
833
|
}));
|
|
827
|
-
|
|
834
|
+
const finalLogin = await fetch(new Request(login, {
|
|
828
835
|
method: 'POST',
|
|
829
836
|
headers: serializeCookies(cookieJar) ? { Cookie: serializeCookies(cookieJar) } : {},
|
|
830
837
|
}));
|
|
838
|
+
collectSetCookies(cookieJar, finalLogin);
|
|
839
|
+
this.sessionUserToken = finalLogin.headers.get('x-usertoken-response') || '';
|
|
831
840
|
const cookies = serializeCookies(cookieJar);
|
|
832
841
|
this.oauthCookies = cookies;
|
|
833
842
|
return cookies;
|
|
@@ -915,27 +924,6 @@ export class SDKClient {
|
|
|
915
924
|
}
|
|
916
925
|
}
|
|
917
926
|
|
|
918
|
-
function scriptString(value) {
|
|
919
|
-
return JSON.stringify(String(value)).replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
function parseAttachmentResult(output) {
|
|
923
|
-
if (!output) return null;
|
|
924
|
-
const lines = String(output).split(/\r?\n/).reverse();
|
|
925
|
-
for (const line of lines) {
|
|
926
|
-
const candidate = line.replace(/^\*\*\* Script:\s*/, '').trim();
|
|
927
|
-
const markerCandidate = candidate.replace(/^JSN_ATTACHMENT_RESULT:\s*/, '');
|
|
928
|
-
if (!markerCandidate.startsWith('{')) continue;
|
|
929
|
-
try {
|
|
930
|
-
const result = JSON.parse(markerCandidate);
|
|
931
|
-
if (result && typeof result === 'object') return result;
|
|
932
|
-
} catch {
|
|
933
|
-
// Ignore unrelated script output and keep looking for the result marker.
|
|
934
|
-
}
|
|
935
|
-
}
|
|
936
|
-
return null;
|
|
937
|
-
}
|
|
938
|
-
|
|
939
927
|
function detectScriptError(output) {
|
|
940
928
|
const lines = String(output || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean);
|
|
941
929
|
const errorLine = lines.find(line => /(?:ReferenceError|TypeError|RhinoEcmaError|Script execution error|undefined is not a function|is not defined)/i.test(line));
|