@jacebenson/jsn 3.1.5 → 3.2.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.1.5",
3
+ "version": "3.2.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": {
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';
@@ -25,7 +27,8 @@ import { groupRolesCmd } from './commands/grouproles.js';
25
27
  import { ticketsCmd } from './commands/tickets.js';
26
28
  import { versionCmd } from './commands/version.js';
27
29
  import { devCmd } from './commands/dev.js';
28
- import { skillCmd, checkSkill } from './commands/skill.js';
30
+ import { skillCmd } from './commands/skill.js';
31
+ import { getVersion, checkLatest } from './commands/version.js';
29
32
 
30
33
  // Dev subcommands promoted to root level for progressive disclosure
31
34
  import {
@@ -153,8 +156,23 @@ export function buildCLI() {
153
156
  if (argv.format) {
154
157
  app.output.setFormat(argv.format);
155
158
  }
159
+ // --profile: resolve profile name to its instance URL
160
+ // --instance wins if both are specified (explicit URL > profile reference)
161
+ if (argv.profile) {
162
+ const profile = (cfg.profiles || {})[argv.profile];
163
+ if (!profile) {
164
+ process.stderr.write(`Error: Unknown profile "${argv.profile}"\n`);
165
+ process.exit(1);
166
+ }
167
+ const url = profile.instance_url;
168
+ app._overrideInstance = url;
169
+ argv._overrideInstance = url;
170
+ // Temporarily switch active profile so auth picks up the right user
171
+ cfg.activeProfile = argv.profile;
172
+ }
156
173
  if (argv.instance) {
157
174
  app._overrideInstance = argv.instance;
175
+ argv._overrideInstance = argv.instance;
158
176
  }
159
177
  },
160
178
  // Guard mutation commands when no instance configured
@@ -166,6 +184,7 @@ export function buildCLI() {
166
184
  // Check for --instance override
167
185
  if (argv.instance) {
168
186
  app._overrideInstance = argv.instance;
187
+ argv._overrideInstance = argv.instance;
169
188
  }
170
189
  // Determine if this is a mutation
171
190
  const subcommand = (argv._[1] || '').toString();
@@ -183,18 +202,40 @@ export function buildCLI() {
183
202
 
184
203
  const cmd = (argv._[0] || '').toString();
185
204
 
186
- // Auto-check skill on every command (fire-and-forget, non-blocking)
187
- const skipSkillCheck = ['help', 'version', 'completion', 'skill'].includes(cmd)
188
- || process.env.JSN_NO_SKILL_CHECK === '1'
189
- || argv['no-skill-check']
205
+ // Daily npm version check (fire-and-forget, non-blocking)
206
+ // Checks npm for newer jsn releases, at most once per 24 hours.
207
+ const skipNpmCheck = ['help', 'version', 'completion', 'skill'].includes(cmd)
208
+ || process.env.JSN_NO_VERSION_CHECK === '1'
190
209
  || argv.json
191
210
  || 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`);
211
+ if (!skipNpmCheck) {
212
+ const cacheFile = path.join(globalConfigDir(), '.last-npm-check');
213
+ let shouldCheck = true;
214
+ try {
215
+ const mtime = fs.statSync(cacheFile).mtimeMs;
216
+ const elapsed = Date.now() - mtime;
217
+ // Only check if >= 24 hours since last check
218
+ if (elapsed < 86400000) shouldCheck = false;
219
+ } catch {
220
+ // File doesn't exist — check
221
+ }
222
+
223
+ if (shouldCheck) {
224
+ const version = getVersion();
225
+ checkLatest().then(latest => {
226
+ if (latest && version !== latest) {
227
+ process.stderr.write(`\n⚠ jsn ${latest} available (you have ${version}) — run "npm install -g @jacebenson/jsn" to update\n\n`);
228
+ }
229
+ }).catch(() => {});
230
+
231
+ // Stamp the cache file (write async, don't block)
232
+ try {
233
+ fs.mkdirSync(globalConfigDir(), { recursive: true });
234
+ fs.writeFileSync(cacheFile, String(Date.now()), 'utf-8');
235
+ } catch {
236
+ // Non-fatal
196
237
  }
197
- }).catch(() => {});
238
+ }
198
239
  }
199
240
 
200
241
  // Print context header for interactive terminals
@@ -4,32 +4,86 @@ import os from 'node:os';
4
4
  import process from 'node:process';
5
5
  import { fileURLToPath } from 'node:url';
6
6
 
7
+ import { execSync } from 'node:child_process';
8
+
7
9
  const SKILL_NAME = 'servicenow';
8
10
  const SKILL_REPO_PATH = path.join(
9
11
  path.dirname(fileURLToPath(import.meta.url)),
10
12
  '..', '..', 'skills', SKILL_NAME, 'SKILL.md'
11
13
  );
12
14
 
13
- const HERMES_SKILL_PATH = path.join(os.homedir(), '.hermes', 'skills', SKILL_NAME, 'SKILL.md');
14
-
15
15
  const SKILL_RAW_URL = 'https://raw.githubusercontent.com/jacebenson/jsn/nodejs/skills/servicenow/SKILL.md';
16
16
 
17
+ /**
18
+ * Resolve the user's real home directory, even when Hermes overrides $HOME.
19
+ */
20
+ function realHomeDir() {
21
+ try {
22
+ const result = execSync('getent passwd ' + process.env.USER + ' 2>/dev/null', { encoding: 'utf-8', timeout: 3000 });
23
+ const home = result.trim().split(':')[5];
24
+ if (home) return home;
25
+ } catch {
26
+ // fall through
27
+ }
28
+ return os.homedir();
29
+ }
30
+
31
+ /**
32
+ * Resolve the Hermes profile skill directory.
33
+ * Checks HERMES_PROFILE env var, then scans ~/.hermes/profiles/ for the first
34
+ * profile directory, falling back to ~/.hermes/skills/ (legacy symlink path).
35
+ */
36
+ function hermesSkillDir() {
37
+ const base = path.join(realHomeDir(), '.hermes');
38
+ const profileEnv = process.env.HERMES_PROFILE;
39
+ let profileName = profileEnv;
40
+
41
+ if (!profileName) {
42
+ try {
43
+ const profiles = fs.readdirSync(path.join(base, 'profiles'));
44
+ // Pick the first real profile directory (skip . and ..)
45
+ profileName = profiles.find(d => d !== '.' && d !== '..') || null;
46
+ } catch {
47
+ profileName = null;
48
+ }
49
+ }
50
+
51
+ if (profileName) {
52
+ return path.join(base, 'profiles', profileName, 'skills', SKILL_NAME);
53
+ }
54
+ return path.join(base, 'skills', SKILL_NAME);
55
+ }
56
+
57
+ /** Resolve the installed SKILL.md path for Hermes. */
58
+ function hermesSkillPath() {
59
+ return path.join(hermesSkillDir(), 'SKILL.md');
60
+ }
61
+
17
62
  // Known agent skill directories (personal/user-level)
18
63
  // Keyed by agent name for the --target flag
64
+ // Each value is the directory for the <name>/SKILL.md file
19
65
  const AGENT_SKILL_DIRS = {
20
- hermes: path.join(os.homedir(), '.hermes', 'skills', SKILL_NAME),
21
- copilot: path.join(os.homedir(), '.copilot', 'skills', SKILL_NAME),
22
- claude: path.join(os.homedir(), '.claude', 'skills', SKILL_NAME),
23
- cursor: path.join(os.homedir(), '.cursor', 'rules'),
24
- agents: path.join(os.homedir(), '.agents', 'skills', SKILL_NAME),
66
+ hermes: hermesSkillDir(),
67
+ copilot: path.join(realHomeDir(), '.copilot', 'skills', SKILL_NAME),
68
+ vscode: path.join(realHomeDir(), '.copilot', 'instructions', SKILL_NAME),
69
+ claude: path.join(realHomeDir(), '.claude', 'skills', SKILL_NAME),
70
+ cursor: path.join(realHomeDir(), '.cursor', 'rules'),
71
+ agents: path.join(realHomeDir(), '.agents', 'skills', SKILL_NAME),
72
+ opencode: path.join(realHomeDir(), '.config', 'opencode', 'skills', SKILL_NAME),
73
+ openclaw: path.join(realHomeDir(), '.openclaw', 'skills', SKILL_NAME),
74
+ codex: path.join(realHomeDir(), '.codex', 'skills', SKILL_NAME),
25
75
  };
26
76
 
27
77
  const TARGET_NAMES = {
28
78
  hermes: 'Hermes Agent',
29
79
  copilot: 'GitHub Copilot',
80
+ vscode: 'VS Code (Copilot Instructions)',
30
81
  claude: 'Claude Code',
31
82
  cursor: 'Cursor',
32
83
  agents: 'Agents (open standard)',
84
+ opencode: 'OpenCode',
85
+ openclaw: 'OpenClaw',
86
+ codex: 'Codex CLI',
33
87
  };
34
88
 
35
89
  function readBundledSkill() {
@@ -40,21 +94,32 @@ function readBundledSkill() {
40
94
  }
41
95
  }
42
96
 
43
- function readInstalledSkill() {
44
- try {
45
- return fs.readFileSync(HERMES_SKILL_PATH, 'utf-8');
46
- } catch {
47
- return null;
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
+ }
48
116
  }
49
- }
50
117
 
51
- export async function checkSkill() {
52
- // Compare the Hermes-installed copy's version against GitHub (not the npm-bundled copy).
53
- // Uses the YAML frontmatter "version" field so publishing jsn doesn't flag it.
54
- const installed = readInstalledSkill() || readBundledSkill();
55
- if (!installed) return { current: false, error: 'Skill file not found' };
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' };
56
121
 
57
- const installedVersion = extractVersion(installed);
122
+ const installedVersion = extractVersion(primary.content);
58
123
  if (!installedVersion) return { current: false, error: 'No version field in installed skill' };
59
124
 
60
125
  try {
@@ -68,11 +133,30 @@ export async function checkSkill() {
68
133
  if (!upstreamVersion) return { current: false, error: 'No version field in upstream skill' };
69
134
 
70
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
+ }
145
+
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
+
71
150
  return {
72
151
  current,
73
152
  installed_version: installedVersion,
74
153
  upstream_version: upstreamVersion,
75
- error: current ? null : `Skill version ${installedVersion} vs GitHub ${upstreamVersion} — run "jsn skill install" to update`,
154
+ checked: installedCopies.length,
155
+ harnesses: {
156
+ installed: installedCopies.map(c => c.label),
157
+ outdated,
158
+ },
159
+ error: current ? null : outdatedMsg,
76
160
  };
77
161
  } catch {
78
162
  return { current: false, error: 'Could not check — GitHub unreachable' };
@@ -85,6 +169,9 @@ function extractVersion(content) {
85
169
  return m ? m[1] : null;
86
170
  }
87
171
 
172
+ // Exported for testing
173
+ export { realHomeDir, hermesSkillDir, extractVersion, AGENT_SKILL_DIRS, TARGET_NAMES };
174
+
88
175
  export function skillCmd(wrap) {
89
176
  return {
90
177
  command: 'skill',
@@ -112,13 +199,28 @@ export function skillCmd(wrap) {
112
199
  describe: 'Check if the bundled skill file is up to date with GitHub',
113
200
  handler: wrap(async (_argv, app) => {
114
201
  const result = await checkSkill();
202
+ const h = result.harnesses || {};
203
+
204
+ // Build a summary that shows which harnesses are installed and current
205
+ let summary;
115
206
  if (result.error && !result.current) {
116
- app.ok(result, { summary: result.error });
207
+ summary = result.error;
117
208
  } else if (result.current) {
118
- app.ok(result, { summary: `✓ Skill is current (v${result.installed_version})` });
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('');
119
214
  } else {
120
- app.ok(result, { summary: `⚠ Skill outdated: installed v${result.installed_version} vs GitHub v${result.upstream_version} — run "jsn skill install" to update` });
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('');
121
221
  }
222
+
223
+ app.ok(result, { summary });
122
224
  }),
123
225
  })
124
226
  .command({
@@ -148,7 +250,7 @@ export function skillCmd(wrap) {
148
250
  }, {
149
251
  summary: 'Skill file locations and install targets',
150
252
  breadcrumbs: [
151
- { action: 'install', cmd: 'jsn skill install', description: 'Install to Hermes (default)' },
253
+ { action: 'install', cmd: 'jsn skill install', description: 'Interactive picker for install targets' },
152
254
  { action: 'install-all', cmd: 'jsn skill install --target all', description: 'Install to all supported agents' },
153
255
  { action: 'install-copilot', cmd: 'jsn skill install --target copilot', description: 'Install for GitHub Copilot' },
154
256
  { action: 'install-claude', cmd: 'jsn skill install --target claude', description: 'Install for Claude Code' },
@@ -166,8 +268,7 @@ export function skillCmd(wrap) {
166
268
  })
167
269
  .option('target', {
168
270
  type: 'string',
169
- describe: 'Target agent(s): hermes, copilot, claude, cursor, agents, or "all" (comma-separated)',
170
- default: 'hermes',
271
+ describe: 'Target agent(s): hermes, copilot, vscode, claude, cursor, agents, opencode, openclaw, codex, or "all" (comma-separated). Omit for interactive picker.',
171
272
  }),
172
273
  handler: wrap(async (argv, app) => {
173
274
  const res = await fetch(SKILL_RAW_URL);
@@ -184,7 +285,8 @@ export function skillCmd(wrap) {
184
285
  const targetPath = path.join(p, 'SKILL.md');
185
286
  fs.writeFileSync(targetPath, content, 'utf-8');
186
287
  targets.push({ name: path.basename(argv.dir), path: targetPath });
187
- } else {
288
+ } else if (argv.target) {
289
+ // Explicit --target: install to specified agents
188
290
  const rawTargets = argv.target.split(',').map(t => t.trim().toLowerCase());
189
291
  const all = rawTargets.includes('all');
190
292
 
@@ -204,6 +306,80 @@ export function skillCmd(wrap) {
204
306
  }
205
307
  }
206
308
  }
309
+ } else if (process.stdin.isTTY) {
310
+ // Interactive: show multi-select picker
311
+ const fetchedVersion = extractVersion(content);
312
+ // Check if any existing installed copies are outdated
313
+ const outdated = [];
314
+ if (fetchedVersion) {
315
+ for (const [key, dir] of Object.entries(AGENT_SKILL_DIRS)) {
316
+ const skillPath = key === 'cursor'
317
+ ? path.join(dir, `${SKILL_NAME}.mdc`)
318
+ : path.join(dir, 'SKILL.md');
319
+ try {
320
+ const existing = fs.readFileSync(skillPath, 'utf-8');
321
+ const existingVersion = extractVersion(existing);
322
+ if (existingVersion && existingVersion !== fetchedVersion) {
323
+ outdated.push(TARGET_NAMES[key]);
324
+ }
325
+ } catch {
326
+ // Not installed — skip
327
+ }
328
+ }
329
+ }
330
+ const promptMsg = outdated.length > 0
331
+ ? `Where should the jsn skill be installed? (${outdated.length} outdated)`
332
+ : 'Where should the jsn skill be installed?';
333
+
334
+ const { default: checkbox } = await import('@inquirer/checkbox');
335
+ const choices = Object.entries(TARGET_NAMES).map(([key, label]) => {
336
+ const dir = AGENT_SKILL_DIRS[key];
337
+ const targetPath = key === 'cursor'
338
+ ? path.join(dir, `${SKILL_NAME}.mdc`)
339
+ : path.join(dir, 'SKILL.md');
340
+ // Show tildified path for readability
341
+ const home = realHomeDir();
342
+ const displayPath = targetPath.replace(home, '~');
343
+ return {
344
+ name: label,
345
+ value: key,
346
+ description: displayPath,
347
+ // Pre-check agents whose skill dirs already exist
348
+ checked: fs.existsSync(AGENT_SKILL_DIRS[key]),
349
+ };
350
+ });
351
+ const selected = await checkbox({
352
+ message: promptMsg,
353
+ choices,
354
+ instructions: '(space to toggle, enter to confirm)',
355
+ });
356
+
357
+ if (selected.length === 0) {
358
+ // Fall back to Hermes if nothing selected
359
+ selected.push('hermes');
360
+ }
361
+
362
+ for (const key of selected) {
363
+ const dir = AGENT_SKILL_DIRS[key];
364
+ if (key === 'cursor') {
365
+ fs.mkdirSync(dir, { recursive: true });
366
+ const targetPath = path.join(dir, `${SKILL_NAME}.mdc`);
367
+ fs.writeFileSync(targetPath, content, 'utf-8');
368
+ targets.push({ name: TARGET_NAMES[key], path: targetPath });
369
+ } else {
370
+ fs.mkdirSync(dir, { recursive: true });
371
+ const targetPath = path.join(dir, 'SKILL.md');
372
+ fs.writeFileSync(targetPath, content, 'utf-8');
373
+ targets.push({ name: TARGET_NAMES[key], path: targetPath });
374
+ }
375
+ }
376
+ } else {
377
+ // Non-interactive, no --target: install to Hermes only (backward compat)
378
+ const dir = AGENT_SKILL_DIRS.hermes;
379
+ fs.mkdirSync(dir, { recursive: true });
380
+ const targetPath = path.join(dir, 'SKILL.md');
381
+ fs.writeFileSync(targetPath, content, 'utf-8');
382
+ targets.push({ name: TARGET_NAMES.hermes, path: targetPath });
207
383
  }
208
384
 
209
385
  if (targets.length === 0) {
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
5
5
  const NPM_PKG = '@jacebenson/jsn';
6
6
  const NPM_REGISTRY = `https://registry.npmjs.org/${NPM_PKG}/latest`;
7
7
 
8
- function getVersion() {
8
+ export function getVersion() {
9
9
  try {
10
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
11
  const pkgPath = path.join(__dirname, '..', '..', 'package.json');
@@ -16,7 +16,7 @@ function getVersion() {
16
16
  }
17
17
  }
18
18
 
19
- async function checkLatest() {
19
+ export async function checkLatest() {
20
20
  try {
21
21
  const res = await fetch(NPM_REGISTRY);
22
22
  if (!res.ok) return null;