@jacebenson/jsn 3.1.5 → 3.3.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.
package/bin/jsn.js CHANGED
@@ -1,5 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { cli } from '../src/cli.js';
3
+ // Suppress the node:sqlite experimental warning on Node 22.x.
4
+ // SQLite is stable in Node 24+. The patch must be applied before any
5
+ // module imports node:sqlite, so we use a dynamic import after patching.
6
+ const originalEmitWarning = process.emitWarning;
7
+ process.emitWarning = function emitWarning(warning, ...args) {
8
+ const message = typeof warning === 'string'
9
+ ? warning
10
+ : (warning?.message || '');
11
+ if (message.includes('SQLite')) {
12
+ return;
13
+ }
14
+ return originalEmitWarning.apply(this, [warning, ...args]);
15
+ };
4
16
 
5
- cli.parse();
17
+ import('../src/cli.js').then((m) => m.cli.parse());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "3.1.5",
3
+ "version": "3.3.0",
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": {
@@ -8,10 +8,10 @@
8
8
  },
9
9
  "main": "src/cli.js",
10
10
  "scripts": {
11
- "test": "node --test $(find test -name '*.test.js')",
11
+ "test": "NODE_OPTIONS=--require=./suppress-warnings.cjs node --test $(find test -name '*.test.js')",
12
12
  "lint": "npx eslint src/ bin/ test/",
13
13
  "postinstall": "node scripts/postinstall.mjs",
14
- "start": "node bin/jsn.js",
14
+ "start": "NODE_OPTIONS=--require=./suppress-warnings.cjs node bin/jsn.js",
15
15
  "tag-reminder": "node scripts/tag-reminder.js",
16
16
  "release": "node scripts/release.js"
17
17
  },
@@ -24,13 +24,14 @@
24
24
  "author": "Jace Benson",
25
25
  "license": "MIT",
26
26
  "engines": {
27
- "node": ">=18.0.0"
27
+ "node": ">=22.5.0"
28
28
  },
29
29
  "dependencies": {
30
30
  "@inquirer/core": "^10.1.0",
31
31
  "@inquirer/prompts": "^7.5.1",
32
32
  "chalk": "^5.4.1",
33
33
  "cli-table3": "^0.6.5",
34
+ "gray-matter": "^4.0.3",
34
35
  "yargs": "^17.7.2"
35
36
  },
36
37
  "devDependencies": {
@@ -40,6 +41,7 @@
40
41
  },
41
42
  "files": [
42
43
  "bin/jsn.js",
44
+ "suppress-warnings.cjs",
43
45
  "src/",
44
46
  "skills/",
45
47
  "scripts/",
@@ -54,9 +54,10 @@ Pick the most specific tool for the job. **Never default to eval** — it's the
54
54
 
55
55
  1. **Specific commands** — `jsn flows`, `jsn rules`, `jsn catalogitems`, etc.
56
56
  2. **`jsn records --table <name>`** — generic CRUD on any table
57
- 3. **`jsn rest`** — raw Table API escape hatch
58
- 4. **`jsn eval`** — ⚠️ **LAST RESORT ONLY**
59
- 5. **Ask the human** — if none of the above work
57
+ 3. **`jsn docs search <query>`** — offline ServiceNow documentation search (no instance needed)
58
+ 4. **`jsn rest`** — raw Table API escape hatch
59
+ 5. **`jsn eval`** — ⚠️ **LAST RESORT ONLY**
60
+ 6. **Ask the human** — if none of the above work
60
61
 
61
62
  ## Key Commands
62
63
 
@@ -69,6 +70,7 @@ Pick the most specific tool for the job. **Never default to eval** — it's the
69
70
  | **UX** | `forms` (section/element layout), `lists`, `clientscripts`, `uipolicies` |
70
71
  | **Data** | `tables`, `columns`, `includes`, `logs`, `properties`, `records` |
71
72
  | **Config** | `setup`, `auth`, `profiles`, `updatesets`, `scopes` |
73
+ | **Docs** | `docs sync`, `docs status`, `docs search`, `docs serve` |
72
74
  | **Developer** | `eval`, `rest`, `skill`, `version` |
73
75
 
74
76
  ### `jsn rest` examples
@@ -84,6 +86,32 @@ jsn rest --table incident --sys-id abc123... --json
84
86
  jsn rest "/api/now/table/incident?sysparm_limit=3" --json
85
87
  ```
86
88
 
89
+ ### `jsn docs` — offline documentation search
90
+
91
+ No ServiceNow instance required. Searches the full ServiceNow docs locally with FTS5 + semantic hybrid search.
92
+
93
+ ```bash
94
+ # First run: download docs and build the index (~45k files, ~3-5 min)
95
+ jsn docs sync
96
+
97
+ # Check state
98
+ jsn docs status
99
+
100
+ # Search (keyword + semantic hybrid)
101
+ jsn docs search "GlideRecord query" --limit 5 --json
102
+
103
+ # Filter by bundle
104
+ jsn docs search "REST API" --bundle it-service-management --json
105
+
106
+ # Start the web UI
107
+ jsn docs serve
108
+
109
+ # Share on your network
110
+ jsn docs serve --expose
111
+ ```
112
+
113
+ **Re-running `jsn docs sync`** does a smart incremental refresh — pulls latest docs and only rebuilds changed files (seconds, not minutes).
114
+
87
115
  ## Troubleshooting Field Behavior
88
116
 
89
117
  Check order (priority):
package/src/app.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import { AuthManager } from './auth.js';
4
4
  import { SDKClient } from './sdk.js';
5
5
  import { OutputWriter, FormatAuto, FormatJSON, FormatMarkdown, FormatQuiet, FormatStyled } from './output.js';
6
- import { getEffectiveInstance } from './config.js';
6
+ import { getEffectiveInstance, normalizeInstanceURL } from './config.js';
7
7
  import { extractProfileName } from './helpers.js';
8
8
  import { getCurrentUser, getCurrentApplication, getCurrentUpdateSet } from './context.js';
9
9
  import { errUsage, errAuth } from './errors.js';
@@ -55,6 +55,8 @@ export class App {
55
55
  }
56
56
 
57
57
  getEffectiveInstance() {
58
+ // Command-line override (--instance, --profile) takes precedence
59
+ if (this._overrideInstance) return this._overrideInstance;
58
60
  return getEffectiveInstance(this.config);
59
61
  }
60
62
 
@@ -191,6 +193,18 @@ export class App {
191
193
  throw errAuth('Not authenticated');
192
194
  }
193
195
  }
196
+
197
+ /**
198
+ * Override the effective instance for this command run.
199
+ * Rebuilds the SDK client for the new instance.
200
+ */
201
+ setEffectiveInstance(url) {
202
+ const normalizedUrl = normalizeInstanceURL(url);
203
+ if (normalizedUrl) {
204
+ this._overrideInstance = normalizedUrl;
205
+ this.sdk = new SDKClient(normalizedUrl, this.auth);
206
+ }
207
+ }
194
208
  }
195
209
 
196
210
  function resolveFormat(fmt) {
package/src/auth.js CHANGED
@@ -58,6 +58,24 @@ function removePKCEState(instance) {
58
58
  const DEFAULT_OAUTH_CLIENT_ID = '543e5655f77746a28228c6009a599dfb';
59
59
  const REDIRECT_URI = '/sdk-oauth.do';
60
60
 
61
+ // ─── WSL detection ───
62
+
63
+ /**
64
+ * Detect if running under Windows Subsystem for Linux.
65
+ * Reads /proc/version which contains "Microsoft" or "WSL" on WSL.
66
+ */
67
+ let _cachedIsWSL = null;
68
+ async function isWSLEnv() {
69
+ if (_cachedIsWSL !== null) return _cachedIsWSL;
70
+ try {
71
+ const data = fs.readFileSync('/proc/version', 'utf-8');
72
+ _cachedIsWSL = /microsoft|wsl/i.test(data);
73
+ } catch {
74
+ _cachedIsWSL = false;
75
+ }
76
+ return _cachedIsWSL;
77
+ }
78
+
61
79
  // ─── Basic auth from environment variables ───
62
80
  // SN_USERNAME / SN_PASSWORD — global credentials
63
81
  // SN_<INSTANCE>_USERNAME / SN_<INSTANCE>_PASSWORD — instance-specific (e.g. SN_DEV328604_USERNAME)
@@ -386,10 +404,23 @@ export class AuthManager {
386
404
  args = [authURL];
387
405
  } else if (platform === 'win32') {
388
406
  cmd = 'cmd';
389
- args = ['/c', 'start', authURL];
407
+ // Windows: cmd.exe interprets & as a command separator, which truncates
408
+ // the URL after the first parameter. Passing an empty string as the first
409
+ // arg to `start` serves as the window title, and spawn auto-quotes the
410
+ // URL arg, protecting & and other special chars from cmd.exe's parser.
411
+ args = ['/c', 'start', '', authURL];
390
412
  } else {
391
- cmd = 'xdg-open';
392
- args = [authURL];
413
+ // WSL detection: check if we're running under Windows Subsystem for Linux
414
+ // On WSL, xdg-open delegates to cmd.exe /C start <url>, which truncates
415
+ // on &. We call cmd.exe directly with proper quoting instead.
416
+ const isWSL = platform === 'linux' && (await isWSLEnv());
417
+ if (isWSL) {
418
+ cmd = 'cmd.exe';
419
+ args = ['/c', 'start', '', authURL];
420
+ } else {
421
+ cmd = 'xdg-open';
422
+ args = [authURL];
423
+ }
393
424
  }
394
425
  const child = open(cmd, args, { detached: true, stdio: 'ignore' });
395
426
  child.on('error', () => {
package/src/cli.js CHANGED
@@ -3,7 +3,9 @@
3
3
  import yargs from 'yargs';
4
4
  import { hideBin } from 'yargs/helpers';
5
5
  import process from 'node:process';
6
- import { loadConfig, getEffectiveInstance } from './config.js';
6
+ import path from 'node:path';
7
+ import fs from 'node:fs';
8
+ import { loadConfig, getEffectiveInstance, globalConfigDir } from './config.js';
7
9
  import { App } from './app.js';
8
10
  import { renderHelp } from './help.js';
9
11
  import { isMutationCommand } from './mutations.js';
@@ -26,6 +28,8 @@ import { ticketsCmd } from './commands/tickets.js';
26
28
  import { versionCmd } from './commands/version.js';
27
29
  import { devCmd } from './commands/dev.js';
28
30
  import { skillCmd, checkSkill } from './commands/skill.js';
31
+ import { getVersion, checkLatest } from './commands/version.js';
32
+ import { docsCmd } from './commands/docs/docs.js';
29
33
 
30
34
  // Dev subcommands promoted to root level for progressive disclosure
31
35
  import {
@@ -153,19 +157,35 @@ export function buildCLI() {
153
157
  if (argv.format) {
154
158
  app.output.setFormat(argv.format);
155
159
  }
160
+ // --profile: resolve profile name to its instance URL
161
+ // --instance wins if both are specified (explicit URL > profile reference)
162
+ if (argv.profile) {
163
+ const profile = (cfg.profiles || {})[argv.profile];
164
+ if (!profile) {
165
+ process.stderr.write(`Error: Unknown profile "${argv.profile}"\n`);
166
+ process.exit(1);
167
+ }
168
+ const url = profile.instance_url;
169
+ app._overrideInstance = url;
170
+ argv._overrideInstance = url;
171
+ // Temporarily switch active profile so auth picks up the right user
172
+ cfg.activeProfile = argv.profile;
173
+ }
156
174
  if (argv.instance) {
157
175
  app._overrideInstance = argv.instance;
176
+ argv._overrideInstance = argv.instance;
158
177
  }
159
178
  },
160
179
  // Guard mutation commands when no instance configured
161
180
  (argv) => {
162
181
  const cmd = (argv._[0] || '').toString();
163
- if (!cmd || ['help', 'version', 'completion', 'setup', 'auth', 'profiles', 'skill'].includes(cmd)) {
182
+ if (!cmd || ['help', 'version', 'completion', 'setup', 'auth', 'profiles', 'skill', 'docs'].includes(cmd)) {
164
183
  return;
165
184
  }
166
185
  // Check for --instance override
167
186
  if (argv.instance) {
168
187
  app._overrideInstance = argv.instance;
188
+ argv._overrideInstance = argv.instance;
169
189
  }
170
190
  // Determine if this is a mutation
171
191
  const subcommand = (argv._[1] || '').toString();
@@ -183,22 +203,49 @@ export function buildCLI() {
183
203
 
184
204
  const cmd = (argv._[0] || '').toString();
185
205
 
206
+ // Daily npm version check (fire-and-forget, non-blocking)
207
+ // Checks npm for newer jsn releases, at most once per 24 hours.
208
+ const skipNpmCheck = ['help', 'version', 'completion', 'skill', 'docs'].includes(cmd)
209
+ || process.env.JSN_NO_VERSION_CHECK === '1'
210
+ || argv.json
211
+ || argv.quiet;
212
+
186
213
  // Auto-check skill on every command (fire-and-forget, non-blocking)
187
- const skipSkillCheck = ['help', 'version', 'completion', 'skill'].includes(cmd)
214
+ const skipSkillCheck = ['help', 'version', 'completion', 'skill', 'docs'].includes(cmd)
188
215
  || process.env.JSN_NO_SKILL_CHECK === '1'
189
216
  || argv['no-skill-check']
190
- || argv.json
191
- || argv.quiet;
192
- if (!skipSkillCheck) {
193
- checkSkill().then(result => {
194
- if (result && !result.current && result.error) {
195
- process.stderr.write(`\n⚠ ${result.error}\n\n`);
217
+ if (!skipNpmCheck) {
218
+ const cacheFile = path.join(globalConfigDir(), '.last-npm-check');
219
+ let shouldCheck = true;
220
+ try {
221
+ const mtime = fs.statSync(cacheFile).mtimeMs;
222
+ const elapsed = Date.now() - mtime;
223
+ // Only check if >= 24 hours since last check
224
+ if (elapsed < 86400000) shouldCheck = false;
225
+ } catch {
226
+ // File doesn't exist — check
227
+ }
228
+
229
+ if (shouldCheck) {
230
+ const version = getVersion();
231
+ checkLatest().then(latest => {
232
+ if (latest && version !== latest) {
233
+ process.stderr.write(`\n⚠ jsn ${latest} available (you have ${version}) — run "npm install -g @jacebenson/jsn" to update\n\n`);
234
+ }
235
+ }).catch(() => {});
236
+
237
+ // Stamp the cache file (write async, don't block)
238
+ try {
239
+ fs.mkdirSync(globalConfigDir(), { recursive: true });
240
+ fs.writeFileSync(cacheFile, String(Date.now()), 'utf-8');
241
+ } catch {
242
+ // Non-fatal
196
243
  }
197
- }).catch(() => {});
244
+ }
198
245
  }
199
246
 
200
247
  // Print context header for interactive terminals
201
- if (!['help', 'version', 'completion', 'skill'].includes(cmd)) {
248
+ if (!['help', 'version', 'completion', 'skill', 'docs'].includes(cmd)) {
202
249
  await argv.app.printContextHeader(argv);
203
250
  }
204
251
  })
@@ -281,6 +328,7 @@ export function buildCLI() {
281
328
  .command(evalCmd(wrap))
282
329
  .command(restCmd(wrap))
283
330
  .command(skillCmd(wrap))
331
+ .command(docsCmd(wrap))
284
332
  .command(versionCmd(wrap))
285
333
  // Legacy
286
334
  .command(devCmd(wrap))
@@ -0,0 +1,140 @@
1
+ // Docs DB path resolution and schema helpers.
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { DatabaseSync } from 'node:sqlite';
7
+
8
+ const DOCS_DIR_NAME = 'servicenow-cli/docs';
9
+
10
+ function xdgCacheHome() {
11
+ if (process.env.XDG_CACHE_HOME) return process.env.XDG_CACHE_HOME;
12
+ return path.join(os.homedir(), '.cache');
13
+ }
14
+
15
+ export function getDocsCacheDir() {
16
+ return path.join(xdgCacheHome(), DOCS_DIR_NAME);
17
+ }
18
+
19
+ export function getDocsDbPath() {
20
+ return path.join(getDocsCacheDir(), 'docs.db');
21
+ }
22
+
23
+ export function getDocsSourceDir() {
24
+ return path.join(getDocsCacheDir(), 'source');
25
+ }
26
+
27
+ export function getDocsSourceMarkdownDir() {
28
+ return path.join(getDocsSourceDir(), 'markdown');
29
+ }
30
+
31
+ export function ensureDocsCacheDir() {
32
+ const dir = getDocsCacheDir();
33
+ fs.mkdirSync(dir, { recursive: true });
34
+ return dir;
35
+ }
36
+
37
+ export function openDocsDb(opts = {}) {
38
+ const dbPath = opts.dbPath || getDocsDbPath();
39
+ ensureDocsCacheDir();
40
+ const db = new DatabaseSync(dbPath);
41
+ db.exec('PRAGMA journal_mode = WAL');
42
+ return db;
43
+ }
44
+
45
+ export function closeDocsDb(db) {
46
+ try {
47
+ db.close();
48
+ } catch {
49
+ // ignore
50
+ }
51
+ }
52
+
53
+ export function docsDbExists() {
54
+ return fs.existsSync(getDocsDbPath());
55
+ }
56
+
57
+ export function getSchemaVersion(db) {
58
+ try {
59
+ const row = db.prepare("SELECT value FROM meta WHERE key='schema_version'").get();
60
+ return row ? parseInt(row.value, 10) : 0;
61
+ } catch {
62
+ return 0;
63
+ }
64
+ }
65
+
66
+ export function setSchemaVersion(db, version) {
67
+ db.exec("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)");
68
+ db.prepare("INSERT OR REPLACE INTO meta(key, value) VALUES('schema_version', ?)").run(String(version));
69
+ }
70
+
71
+ export function initDocsSchema(db) {
72
+ db.exec(`
73
+ CREATE TABLE IF NOT EXISTS docs (
74
+ id INTEGER PRIMARY KEY,
75
+ path TEXT NOT NULL UNIQUE,
76
+ title TEXT,
77
+ release TEXT,
78
+ bundle TEXT,
79
+ doc_type TEXT,
80
+ locale TEXT,
81
+ frontmatter TEXT,
82
+ body TEXT,
83
+ size INTEGER,
84
+ mtime INTEGER,
85
+ hash TEXT,
86
+ hrr_vector BLOB
87
+ );
88
+
89
+ CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5(
90
+ title, body,
91
+ content='docs',
92
+ content_rowid='id',
93
+ tokenize='porter unicode61'
94
+ );
95
+
96
+ CREATE TRIGGER IF NOT EXISTS docs_ai AFTER INSERT ON docs BEGIN
97
+ INSERT INTO docs_fts(rowid, title, body) VALUES (new.id, new.title, new.body);
98
+ END;
99
+ CREATE TRIGGER IF NOT EXISTS docs_ad AFTER DELETE ON docs BEGIN
100
+ INSERT INTO docs_fts(docs_fts, rowid, title, body) VALUES('delete', old.id, old.title, old.body);
101
+ END;
102
+ CREATE TRIGGER IF NOT EXISTS docs_au AFTER UPDATE ON docs BEGIN
103
+ INSERT INTO docs_fts(docs_fts, rowid, title, body) VALUES('delete', old.id, old.title, old.body);
104
+ INSERT INTO docs_fts(rowid, title, body) VALUES (new.id, new.title, new.body);
105
+ END;
106
+
107
+ CREATE INDEX IF NOT EXISTS idx_docs_bundle ON docs(bundle);
108
+ CREATE INDEX IF NOT EXISTS idx_docs_doc_type ON docs(doc_type);
109
+ CREATE INDEX IF NOT EXISTS idx_docs_release ON docs(release);
110
+
111
+ CREATE TABLE IF NOT EXISTS meta (
112
+ key TEXT PRIMARY KEY,
113
+ value TEXT
114
+ );
115
+ `);
116
+ setSchemaVersion(db, 1);
117
+ }
118
+
119
+ export function hasEmbeddings(db) {
120
+ try {
121
+ const cols = db.prepare('PRAGMA table_info(docs)').all();
122
+ if (!cols.some((c) => c.name === 'hrr_vector')) return false;
123
+ return db.prepare('SELECT COUNT(*) AS n FROM docs WHERE hrr_vector IS NOT NULL').get().n > 0;
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ export function getMeta(db, key, defaultValue = null) {
130
+ try {
131
+ const row = db.prepare('SELECT value FROM meta WHERE key = ?').get(key);
132
+ return row ? row.value : defaultValue;
133
+ } catch {
134
+ return defaultValue;
135
+ }
136
+ }
137
+
138
+ export function setMeta(db, key, value) {
139
+ db.prepare('INSERT OR REPLACE INTO meta(key, value) VALUES(?, ?)').run(key, value);
140
+ }
@@ -0,0 +1,166 @@
1
+ // jsn docs — ServiceNow documentation search.
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import net from 'node:net';
6
+ import process from 'node:process';
7
+ import {
8
+ getDocsDbPath, getDocsSourceDir, getDocsSourceMarkdownDir, docsDbExists,
9
+ openDocsDb, closeDocsDb, getMeta,
10
+ } from './db.js';
11
+ import { syncDocs } from './sync.js';
12
+ import { refreshDocs } from './refresh.js';
13
+ import { embedDocs } from './embed.js';
14
+ import { searchDocs } from './search.js';
15
+ import { serveDocs } from './serve.js';
16
+
17
+ function formatBytes(n) {
18
+ if (n == null) return '-';
19
+ if (n < 1024) return `${n} B`;
20
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
21
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
22
+ }
23
+
24
+ export function docsCmd(wrap) {
25
+ return {
26
+ command: 'docs [subcommand]',
27
+ describe: 'Search ServiceNow documentation locally (sync, status, search, serve)',
28
+ builder: (yargs) => {
29
+ return yargs
30
+ .command({
31
+ command: 'sync',
32
+ describe: 'Clone/update ServiceNowDocs and build the docs.db index',
33
+ builder: (y) => y
34
+ .option('no-embed', { type: 'boolean', describe: 'Skip HRR semantic embeddings' })
35
+ .option('no-ingest', { type: 'boolean', describe: 'Clone/pull only; do not rebuild index' }),
36
+ handler: wrap(async (argv, app) => {
37
+ const result = syncDocs({
38
+ markdownDir: getDocsSourceMarkdownDir(),
39
+ dbPath: getDocsDbPath(),
40
+ embed: argv['no-embed'] !== true,
41
+ noIngest: argv['no-ingest'] === true,
42
+ });
43
+ app.ok(result, { summary: 'Docs sync complete' });
44
+ }),
45
+ })
46
+ .command({
47
+ command: 'status',
48
+ describe: 'Show docs database status',
49
+ handler: wrap(async (argv, app) => {
50
+ const dbPath = getDocsDbPath();
51
+ const repoDir = getDocsSourceDir();
52
+ const markdownDir = getDocsSourceMarkdownDir();
53
+ const downloaded = fs.existsSync(path.join(repoDir, '.git')) && fs.existsSync(markdownDir);
54
+ const dbExists = docsDbExists();
55
+
56
+ let state, embedded, documents, bundles, docTypes, syncedAt, dbSize;
57
+ if (dbExists) {
58
+ const db = openDocsDb();
59
+ documents = db.prepare('SELECT COUNT(*) AS n FROM docs').get().n;
60
+ bundles = db.prepare('SELECT COUNT(DISTINCT bundle) AS n FROM docs').get().n;
61
+ docTypes = db.prepare('SELECT COUNT(DISTINCT doc_type) AS n FROM docs').get().n;
62
+ const embCount = db.prepare('SELECT COUNT(*) AS n FROM docs WHERE hrr_vector IS NOT NULL').get().n;
63
+ syncedAt = getMeta(db, 'synced_at');
64
+ const stat = fs.statSync(dbPath);
65
+ dbSize = stat.size;
66
+ closeDocsDb(db);
67
+
68
+ embedded = embCount >= documents && documents > 0;
69
+ if (embedded) state = 'ready';
70
+ else if (documents > 0) state = 'indexed (not embedded)';
71
+ else state = 'empty';
72
+ } else {
73
+ state = downloaded ? 'not indexed' : 'not downloaded';
74
+ embedded = false;
75
+ }
76
+
77
+ // Quick probe to see if the server is running on the default port.
78
+ const served = await new Promise((resolve) => {
79
+ const sock = new net.Socket();
80
+ sock.setTimeout(500);
81
+ sock.on('connect', () => { sock.destroy(); resolve(true); });
82
+ sock.on('error', () => resolve(false));
83
+ sock.on('timeout', () => { sock.destroy(); resolve(false); });
84
+ sock.connect(3000, '127.0.0.1');
85
+ });
86
+
87
+ app.ok({
88
+ state,
89
+ downloaded: { ok: downloaded, path: repoDir },
90
+ db: { ok: dbExists, path: dbPath },
91
+ embedded: { ok: embedded },
92
+ ...(documents != null && { documents, bundles, docTypes, syncedAt: syncedAt ? new Date(parseInt(syncedAt, 10)).toISOString() : null }),
93
+ ...(dbSize != null && { dbSizeHuman: formatBytes(dbSize) }),
94
+ ...(served && { served: 'http://127.0.0.1:3000' }),
95
+ }, { summary: `Docs database: ${state}` });
96
+ }),
97
+ })
98
+ .command({
99
+ command: 'search <query>',
100
+ describe: 'Search docs (FTS5 + optional HRR hybrid rerank)',
101
+ builder: (y) => y
102
+ .positional('query', { type: 'string', describe: 'Search query' })
103
+ .option('limit', { type: 'number', default: 20, describe: 'Max results' })
104
+ .option('offset', { type: 'number', default: 0, describe: 'Result offset' })
105
+ .option('bundle', { type: 'string', describe: 'Filter by bundle' })
106
+ .option('doc-type', { type: 'string', describe: 'Filter by doc type' })
107
+ .option('mode', { type: 'string', choices: ['keyword', 'hybrid'], default: 'hybrid', describe: 'Search mode' }),
108
+ handler: wrap(async (argv, app) => {
109
+ const result = searchDocs({
110
+ query: argv.query,
111
+ limit: argv.limit,
112
+ offset: argv.offset,
113
+ bundle: argv.bundle,
114
+ docType: argv['doc-type'],
115
+ mode: argv.mode,
116
+ });
117
+ app.ok(result, { summary: `${result.count} result(s) for "${result.query}" (${result.mode})` });
118
+ }),
119
+ })
120
+ .command({
121
+ command: 'serve',
122
+ describe: 'Start the docs search web UI',
123
+ builder: (y) => y
124
+ .option('port', { type: 'number', default: 3000, describe: 'HTTP port' })
125
+ .option('host', { type: 'string', default: '127.0.0.1', describe: 'Bind address' })
126
+ .option('expose', { type: 'boolean', default: false, describe: 'Bind to 0.0.0.0 to expose on the network' }),
127
+ handler: wrap(async (argv, _app) => {
128
+ try {
129
+ const host = argv.expose ? '0.0.0.0' : argv.host;
130
+ await serveDocs({ port: argv.port, host });
131
+ } catch (err) {
132
+ process.stderr.write(`Error: ${err.message}\n`);
133
+ process.exit(1);
134
+ }
135
+ }),
136
+ })
137
+ .command({
138
+ command: 'embed',
139
+ describe: 'Backfill or refresh HRR semantic embeddings',
140
+ handler: wrap(async (argv, app) => {
141
+ const result = embedDocs();
142
+ app.ok(result, { summary: `Embedded ${result.embedded} of ${result.total} docs` });
143
+ }),
144
+ })
145
+ .command({
146
+ command: 'refresh',
147
+ describe: 'Incrementally refresh docs.db from existing source clone',
148
+ handler: wrap(async (argv, app) => {
149
+ const result = refreshDocs({ docsDir: getDocsSourceMarkdownDir() });
150
+ app.ok(result, { summary: `Refresh complete: +${result.added} ~${result.updated} -${result.removed} =${result.unchanged}` });
151
+ }),
152
+ });
153
+ },
154
+ handler: () => {
155
+ process.stdout.write('jsn docs — ServiceNow documentation search\n\n');
156
+ process.stdout.write('Available subcommands:\n');
157
+ process.stdout.write(' sync Download docs and build a searchable index (handles updates too)\n');
158
+ process.stdout.write(' status Show whether docs are downloaded, indexed, embedded, or served\n');
159
+ process.stdout.write(' search Full-text + semantic search across the local docs index\n');
160
+ process.stdout.write(' serve Start a local web UI for browsing and searching docs\n');
161
+ process.stdout.write('\n');
162
+ process.stdout.write('Tip: use "jsn docs serve --expose" to bind to 0.0.0.0 and share on your network.\n');
163
+ process.stdout.write('\nRun "jsn docs <command> --help" for details.\n');
164
+ },
165
+ };
166
+ }