@chris1807/claude-kit 2.1.17 → 2.1.18

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/bin/cli.js CHANGED
@@ -11,6 +11,8 @@ const __filename = fileURLToPath(import.meta.url);
11
11
  const __dirname = dirname(__filename);
12
12
  const TEMPLATES_DIR = join(__dirname, '..', 'templates');
13
13
  const GLOBAL_CLAUDE_DIR = join(process.env.HOME || process.env.USERPROFILE, '.claude');
14
+ const PKG = await fs.readJson(join(__dirname, '..', 'package.json'));
15
+ const KIT_VERSION = PKG.version;
14
16
 
15
17
  // ============================================================================
16
18
  // CLI Arguments
@@ -110,6 +112,47 @@ async function installFile(src, dest, label) {
110
112
  return true;
111
113
  }
112
114
 
115
+ // ============================================================================
116
+ // Helper: additive merge for settings.json
117
+ // ============================================================================
118
+ // Top-level keys: keep user's value if present, add kit's only if missing.
119
+ // `hooks`: union of matcher blocks; within each block, union of hook commands
120
+ // (compared by `command` string). Never removes user-added entries.
121
+ function mergeSettings(existing, template) {
122
+ const merged = { ...existing };
123
+ for (const [key, value] of Object.entries(template)) {
124
+ if (key === 'hooks') {
125
+ merged.hooks = mergeHooks(existing.hooks || {}, value);
126
+ } else if (!(key in merged)) {
127
+ merged[key] = value;
128
+ }
129
+ }
130
+ return merged;
131
+ }
132
+
133
+ function mergeHooks(existing, template) {
134
+ const merged = { ...existing };
135
+ for (const [event, templateBlocks] of Object.entries(template)) {
136
+ const resultBlocks = [...(merged[event] || [])];
137
+ for (const tplBlock of templateBlocks) {
138
+ const idx = resultBlocks.findIndex(b => b.matcher === tplBlock.matcher);
139
+ if (idx === -1) {
140
+ resultBlocks.push(tplBlock);
141
+ } else {
142
+ const newHooks = [...(resultBlocks[idx].hooks || [])];
143
+ for (const tplHook of tplBlock.hooks || []) {
144
+ if (!newHooks.some(h => h.command === tplHook.command)) {
145
+ newHooks.push(tplHook);
146
+ }
147
+ }
148
+ resultBlocks[idx] = { ...resultBlocks[idx], hooks: newHooks };
149
+ }
150
+ }
151
+ merged[event] = resultBlocks;
152
+ }
153
+ return merged;
154
+ }
155
+
113
156
  // ============================================================================
114
157
  // Step 1: Global Agents (always)
115
158
  // ============================================================================
@@ -243,6 +286,7 @@ async function main() {
243
286
  // ── MCP Servers (interactive selection) ───────────────────────────────
244
287
  let dbType = 'none';
245
288
  let selectedServers = [];
289
+ let adoOrgChoice = null;
246
290
  if (components.includes('mcp')) {
247
291
  console.log(chalk.yellow.bold('\n🔌 MCP Servers → .mcp.json\n'));
248
292
 
@@ -379,6 +423,7 @@ async function main() {
379
423
 
380
424
  dbType = mcpChoices.db;
381
425
  selectedServers = mcpChoices.servers;
426
+ adoOrgChoice = mcpChoices.adoOrg || null;
382
427
 
383
428
  const mcpPath = join(targetDir, '.mcp.json');
384
429
  if (await fs.pathExists(mcpPath)) {
@@ -388,15 +433,16 @@ async function main() {
388
433
  if (newContent.trim() === existingContent.trim()) {
389
434
  console.log(chalk.gray(' = .mcp.json (identical, skipped)'));
390
435
  } else {
436
+ // In --all mode, merge by default — preserves user-added MCP servers.
391
437
  const { action } = installAll
392
- ? { action: 'overwrite' }
438
+ ? { action: 'merge' }
393
439
  : await inquirer.prompt([{
394
440
  type: 'list',
395
441
  name: 'action',
396
442
  message: '.mcp.json already exists:',
397
443
  choices: [
444
+ { name: 'Merge (add missing servers, keep yours)', value: 'merge' },
398
445
  { name: 'Overwrite with new config', value: 'overwrite' },
399
- { name: 'Merge (add missing servers)', value: 'merge' },
400
446
  { name: 'Skip', value: 'skip' },
401
447
  ],
402
448
  }]);
@@ -422,11 +468,27 @@ async function main() {
422
468
  // ── Settings ──────────────────────────────────────────────────────────
423
469
  if (components.includes('settings')) {
424
470
  console.log(chalk.yellow.bold('\n⚙️ Settings → .claude/settings.json\n'));
425
- await installFile(
426
- join(TEMPLATES_DIR, 'infrastructure', 'settings.json'),
427
- join(targetDir, '.claude', 'settings.json'),
428
- 'settings.json'
471
+ const templateSettings = await fs.readJson(
472
+ join(TEMPLATES_DIR, 'infrastructure', 'settings.json')
429
473
  );
474
+ const settingsPath = join(targetDir, '.claude', 'settings.json');
475
+ await fs.ensureDir(dirname(settingsPath));
476
+
477
+ if (!await fs.pathExists(settingsPath)) {
478
+ await fs.writeJson(settingsPath, templateSettings, { spaces: 2 });
479
+ console.log(chalk.green(' ✓ settings.json'));
480
+ } else {
481
+ const existing = await fs.readJson(settingsPath);
482
+ const merged = mergeSettings(existing, templateSettings);
483
+ const before = JSON.stringify(existing);
484
+ const after = JSON.stringify(merged);
485
+ if (before === after) {
486
+ console.log(chalk.gray(' = settings.json (no kit changes needed)'));
487
+ } else {
488
+ await fs.writeJson(settingsPath, merged, { spaces: 2 });
489
+ console.log(chalk.green(' ✓ settings.json (merged — kit hooks added, user customizations preserved)'));
490
+ }
491
+ }
430
492
  }
431
493
 
432
494
  // ── CLAUDE.md Workflow ────────────────────────────────────────────────
@@ -468,18 +530,27 @@ async function main() {
468
530
  existing = await fs.readFile(claudeMdPath, 'utf8');
469
531
 
470
532
  if (existing.includes('Claude Kit Workflow') || existing.includes('Care Solutions AI Workflow')) {
471
- console.log(chalk.gray(' = Workflow section already exists'));
472
- if (!installAll) {
533
+ // Check whether the existing workflow section matches the current template.
534
+ // If it does, nothing to do. If not, replace it (auto in --all, prompt otherwise).
535
+ const cleaned = existing.replace(/\n## (?:Claude Kit Workflow|Care Solutions AI Workflow)[\s\S]*$/, '').trimEnd();
536
+ const expected = `${cleaned}\n\n${workflowContent}`;
537
+ if (existing.trim() === expected.trim()) {
538
+ console.log(chalk.gray(' = Workflow section up to date'));
539
+ } else if (installAll) {
540
+ await fs.writeFile(claudeMdPath, expected);
541
+ console.log(chalk.green(' ✓ Workflow section re-synced to current template'));
542
+ } else {
473
543
  const { replace } = await inquirer.prompt([{
474
544
  type: 'confirm',
475
545
  name: 'replace',
476
- message: 'Replace existing workflow section?',
477
- default: false,
546
+ message: 'Workflow section differs from template — replace?',
547
+ default: true,
478
548
  }]);
479
549
  if (replace) {
480
- const cleaned = existing.replace(/\n## (?:Claude Kit Workflow|Care Solutions AI Workflow)[\s\S]*$/, '').trimEnd();
481
- await fs.writeFile(claudeMdPath, `${cleaned}\n\n${workflowContent}`);
550
+ await fs.writeFile(claudeMdPath, expected);
482
551
  console.log(chalk.green(' ✓ Workflow section replaced'));
552
+ } else {
553
+ console.log(chalk.gray(' ⊘ Workflow section left as-is'));
483
554
  }
484
555
  }
485
556
  } else {
@@ -518,6 +589,19 @@ async function main() {
518
589
  }
519
590
  }
520
591
 
592
+ // ── Install Manifest ──────────────────────────────────────────────────
593
+ // Records the kit version and the choices made (db, adoOrg) so the
594
+ // kit-update-check hook can re-run the installer non-interactively.
595
+ const manifestPath = join(targetDir, '.claude', '.kit-install.json');
596
+ await fs.ensureDir(dirname(manifestPath));
597
+ await fs.writeJson(manifestPath, {
598
+ version: KIT_VERSION,
599
+ choices: {
600
+ db: dbType,
601
+ adoOrg: selectedServers.includes('azuredevops') ? adoOrgChoice : null,
602
+ },
603
+ }, { spaces: 2 });
604
+
521
605
  // ── Summary ───────────────────────────────────────────────────────────
522
606
  const agentCount = (await fs.pathExists(join(targetDir, '.claude', 'agents')))
523
607
  ? (await fs.readdir(join(targetDir, '.claude', 'agents'))).filter(f => f.endsWith('.md')).length
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chris1807/claude-kit",
3
- "version": "2.1.17",
3
+ "version": "2.1.18",
4
4
  "description": "Claude Code starter kit for Azure DevOps teams — agents, hooks, MCP servers, slash commands, and end-to-end work item → PR → release → deploy workflow automation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env bash
2
+ # kit-update-check.sh — SessionStart hook
3
+ #
4
+ # Checks npm for a newer @chris1807/claude-kit and re-runs the installer
5
+ # non-interactively when one is available. Must never block claude startup —
6
+ # every external call is timeout-bounded and every failure exits 0 silently.
7
+ #
8
+ # Throttled to once per 24h via a marker file. Reads .claude/.kit-install.json
9
+ # for the installed version and the choices to pass to the re-install (db, adoOrg).
10
+
11
+ set +e # never propagate failures
12
+
13
+ HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
14
+ CLAUDE_DIR="$(cd "$HOOK_DIR/.." && pwd)"
15
+ PROJECT_DIR="$(cd "$CLAUDE_DIR/.." && pwd)"
16
+ MANIFEST="$CLAUDE_DIR/.kit-install.json"
17
+ MARKER="$CLAUDE_DIR/.kit-update-check"
18
+ LOG="$CLAUDE_DIR/.kit-update.log"
19
+
20
+ # No manifest = legacy install or global-only; nothing to do.
21
+ [ -f "$MANIFEST" ] || exit 0
22
+
23
+ # Throttle to one check per 24h. A failed check still touches the marker so
24
+ # unreachable npm doesn't retry every session.
25
+ if [ -f "$MARKER" ]; then
26
+ if command -v stat >/dev/null 2>&1; then
27
+ LAST=$(stat -f %m "$MARKER" 2>/dev/null || stat -c %Y "$MARKER" 2>/dev/null || echo 0)
28
+ NOW=$(date +%s)
29
+ if [ "$((NOW - LAST))" -lt 86400 ]; then
30
+ exit 0
31
+ fi
32
+ fi
33
+ fi
34
+
35
+ # Crude JSON extraction (avoids requiring jq).
36
+ extract() {
37
+ grep -o "\"$1\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$MANIFEST" | head -1 | sed 's/.*"\([^"]*\)"$/\1/'
38
+ }
39
+ INSTALLED=$(extract version)
40
+ DB=$(extract db)
41
+ ADO_ORG=$(extract adoOrg)
42
+
43
+ # Fetch latest with a 5s timeout — must never hang. `timeout` isn't on macOS
44
+ # by default; perl's alarm() is the most portable fallback.
45
+ if command -v timeout >/dev/null 2>&1; then
46
+ LATEST=$(timeout 5 npm view @chris1807/claude-kit version 2>/dev/null)
47
+ elif command -v gtimeout >/dev/null 2>&1; then
48
+ LATEST=$(gtimeout 5 npm view @chris1807/claude-kit version 2>/dev/null)
49
+ elif command -v perl >/dev/null 2>&1; then
50
+ LATEST=$(perl -e 'alarm 5; exec @ARGV or exit 1' npm view @chris1807/claude-kit version 2>/dev/null)
51
+ else
52
+ # No timeout mechanism available — skip rather than risk a hang.
53
+ touch "$MARKER"
54
+ exit 0
55
+ fi
56
+
57
+ touch "$MARKER"
58
+
59
+ [ -z "$LATEST" ] && exit 0
60
+ [ -z "$INSTALLED" ] && exit 0
61
+ [ "$LATEST" = "$INSTALLED" ] && exit 0
62
+
63
+ # Only proceed if LATEST sorts strictly greater than INSTALLED.
64
+ NEWER=$(printf '%s\n%s\n' "$INSTALLED" "$LATEST" | sort -V | tail -1)
65
+ [ "$NEWER" = "$LATEST" ] || exit 0
66
+ [ "$NEWER" = "$INSTALLED" ] && exit 0
67
+
68
+ echo "claude-kit: updating $INSTALLED → $LATEST..." >&2
69
+
70
+ ARGS=("@chris1807/claude-kit" "init" "$PROJECT_DIR" "--all")
71
+ if [ -n "$DB" ] && [ "$DB" != "null" ]; then
72
+ ARGS+=("--db=$DB")
73
+ fi
74
+ if [ -n "$ADO_ORG" ] && [ "$ADO_ORG" != "null" ]; then
75
+ ARGS+=("--ado-org=$ADO_ORG")
76
+ fi
77
+
78
+ if npx -y "${ARGS[@]}" >"$LOG" 2>&1; then
79
+ echo "claude-kit: updated to $LATEST (log: $LOG)" >&2
80
+ else
81
+ echo "claude-kit: update failed (see $LOG)" >&2
82
+ fi
83
+
84
+ exit 0
@@ -89,6 +89,16 @@
89
89
  }
90
90
  ]
91
91
  }
92
+ ],
93
+ "SessionStart": [
94
+ {
95
+ "hooks": [
96
+ {
97
+ "type": "command",
98
+ "command": "bash .claude/hooks/kit-update-check.sh"
99
+ }
100
+ ]
101
+ }
92
102
  ]
93
103
  }
94
104
  }