servicemap 0.1.12 → 0.1.14

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.
Files changed (2) hide show
  1. package/bin/servicemap.mjs +173 -26
  2. package/package.json +1 -1
@@ -1,13 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from 'node:child_process';
3
3
  import { existsSync } from 'node:fs';
4
- import { cp, mkdir, readFile, rm } from 'node:fs/promises';
4
+ import { cp, mkdir, readFile, rm, symlink } from 'node:fs/promises';
5
5
  import { dirname, join, resolve } from 'node:path';
6
6
  import { homedir } from 'node:os';
7
7
  import readline from 'node:readline';
8
8
  import { fileURLToPath } from 'node:url';
9
9
 
10
10
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
11
+ const repositoryUrl = 'https://github.com/douglasbrown11/servicemap.git';
12
+ const stableSourceRoot = () => resolve(homedir(), '.servicemap/source');
11
13
  const scanScript = resolve(packageRoot, 'servicemap/scripts/scan-project.mjs');
12
14
  const pageScript = resolve(packageRoot, 'servicemap/scripts/generate-page.mjs');
13
15
  const desktopScript = resolve(packageRoot, 'servicemap/scripts/generate-desktop-app.mjs');
@@ -162,6 +164,7 @@ function parseArgs() {
162
164
  agent: 'codex',
163
165
  targets: null,
164
166
  skillsDir: null,
167
+ installMethod: null,
165
168
  allowedEmails: [],
166
169
  invokedWithoutArgs: args.length === 0,
167
170
  explicitCommand: false,
@@ -190,6 +193,7 @@ function parseArgs() {
190
193
  }
191
194
  if (arg === '--agent') options.agent = args[++index] ?? options.agent;
192
195
  if (arg === '--targets') options.targets = (args[++index] ?? '').split(',').map((target) => target.trim()).filter(Boolean);
196
+ if (arg === '--install-method') options.installMethod = args[++index] ?? null;
193
197
  if (arg === '--allow-email') {
194
198
  options.allowedEmails.push(args[++index] ?? '');
195
199
  options.generationRequested = true;
@@ -217,7 +221,7 @@ Usage:
217
221
  servicemap
218
222
  servicemap generate [--root <project>] [--mode auto|page|desktop] [--allow-emails <list>] [--dry-run]
219
223
  servicemap --root <project> [--mode auto|page|desktop] [--allow-emails <list>] [--dry-run]
220
- servicemap install-skill [--survey] [--agent codex|claude|grok|all] [--targets <ids>]
224
+ servicemap install-skill [--survey] [--agent codex|claude|grok|all] [--targets <ids>] [--install-method copy|symlink]
221
225
  servicemap skills install [--survey] [--agent codex|claude|grok|all]
222
226
 
223
227
  Examples:
@@ -291,20 +295,42 @@ function skillRootsForTargets(options, targets) {
291
295
 
292
296
  const visibleUniversalLimit = 12;
293
297
  const additionalAgentWindowSize = 8;
298
+ const dim = (value) => `\x1b[2m${value}\x1b[22m`;
299
+ const green = (value) => `\x1b[32m${value}\x1b[39m`;
300
+ const cyan = (value) => `\x1b[36m${value}\x1b[39m`;
301
+
302
+ function timelineStep(text) {
303
+ console.log(`${green('◇')} ${text}`);
304
+ console.log(dim('│'));
305
+ }
306
+
307
+ function timelineActive(text) {
308
+ console.log(`${green('◆')} ${text}`);
309
+ console.log(dim('│'));
310
+ }
311
+
312
+ function timelineLine(text = '') {
313
+ console.log(`${dim('│')} ${text}`);
314
+ }
294
315
 
295
316
  function renderSurvey(selected, cursor) {
296
317
  console.clear();
297
- console.log('Which agents do you want to install Servicemap to?\n');
298
- console.log('— Universal (.agents/skills) — always included —');
318
+ timelineStep(cyan('servicemap'));
319
+ timelineStep(`Source: ${repositoryUrl}`);
320
+ timelineStep(`Stable source: ${stableSourceRoot()}`);
321
+ timelineActive('Which agents do you want to install Servicemap to?');
322
+ timelineLine('Universal (.agents/skills) — always included');
299
323
  for (const agent of universalAgents.slice(0, visibleUniversalLimit)) {
300
- console.log(` • ${agent}`);
324
+ timelineLine(`• ${agent}`);
301
325
  }
302
326
  const hiddenUniversalCount = universalAgents.length - visibleUniversalLimit;
303
327
  if (hiddenUniversalCount > 0) {
304
- console.log(` ...and ${hiddenUniversalCount} more`);
328
+ timelineLine(`...and ${hiddenUniversalCount} more`);
305
329
  }
306
- console.log('\n— Additional agents —');
307
- console.log('Use ↑/↓, Space to select, Enter to install.\n');
330
+ timelineLine('');
331
+ timelineLine('Additional agents');
332
+ timelineLine('Use ↑/↓, Space to select, Enter to install.');
333
+ timelineLine('');
308
334
 
309
335
  const halfWindow = Math.floor(additionalAgentWindowSize / 2);
310
336
  let start = Math.max(0, cursor - halfWindow);
@@ -313,19 +339,17 @@ function renderSurvey(selected, cursor) {
313
339
  const beforeCount = start;
314
340
  const afterCount = targetCatalog.length - end;
315
341
 
316
- if (beforeCount > 0) {
317
- console.log(` ↑ ${beforeCount} more`);
318
- }
319
-
320
342
  targetCatalog.slice(start, end).forEach((target, offset) => {
321
343
  const index = start + offset;
322
344
  const pointer = index === cursor ? '>' : ' ';
323
345
  const checked = selected.has(target.id) ? 'x' : ' ';
324
- console.log(`${pointer} [${checked}] ${target.label}`);
346
+ timelineLine(`${pointer} [${checked}] ${target.label} ${dim(`(${target.root()})`)}`);
325
347
  });
326
348
 
327
- if (afterCount > 0) {
328
- console.log(` ↓ ${afterCount} more`);
349
+ if (beforeCount > 0 || afterCount > 0) {
350
+ const before = beforeCount > 0 ? `↑ ${beforeCount} more` : '';
351
+ const after = afterCount > 0 ? `↓ ${afterCount} more` : '';
352
+ timelineLine(dim([before, after].filter(Boolean).join(' ')));
329
353
  }
330
354
 
331
355
  if (selected.size > 0) {
@@ -334,7 +358,8 @@ function renderSurvey(selected, cursor) {
334
358
  .map((target) => target.label);
335
359
  const preview = selectedLabels.slice(0, 3).join(', ');
336
360
  const remainder = selectedLabels.length > 3 ? ` +${selectedLabels.length - 3} more` : '';
337
- console.log(`\nSelected: ${preview}${remainder}`);
361
+ timelineLine('');
362
+ timelineLine(`Selected: ${preview}${remainder}`);
338
363
  }
339
364
  }
340
365
 
@@ -379,12 +404,134 @@ function runTargetSurvey() {
379
404
  });
380
405
  }
381
406
 
407
+ function renderInstallMethodSurvey(cursor) {
408
+ console.clear();
409
+ const methods = [
410
+ { id: 'symlink', label: 'Symlink', note: 'Recommended, one stable source for easy updates' },
411
+ { id: 'copy', label: 'Copy', note: 'Standalone copies in each selected agent folder' },
412
+ ];
413
+
414
+ timelineStep(cyan('servicemap'));
415
+ timelineStep(`Source: ${repositoryUrl}`);
416
+ timelineStep(`Stable source: ${stableSourceRoot()}`);
417
+ timelineStep('Agents selected');
418
+ timelineActive('Installation method');
419
+ methods.forEach((method, index) => {
420
+ const pointer = index === cursor ? '>' : ' ';
421
+ const selected = index === cursor ? '●' : '○';
422
+ timelineLine(`${pointer} ${selected} ${method.label} ${dim(`(${method.note})`)}`);
423
+ });
424
+ timelineLine(dim('↑/↓ to navigate • Enter to confirm'));
425
+ }
426
+
427
+ function runInstallMethodSurvey() {
428
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
429
+ return Promise.resolve('copy');
430
+ }
431
+
432
+ const methods = ['symlink', 'copy'];
433
+ let cursor = 0;
434
+
435
+ return new Promise((resolvePromise) => {
436
+ readline.emitKeypressEvents(process.stdin);
437
+ process.stdin.setRawMode(true);
438
+ renderInstallMethodSurvey(cursor);
439
+
440
+ const onKeypress = (_str, key) => {
441
+ if (key.name === 'up') cursor = Math.max(0, cursor - 1);
442
+ if (key.name === 'down') cursor = Math.min(methods.length - 1, cursor + 1);
443
+ if (key.name === 'return') {
444
+ process.stdin.setRawMode(false);
445
+ process.stdin.off('keypress', onKeypress);
446
+ console.log('');
447
+ resolvePromise(methods[cursor]);
448
+ return;
449
+ }
450
+ if (key.ctrl && key.name === 'c') {
451
+ process.stdin.setRawMode(false);
452
+ process.stdin.off('keypress', onKeypress);
453
+ process.exit(130);
454
+ }
455
+
456
+ renderInstallMethodSurvey(cursor);
457
+ };
458
+
459
+ process.stdin.on('keypress', onKeypress);
460
+ });
461
+ }
462
+
463
+ function runSilent(command, args, options = {}) {
464
+ return new Promise((resolvePromise, reject) => {
465
+ const child = spawn(command, args, {
466
+ stdio: 'ignore',
467
+ ...options,
468
+ });
469
+
470
+ child.on('error', reject);
471
+ child.on('exit', (code) => {
472
+ if (code === 0) resolvePromise();
473
+ else reject(new Error(`${command} ${args.join(' ')} exited with code ${code}`));
474
+ });
475
+ });
476
+ }
477
+
478
+ async function prepareStableSource(options) {
479
+ const sourceRoot = stableSourceRoot();
480
+ if (options.dryRun) {
481
+ timelineStep(`Would prepare stable source at ${sourceRoot}`);
482
+ return resolve(sourceRoot, 'servicemap');
483
+ }
484
+
485
+ timelineStep(`Source: ${repositoryUrl}`);
486
+ await mkdir(dirname(sourceRoot), { recursive: true });
487
+
488
+ try {
489
+ if (existsSync(join(sourceRoot, '.git'))) {
490
+ await runSilent('git', ['pull', '--ff-only'], { cwd: sourceRoot });
491
+ timelineStep('Repository updated');
492
+ } else {
493
+ await rm(sourceRoot, { recursive: true, force: true });
494
+ await runSilent('git', ['clone', '--depth', '1', repositoryUrl, sourceRoot]);
495
+ timelineStep('Repository cloned');
496
+ }
497
+ } catch (_error) {
498
+ await rm(sourceRoot, { recursive: true, force: true });
499
+ await cp(packageRoot, sourceRoot, { recursive: true });
500
+ timelineStep('Repository unavailable; copied packaged source');
501
+ }
502
+
503
+ return resolve(sourceRoot, 'servicemap');
504
+ }
505
+
506
+ async function installIntoRoot(root, source, method, options) {
507
+ const destination = join(root, 'servicemap');
508
+ if (options.dryRun) {
509
+ const verb = method === 'symlink' ? 'link' : 'copy';
510
+ console.log(`Would ${verb} Servicemap skill to ${destination}`);
511
+ return;
512
+ }
513
+
514
+ await mkdir(root, { recursive: true });
515
+ await rm(destination, { recursive: true, force: true });
516
+
517
+ if (method === 'symlink') {
518
+ await symlink(source, destination, 'dir');
519
+ console.log(`Linked Servicemap skill to ${destination}`);
520
+ return;
521
+ }
522
+
523
+ await cp(source, destination, { recursive: true });
524
+ console.log(`Installed Servicemap skill to ${destination}`);
525
+ }
526
+
382
527
  async function installSkill(options) {
383
- const source = resolve(packageRoot, 'servicemap');
528
+ let source = resolve(packageRoot, 'servicemap');
384
529
  const selectedTargets = options.survey && !options.yes
385
530
  ? [{ id: 'universal', label: 'Universal', root: universalRoot }, ...await runTargetSurvey()]
386
531
  : targetsForOptions(options);
387
532
  const roots = skillRootsForTargets(options, selectedTargets);
533
+ const method = options.installMethod
534
+ ?? (options.survey && !options.yes ? await runInstallMethodSurvey() : 'copy');
388
535
 
389
536
  if (!existsSync(source)) {
390
537
  throw new Error(`Bundled skill folder not found: ${source}`);
@@ -395,19 +542,19 @@ async function installSkill(options) {
395
542
  return;
396
543
  }
397
544
 
398
- if (options.dryRun) {
399
- for (const root of roots) {
400
- console.log(`Would install Servicemap skill to ${join(root, 'servicemap')}`);
545
+ if (!['copy', 'symlink'].includes(method)) {
546
+ throw new Error(`Unknown install method "${method}". Use copy or symlink.`);
547
+ }
548
+
549
+ if (method === 'symlink') {
550
+ source = await prepareStableSource(options);
551
+ if (!existsSync(source) && !options.dryRun) {
552
+ throw new Error(`Stable skill source not found: ${source}`);
401
553
  }
402
- return;
403
554
  }
404
555
 
405
556
  for (const root of roots) {
406
- const destination = join(root, 'servicemap');
407
- await mkdir(root, { recursive: true });
408
- await rm(destination, { recursive: true, force: true });
409
- await cp(source, destination, { recursive: true });
410
- console.log(`Installed Servicemap skill to ${destination}`);
557
+ await installIntoRoot(root, source, method, options);
411
558
  }
412
559
 
413
560
  console.log('Restart your AI app if /servicemap does not appear immediately.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "servicemap",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Generate an internal service map page or desktop app for a software project.",
5
5
  "type": "module",
6
6
  "bin": {