@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "3.3.0",
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 (fire-and-forget, non-blocking)
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;
@@ -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
- export async function checkSkill() {
98
- // Compare all installed skill copies against GitHub.
99
- // Uses a single HTTP fetch and reads all harness paths that exist.
100
- // Returns the first installed version found for comparison, plus a list
101
- // of harnesses where the skill is current vs outdated (or missing).
102
- const SKILL_NAME = 'servicenow';
103
-
104
- // Collect all installed copies that actually exist on disk
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
- // Fall back to bundled skill if nothing installed yet
119
- const primary = installedCopies[0] || { content: readBundledSkill() };
120
- if (!primary.content) return { current: false, error: 'Skill file not found' };
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
- const installedVersion = extractVersion(primary.content);
123
- if (!installedVersion) return { current: false, error: 'No version field in installed skill' };
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
- try {
126
- const controller = new AbortController();
127
- const timer = setTimeout(() => controller.abort(), 10000);
128
- const res = await fetch(SKILL_RAW_URL, { signal: controller.signal });
129
- clearTimeout(timer);
130
- if (!res.ok) return { current: false, error: `GitHub returned ${res.status}` };
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
- const outdatedMsg = outdated.length > 0
147
- ? ` Skill outdated for ${outdated.join(', ')} (v${installedVersion} vs GitHub v${upstreamVersion}) — run "jsn skill install" to update`
148
- : null;
149
-
150
- return {
151
- current,
152
- installed_version: installedVersion,
153
- upstream_version: upstreamVersion,
154
- checked: installedCopies.length,
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: false, error: 'Could not check — GitHub unreachable' };
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 bundled skill file is up to date with GitHub',
206
+ describe: 'Check if the installed skill matches the bundled version',
200
207
  handler: wrap(async (_argv, app) => {
201
- const result = await checkSkill();
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 && !result.current) {
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
- const parts = [`✓ Skill is current (v${result.installed_version})`];
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
- const parts = [`⚠ Skill outdated: installed v${result.installed_version} vs GitHub v${result.upstream_version}`];
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}`
@@ -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();