@jacebenson/jsn 3.3.1 → 3.4.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jacebenson/jsn",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.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": {
|
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"test": "NODE_OPTIONS=--require=./suppress-warnings.cjs node --test $(find test -name '*.test.js')",
|
|
12
12
|
"lint": "npx eslint src/ bin/ test/",
|
|
13
|
-
"postinstall": "node scripts/postinstall.mjs",
|
|
14
13
|
"start": "NODE_OPTIONS=--require=./suppress-warnings.cjs node bin/jsn.js",
|
|
15
14
|
"tag-reminder": "node scripts/tag-reminder.js",
|
|
16
15
|
"release": "node scripts/release.js"
|
|
@@ -114,7 +114,84 @@ export function docsCmd(wrap) {
|
|
|
114
114
|
docType: argv['doc-type'],
|
|
115
115
|
mode: argv.mode,
|
|
116
116
|
});
|
|
117
|
-
|
|
117
|
+
|
|
118
|
+
// Build a readable text blob for styled TTY output.
|
|
119
|
+
const lines = [];
|
|
120
|
+
for (const r of result.results) {
|
|
121
|
+
const title = r.title || r.path || '(untitled)';
|
|
122
|
+
const snippet = (r.snippet || r.body || '')
|
|
123
|
+
.replace(/\\n/g, ' ')
|
|
124
|
+
.replace(/\s+/g, ' ')
|
|
125
|
+
.trim()
|
|
126
|
+
.substring(0, 200);
|
|
127
|
+
lines.push(`[${r.id}] ${title}`);
|
|
128
|
+
if (snippet) lines.push(` ${snippet}`);
|
|
129
|
+
lines.push('');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const hasMore = result.total > result.count;
|
|
133
|
+
if (hasMore) {
|
|
134
|
+
const nextOffset = (result.count + (argv.offset || 0));
|
|
135
|
+
lines.push(`… ${result.total - result.count} more results (use --offset ${nextOffset} for next page)`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
app.ok({
|
|
139
|
+
_formatted: lines.join('\n'),
|
|
140
|
+
query: result.query,
|
|
141
|
+
mode: result.mode,
|
|
142
|
+
total: result.total,
|
|
143
|
+
count: result.count,
|
|
144
|
+
results: result.results,
|
|
145
|
+
}, { summary: `${result.count} of ${result.total} result(s) for "${result.query}" (${result.mode})` });
|
|
146
|
+
}),
|
|
147
|
+
})
|
|
148
|
+
.command({
|
|
149
|
+
command: 'show <id-or-path>',
|
|
150
|
+
describe: 'Show a documentation page by id or path',
|
|
151
|
+
builder: (y) => y
|
|
152
|
+
.positional('id-or-path', { type: 'string', describe: 'Document id (number) or file path' })
|
|
153
|
+
.option('lines', { type: 'number', default: 80, describe: 'Lines of body to show in terminal (use --json for full content)' }),
|
|
154
|
+
handler: wrap(async (argv, app) => {
|
|
155
|
+
const idOrPath = argv['id-or-path'];
|
|
156
|
+
const db = openDocsDb();
|
|
157
|
+
let row;
|
|
158
|
+
if (/^\d+$/.test(idOrPath)) {
|
|
159
|
+
row = db.prepare('SELECT * FROM docs WHERE id = ?').get(parseInt(idOrPath, 10));
|
|
160
|
+
} else {
|
|
161
|
+
row = db.prepare('SELECT * FROM docs WHERE path LIKE ?').get(`%${idOrPath}%`);
|
|
162
|
+
if (!row) {
|
|
163
|
+
row = db.prepare(
|
|
164
|
+
`SELECT d.* FROM docs_fts JOIN docs d ON d.id = docs_fts.rowid WHERE docs_fts MATCH ? LIMIT 1`
|
|
165
|
+
).get(idOrPath);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
closeDocsDb(db);
|
|
169
|
+
|
|
170
|
+
if (!row) {
|
|
171
|
+
throw Object.assign(new Error(`No doc found for "${idOrPath}"`), { code: 'not_found' });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let fm = {};
|
|
175
|
+
try { fm = JSON.parse(row.frontmatter || '{}'); } catch { /* ignore */ }
|
|
176
|
+
|
|
177
|
+
const title = row.title || fm.title || row.path || '(untitled)';
|
|
178
|
+
const header = `${title}\n${'─'.repeat(Math.min(title.length, 80))}`;
|
|
179
|
+
const body = row.body || '';
|
|
180
|
+
const bodyLines = body.split('\n');
|
|
181
|
+
const truncated = bodyLines.length > argv.lines;
|
|
182
|
+
const preview = bodyLines.slice(0, argv.lines).join('\n');
|
|
183
|
+
const hint = truncated ? `\n\n… ${bodyLines.length - argv.lines} more lines (use --lines ${bodyLines.length} or --json for full content)` : '';
|
|
184
|
+
|
|
185
|
+
app.ok({
|
|
186
|
+
id: row.id,
|
|
187
|
+
path: row.path,
|
|
188
|
+
title: row.title,
|
|
189
|
+
bundle: row.bundle,
|
|
190
|
+
release: row.release,
|
|
191
|
+
canonical_url: fm.canonical_url || null,
|
|
192
|
+
body,
|
|
193
|
+
_formatted: `${header}\n\n${preview}${hint}`,
|
|
194
|
+
}, { summary: `Doc ${row.id}: ${title}` });
|
|
118
195
|
}),
|
|
119
196
|
})
|
|
120
197
|
.command({
|
|
@@ -157,9 +234,13 @@ export function docsCmd(wrap) {
|
|
|
157
234
|
process.stdout.write(' sync Download docs and build a searchable index (handles updates too)\n');
|
|
158
235
|
process.stdout.write(' status Show whether docs are downloaded, indexed, embedded, or served\n');
|
|
159
236
|
process.stdout.write(' search Full-text + semantic search across the local docs index\n');
|
|
237
|
+
process.stdout.write(' show Display a full documentation page by id or path\n');
|
|
160
238
|
process.stdout.write(' serve Start a local web UI for browsing and searching docs\n');
|
|
161
239
|
process.stdout.write('\n');
|
|
162
240
|
process.stdout.write('Tip: use "jsn docs serve --expose" to bind to 0.0.0.0 and share on your network.\n');
|
|
241
|
+
process.stdout.write('\n');
|
|
242
|
+
const dbPath = getDocsDbPath();
|
|
243
|
+
process.stdout.write(`Docs DB: ${dbPath} (run "jsn docs status" for details, or query directly with sqlite3)\n`);
|
|
163
244
|
process.stdout.write('\nRun "jsn docs <command> --help" for details.\n');
|
|
164
245
|
},
|
|
165
246
|
};
|
|
@@ -25,7 +25,7 @@ export function searchDocs(opts = {}) {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
28
|
-
const limit = Math.min(parseInt(opts.limit, 10) || 20,
|
|
28
|
+
const limit = Math.min(parseInt(opts.limit, 10) || 20, 1000);
|
|
29
29
|
const offset = parseInt(opts.offset, 10) || 0;
|
|
30
30
|
const bundle = opts.bundle ? String(opts.bundle) : null;
|
|
31
31
|
const docType = opts.docType ? String(opts.docType) : null;
|
|
@@ -44,6 +44,11 @@ export function searchDocs(opts = {}) {
|
|
|
44
44
|
}
|
|
45
45
|
const where = filters.length ? ' AND ' + filters.join(' AND ') : '';
|
|
46
46
|
|
|
47
|
+
// Total matching docs (for pagination)
|
|
48
|
+
const total = db
|
|
49
|
+
.prepare(`SELECT COUNT(*) AS n FROM docs_fts WHERE docs_fts MATCH @q${where}`)
|
|
50
|
+
.get({ q, ...(bundle && { bundle }), ...(docType && { docType }) }).n;
|
|
51
|
+
|
|
47
52
|
let rows;
|
|
48
53
|
if (!useHybrid) {
|
|
49
54
|
rows = db
|
|
@@ -133,6 +138,7 @@ export function searchDocs(opts = {}) {
|
|
|
133
138
|
return {
|
|
134
139
|
query: q,
|
|
135
140
|
mode: useHybrid ? 'hybrid' : 'keyword',
|
|
141
|
+
total,
|
|
136
142
|
count: rows.length,
|
|
137
143
|
results: rows,
|
|
138
144
|
};
|
|
@@ -12,6 +12,9 @@ const BRANCH = 'australia';
|
|
|
12
12
|
|
|
13
13
|
function git(args, cwd, stdio = 'inherit') {
|
|
14
14
|
const r = spawnSync('git', args, { cwd, stdio });
|
|
15
|
+
if (r.error && r.error.code === 'ENOENT') {
|
|
16
|
+
throw new Error('git is not installed. Install git to sync docs, or download the docs repo manually.');
|
|
17
|
+
}
|
|
15
18
|
if (r.status !== 0) {
|
|
16
19
|
throw new Error(`git ${args[0]} failed (exit ${r.status}).`);
|
|
17
20
|
}
|
package/src/commands/skill.js
CHANGED
|
@@ -20,6 +20,9 @@ const SKILL_RAW_URL = 'https://raw.githubusercontent.com/jacebenson/jsn/nodejs/s
|
|
|
20
20
|
* Resolve the user's real home directory, even when Hermes overrides $HOME.
|
|
21
21
|
*/
|
|
22
22
|
function realHomeDir() {
|
|
23
|
+
// getent doesn't exist on Windows; os.homedir() is reliable there.
|
|
24
|
+
if (process.platform === 'win32') return os.homedir();
|
|
25
|
+
|
|
23
26
|
try {
|
|
24
27
|
const result = execSync('getent passwd ' + process.env.USER + ' 2>/dev/null', { encoding: 'utf-8', timeout: 3000 });
|
|
25
28
|
const home = result.trim().split(':')[5];
|
package/scripts/postinstall.mjs
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* postinstall.mjs — Copies the bundled ServiceNow SKILL.md to agent skill directories
|
|
4
|
-
* on npm global install. Mirrors what `basecamp`'s installer does for their skill.
|
|
5
|
-
*
|
|
6
|
-
* Only runs when `npm install -g @jacebenson/jsn`.
|
|
7
|
-
* On `npm install` (local dependency), it's a no-op.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import fs from 'node:fs';
|
|
11
|
-
import path from 'node:path';
|
|
12
|
-
import os from 'node:os';
|
|
13
|
-
import { fileURLToPath } from 'node:url';
|
|
14
|
-
|
|
15
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
-
const SKILL_SOURCE = path.resolve(__dirname, '..', 'skills', 'servicenow', 'SKILL.md');
|
|
17
|
-
const AGENTS_DIR = path.join(os.homedir(), '.agents', 'skills', 'servicenow');
|
|
18
|
-
const AGENTS_SKILL = path.join(AGENTS_DIR, 'SKILL.md');
|
|
19
|
-
const CLAUDE_SKILL_DIR = path.join(os.homedir(), '.claude', 'skills');
|
|
20
|
-
const CLAUDE_SYMLINK = path.join(CLAUDE_SKILL_DIR, 'servicenow');
|
|
21
|
-
|
|
22
|
-
function isGlobalInstall() {
|
|
23
|
-
return (
|
|
24
|
-
process.env.npm_config_global === 'true' ||
|
|
25
|
-
process.env.npm_lifecycle_event === 'postinstall'
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function installSkill() {
|
|
30
|
-
// Check if running in a non-interactive/CI environment where .agents
|
|
31
|
-
// doesn't make sense. Skip silently.
|
|
32
|
-
if (!AGENTS_DIR.startsWith(os.homedir())) {
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
if (!fs.existsSync(SKILL_SOURCE)) {
|
|
37
|
-
console.warn(' ⚠ jsn: ServiceNow SKILL.md not found at', SKILL_SOURCE);
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Create ~/.agents/skills/servicenow/
|
|
42
|
-
try {
|
|
43
|
-
fs.mkdirSync(AGENTS_DIR, { recursive: true });
|
|
44
|
-
} catch {
|
|
45
|
-
// user's home might not be writable in container/CI
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Copy the bundled skill file
|
|
50
|
-
try {
|
|
51
|
-
fs.copyFileSync(SKILL_SOURCE, AGENTS_SKILL);
|
|
52
|
-
console.log(' ✓ jsn: ServiceNow skill installed to ' + AGENTS_SKILL);
|
|
53
|
-
} catch (err) {
|
|
54
|
-
console.warn(' ⚠ jsn: Could not copy skill to ' + AGENTS_SKILL, err.message);
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Symlinks require admin on Windows and are pointless outside Unix/Mac.
|
|
59
|
-
// Skip on win32 to avoid "system cannot find the path" errors.
|
|
60
|
-
if (process.platform === 'win32') return;
|
|
61
|
-
|
|
62
|
-
// Create symlink at ~/.claude/skills/servicenow → ../../.agents/skills/servicenow
|
|
63
|
-
// This is the same pattern basecamp's installer uses: ~/.claude/skills/basecamp → ../../.agents/skills/basecamp
|
|
64
|
-
try {
|
|
65
|
-
fs.mkdirSync(CLAUDE_SKILL_DIR, { recursive: true });
|
|
66
|
-
|
|
67
|
-
// Remove stale symlink or directory if it exists
|
|
68
|
-
try {
|
|
69
|
-
fs.unlinkSync(CLAUDE_SYMLINK);
|
|
70
|
-
} catch {
|
|
71
|
-
// doesn't exist — fine
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Relative symlink so it survives user home dir moves
|
|
75
|
-
const relativeTarget = path.relative(CLAUDE_SKILL_DIR, AGENTS_DIR);
|
|
76
|
-
fs.symlinkSync(relativeTarget, CLAUDE_SYMLINK, 'dir');
|
|
77
|
-
console.log(' ✓ jsn: Claude Code skill link at ' + CLAUDE_SYMLINK);
|
|
78
|
-
} catch (err) {
|
|
79
|
-
// Claude Code skill dir might not be expected — non-fatal
|
|
80
|
-
console.warn(' ⚠ jsn: Could not create Claude Code symlink:', err.message);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
installSkill();
|