@jacebenson/jsn 3.3.0 → 3.3.2
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 +1 -2
- package/src/cli.js +23 -2
- package/src/commands/skill.js +77 -76
- package/scripts/postinstall.mjs +0 -80
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jacebenson/jsn",
|
|
3
|
-
"version": "3.3.
|
|
3
|
+
"version": "3.3.2",
|
|
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"
|
package/src/cli.js
CHANGED
|
@@ -210,10 +210,31 @@ export function buildCLI() {
|
|
|
210
210
|
|| argv.json
|
|
211
211
|
|| argv.quiet;
|
|
212
212
|
|
|
213
|
-
// Auto-check skill on every command (
|
|
213
|
+
// Auto-check skill on every command (once per 24h, non-blocking)
|
|
214
214
|
const skipSkillCheck = ['help', 'version', 'completion', 'skill', 'docs'].includes(cmd)
|
|
215
215
|
|| process.env.JSN_NO_SKILL_CHECK === '1'
|
|
216
|
-
|| argv['no-skill-check']
|
|
216
|
+
|| argv['no-skill-check'];
|
|
217
|
+
if (!skipSkillCheck) {
|
|
218
|
+
const skillCacheFile = path.join(globalConfigDir(), '.last-skill-check');
|
|
219
|
+
let shouldCheckSkill = true;
|
|
220
|
+
try {
|
|
221
|
+
const mtime = fs.statSync(skillCacheFile).mtimeMs;
|
|
222
|
+
if (Date.now() - mtime < 86400000) shouldCheckSkill = false;
|
|
223
|
+
} catch {
|
|
224
|
+
// File doesn't exist — check
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (shouldCheckSkill) {
|
|
228
|
+
checkSkill().catch(() => {});
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
fs.mkdirSync(globalConfigDir(), { recursive: true });
|
|
232
|
+
fs.writeFileSync(skillCacheFile, String(Date.now()), 'utf-8');
|
|
233
|
+
} catch {
|
|
234
|
+
// Non-fatal
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
217
238
|
if (!skipNpmCheck) {
|
|
218
239
|
const cacheFile = path.join(globalConfigDir(), '.last-npm-check');
|
|
219
240
|
let shouldCheck = true;
|
package/src/commands/skill.js
CHANGED
|
@@ -6,6 +6,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
6
6
|
|
|
7
7
|
import { execSync } from 'node:child_process';
|
|
8
8
|
|
|
9
|
+
import { globalConfigPath } from '../config.js';
|
|
10
|
+
|
|
9
11
|
const SKILL_NAME = 'servicenow';
|
|
10
12
|
const SKILL_REPO_PATH = path.join(
|
|
11
13
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
@@ -18,6 +20,9 @@ const SKILL_RAW_URL = 'https://raw.githubusercontent.com/jacebenson/jsn/nodejs/s
|
|
|
18
20
|
* Resolve the user's real home directory, even when Hermes overrides $HOME.
|
|
19
21
|
*/
|
|
20
22
|
function realHomeDir() {
|
|
23
|
+
// getent doesn't exist on Windows; os.homedir() is reliable there.
|
|
24
|
+
if (process.platform === 'win32') return os.homedir();
|
|
25
|
+
|
|
21
26
|
try {
|
|
22
27
|
const result = execSync('getent passwd ' + process.env.USER + ' 2>/dev/null', { encoding: 'utf-8', timeout: 3000 });
|
|
23
28
|
const home = result.trim().split(':')[5];
|
|
@@ -94,73 +99,75 @@ function readBundledSkill() {
|
|
|
94
99
|
}
|
|
95
100
|
}
|
|
96
101
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const installedCopies = [];
|
|
106
|
-
for (const [key, dir] of Object.entries(AGENT_SKILL_DIRS)) {
|
|
107
|
-
const skillPath = key === 'cursor'
|
|
108
|
-
? path.join(dir, `${SKILL_NAME}.mdc`)
|
|
109
|
-
: path.join(dir, 'SKILL.md');
|
|
110
|
-
try {
|
|
111
|
-
const content = fs.readFileSync(skillPath, 'utf-8');
|
|
112
|
-
installedCopies.push({ harness: key, label: TARGET_NAMES[key], content, path: skillPath });
|
|
113
|
-
} catch {
|
|
114
|
-
// Not installed to this harness — skip
|
|
115
|
-
}
|
|
102
|
+
// ── Skill config helpers — read/write skillLocation / skillVersion / skillLastChecked ──
|
|
103
|
+
|
|
104
|
+
function loadSkillConfig() {
|
|
105
|
+
try {
|
|
106
|
+
const raw = fs.readFileSync(globalConfigPath(), 'utf-8');
|
|
107
|
+
return JSON.parse(raw);
|
|
108
|
+
} catch {
|
|
109
|
+
return {};
|
|
116
110
|
}
|
|
111
|
+
}
|
|
117
112
|
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
113
|
+
function saveSkillConfig(updates) {
|
|
114
|
+
const cfg = loadSkillConfig();
|
|
115
|
+
Object.assign(cfg, updates);
|
|
116
|
+
fs.mkdirSync(path.dirname(globalConfigPath()), { recursive: true });
|
|
117
|
+
fs.writeFileSync(globalConfigPath(), JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
118
|
+
}
|
|
121
119
|
|
|
122
|
-
|
|
123
|
-
|
|
120
|
+
function recordSkillInstall(targetPath) {
|
|
121
|
+
const bundled = readBundledSkill();
|
|
122
|
+
const version = extractVersion(bundled) || 'unknown';
|
|
123
|
+
saveSkillConfig({
|
|
124
|
+
skillLocation: targetPath,
|
|
125
|
+
skillVersion: version,
|
|
126
|
+
skillLastChecked: new Date().toISOString(),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
124
129
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const upstream = await res.text();
|
|
132
|
-
const upstreamVersion = extractVersion(upstream);
|
|
133
|
-
if (!upstreamVersion) return { current: false, error: 'No version field in upstream skill' };
|
|
134
|
-
|
|
135
|
-
const current = installedVersion === upstreamVersion;
|
|
136
|
-
|
|
137
|
-
// Check each installed copy for version match
|
|
138
|
-
const outdated = [];
|
|
139
|
-
for (const copy of installedCopies) {
|
|
140
|
-
const v = extractVersion(copy.content);
|
|
141
|
-
if (v && v !== upstreamVersion) {
|
|
142
|
-
outdated.push(copy.label);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
130
|
+
// ── Skill version check (once per day, uses recorded location) ──
|
|
131
|
+
|
|
132
|
+
export function checkSkill() {
|
|
133
|
+
const cfg = loadSkillConfig();
|
|
134
|
+
const location = cfg.skillLocation;
|
|
135
|
+
if (!location) return { current: true, note: 'Skill not installed — run "jsn skill install"' };
|
|
145
136
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
harnesses: {
|
|
156
|
-
installed: installedCopies.map(c => c.label),
|
|
157
|
-
outdated,
|
|
158
|
-
},
|
|
159
|
-
error: current ? null : outdatedMsg,
|
|
160
|
-
};
|
|
137
|
+
// Throttle: skip if checked within last 24 hours
|
|
138
|
+
if (cfg.skillLastChecked) {
|
|
139
|
+
const elapsed = Date.now() - new Date(cfg.skillLastChecked).getTime();
|
|
140
|
+
if (elapsed < 86400000) return { current: true, note: 'Checked within 24h — skipping' };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let installed;
|
|
144
|
+
try {
|
|
145
|
+
installed = fs.readFileSync(location, 'utf-8');
|
|
161
146
|
} catch {
|
|
162
|
-
return { current:
|
|
147
|
+
return { current: true, note: `Skill file not found at ${location} — run "jsn skill install"` };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const installedVersion = extractVersion(installed);
|
|
151
|
+
const bundled = readBundledSkill();
|
|
152
|
+
const bundledVersion = extractVersion(bundled);
|
|
153
|
+
|
|
154
|
+
if (!installedVersion || !bundledVersion) {
|
|
155
|
+
return { current: true, error: 'Could not determine skill version' };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const current = installedVersion === bundledVersion;
|
|
159
|
+
|
|
160
|
+
if (!current) {
|
|
161
|
+
process.stderr.write(
|
|
162
|
+
`\n⚠ jsn: ServiceNow skill is outdated (installed v${installedVersion}, bundled v${bundledVersion}).\n` +
|
|
163
|
+
` Run "jsn skill install" to update.\n\n`
|
|
164
|
+
);
|
|
163
165
|
}
|
|
166
|
+
|
|
167
|
+
// Update last-checked timestamp
|
|
168
|
+
saveSkillConfig({ skillLastChecked: new Date().toISOString() });
|
|
169
|
+
|
|
170
|
+
return { current, installed_version: installedVersion, bundled_version: bundledVersion };
|
|
164
171
|
}
|
|
165
172
|
|
|
166
173
|
function extractVersion(content) {
|
|
@@ -196,28 +203,19 @@ export function skillCmd(wrap) {
|
|
|
196
203
|
})
|
|
197
204
|
.command({
|
|
198
205
|
command: 'check',
|
|
199
|
-
describe: 'Check if the
|
|
206
|
+
describe: 'Check if the installed skill matches the bundled version',
|
|
200
207
|
handler: wrap(async (_argv, app) => {
|
|
201
|
-
const result =
|
|
202
|
-
const h = result.harnesses || {};
|
|
208
|
+
const result = checkSkill();
|
|
203
209
|
|
|
204
|
-
// Build a summary that shows which harnesses are installed and current
|
|
205
210
|
let summary;
|
|
206
|
-
if (result.error
|
|
211
|
+
if (result.error) {
|
|
207
212
|
summary = result.error;
|
|
213
|
+
} else if (result.note) {
|
|
214
|
+
summary = result.note;
|
|
208
215
|
} else if (result.current) {
|
|
209
|
-
|
|
210
|
-
if (h.installed && h.installed.length > 0) {
|
|
211
|
-
parts.push(` — installed for ${h.installed.join(', ')}`);
|
|
212
|
-
}
|
|
213
|
-
summary = parts.join('');
|
|
216
|
+
summary = `✓ Skill is current (v${result.installed_version} matches bundled v${result.bundled_version})`;
|
|
214
217
|
} else {
|
|
215
|
-
|
|
216
|
-
if (h.outdated && h.outdated.length > 0) {
|
|
217
|
-
parts.push(` — outdated for ${h.outdated.join(', ')}`);
|
|
218
|
-
}
|
|
219
|
-
parts.push(` — run "jsn skill install" to update`);
|
|
220
|
-
summary = parts.join('');
|
|
218
|
+
summary = `⚠ Skill outdated: installed v${result.installed_version} vs bundled v${result.bundled_version} — run "jsn skill install" to update`;
|
|
221
219
|
}
|
|
222
220
|
|
|
223
221
|
app.ok(result, { summary });
|
|
@@ -386,6 +384,9 @@ export function skillCmd(wrap) {
|
|
|
386
384
|
throw new Error(`No targets matched. Valid targets: ${Object.keys(AGENT_SKILL_DIRS).join(', ')}, or "all"`);
|
|
387
385
|
}
|
|
388
386
|
|
|
387
|
+
// Record install location so checkSkill knows where to look
|
|
388
|
+
recordSkillInstall(targets[0].path);
|
|
389
|
+
|
|
389
390
|
const installed = targets.reduce((acc, t) => { acc[t.name] = t.path; return acc; }, {});
|
|
390
391
|
const summary = targets.length === 1
|
|
391
392
|
? `Skill installed to ${targets[0].path}`
|
package/scripts/postinstall.mjs
DELETED
|
@@ -1,80 +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
|
-
// Create symlink at ~/.claude/skills/servicenow → ../../.agents/skills/servicenow
|
|
59
|
-
// This is the same pattern basecamp's installer uses: ~/.claude/skills/basecamp → ../../.agents/skills/basecamp
|
|
60
|
-
try {
|
|
61
|
-
fs.mkdirSync(CLAUDE_SKILL_DIR, { recursive: true });
|
|
62
|
-
|
|
63
|
-
// Remove stale symlink or directory if it exists
|
|
64
|
-
try {
|
|
65
|
-
fs.unlinkSync(CLAUDE_SYMLINK);
|
|
66
|
-
} catch {
|
|
67
|
-
// doesn't exist — fine
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Relative symlink so it survives user home dir moves
|
|
71
|
-
const relativeTarget = path.relative(CLAUDE_SKILL_DIR, AGENTS_DIR);
|
|
72
|
-
fs.symlinkSync(relativeTarget, CLAUDE_SYMLINK, 'dir');
|
|
73
|
-
console.log(' ✓ jsn: Claude Code skill link at ' + CLAUDE_SYMLINK);
|
|
74
|
-
} catch (err) {
|
|
75
|
-
// Claude Code skill dir might not be expected — non-fatal
|
|
76
|
-
console.warn(' ⚠ jsn: Could not create Claude Code symlink:', err.message);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
installSkill();
|