chief-clancy 0.2.0-beta.2 → 0.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Autonomous, board-driven development for Claude Code.**
4
4
 
5
- [![npm](https://img.shields.io/npm/v/chief-clancy?color=cb3837)](https://www.npmjs.com/package/chief-clancy) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE) [![Tests](https://img.shields.io/badge/tests-55%20passing-brightgreen)](./test/) [![GitHub Stars](https://img.shields.io/github/stars/Pushedskydiver/clancy?style=flat)](https://github.com/Pushedskydiver/clancy/stargazers)
5
+ [![npm](https://img.shields.io/npm/v/chief-clancy?style=for-the-badge&color=cb3837)](https://www.npmjs.com/package/chief-clancy) [![License: MIT](https://img.shields.io/badge/License-MIT-blue?style=for-the-badge)](./LICENSE) [![Tests](https://img.shields.io/badge/tests-94%20passing-brightgreen?style=for-the-badge)](./test/) [![GitHub Stars](https://img.shields.io/github/stars/Pushedskydiver/clancy?style=for-the-badge)](https://github.com/Pushedskydiver/clancy/stargazers)
6
6
 
7
7
  ```bash
8
8
  npx chief-clancy
@@ -192,7 +192,7 @@ Clancy also merges a section into your `CLAUDE.md` (or creates one) that tells C
192
192
 
193
193
  ## Optional enhancements
194
194
 
195
- Set during `/clancy:init` advanced setup, or by editing `.clancy/.env` directly.
195
+ Set during `/clancy:init` advanced setup, or by editing `.clancy/.env` directly. Use `/clancy:settings` → "Save as defaults" to save non-credential settings to `~/.clancy/defaults.json` — new projects created with `/clancy:init` will inherit them automatically.
196
196
 
197
197
  ### Figma MCP
198
198
 
@@ -336,6 +336,12 @@ Your board tokens and API keys live in `.clancy/.env`. Although Claude doesn't n
336
336
 
337
337
  This prevents Claude from reading these files regardless of what commands run. Clancy automatically adds `.clancy/.env` to `.gitignore` during init, but the deny list is an additional layer.
338
338
 
339
+ ### Credential guard
340
+
341
+ Clancy installs a `PreToolUse` hook (`clancy-credential-guard.js`) that scans every Write, Edit, and MultiEdit operation for credential patterns — API keys, tokens, passwords, private keys, and connection strings. If a match is found, the operation is blocked with a message telling Claude to move the credential to `.clancy/.env` instead. Files that are expected to contain credentials (`.clancy/.env`, `.env.example`, etc.) are exempt.
342
+
343
+ This is best-effort — it won't catch every possible credential format, but it prevents the most common accidental leaks.
344
+
339
345
  ### Token scopes
340
346
 
341
347
  Use the minimum permissions each integration requires:
@@ -418,6 +424,8 @@ lsof -ti:5173 | xargs kill -9 # replace 5173 with your PLAYWRIGHT_DEV_PORT
418
424
 
419
425
  Or directly: `npx chief-clancy@latest`
420
426
 
427
+ The update workflow shows what's changed (changelog diff) and asks for confirmation before overwriting. If you've customised any command or workflow files, they're automatically backed up to `.claude/clancy/local-patches/` before the update — check there to reapply your changes afterwards.
428
+
421
429
  ---
422
430
 
423
431
  **Uninstalling?**
@@ -426,7 +434,7 @@ Or directly: `npx chief-clancy@latest`
426
434
  /clancy:uninstall
427
435
  ```
428
436
 
429
- Removes slash commands from your chosen location. Optionally removes `.clancy/` (credentials and docs). Never touches `CLAUDE.md`.
437
+ Removes slash commands from your chosen location. Cleans up the `<!-- clancy:start -->` / `<!-- clancy:end -->` block from `CLAUDE.md` (or deletes it entirely if Clancy created it) and removes the `.clancy/.env` entry from `.gitignore`. Optionally removes `.clancy/` (credentials and docs).
430
438
 
431
439
  ---
432
440
 
package/bin/install.js CHANGED
@@ -49,6 +49,13 @@ async function choose(question, options, defaultChoice = 1) {
49
49
  return raw.trim() || String(defaultChoice);
50
50
  }
51
51
 
52
+ const crypto = require('crypto');
53
+
54
+ function fileHash(filePath) {
55
+ const content = fs.readFileSync(filePath);
56
+ return crypto.createHash('sha256').digest('hex', content);
57
+ }
58
+
52
59
  function copyDir(src, dest) {
53
60
  // Use lstatSync (not statSync) to detect symlinks — statSync follows them and misreports
54
61
  if (fs.existsSync(dest)) {
@@ -65,6 +72,73 @@ function copyDir(src, dest) {
65
72
  }
66
73
  }
67
74
 
75
+ /**
76
+ * Build a manifest of installed files with SHA-256 hashes.
77
+ * Format: { "relative/path.md": "<sha256>", ... }
78
+ */
79
+ function buildManifest(baseDir) {
80
+ const manifest = {};
81
+ function walk(dir, prefix) {
82
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
83
+ const full = path.join(dir, entry.name);
84
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
85
+ if (entry.isDirectory()) {
86
+ walk(full, rel);
87
+ } else {
88
+ const content = fs.readFileSync(full);
89
+ manifest[rel] = crypto.createHash('sha256').update(content).digest('hex');
90
+ }
91
+ }
92
+ }
93
+ walk(baseDir, '');
94
+ return manifest;
95
+ }
96
+
97
+ /**
98
+ * Detect files modified by the user since last install by comparing
99
+ * current file hashes against the stored manifest. Returns array of
100
+ * { rel, absPath } for modified files.
101
+ */
102
+ function detectModifiedFiles(baseDir, manifestPath) {
103
+ if (!fs.existsSync(manifestPath)) return [];
104
+ let manifest;
105
+ try {
106
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
107
+ } catch { return []; }
108
+
109
+ const modified = [];
110
+ for (const [rel, hash] of Object.entries(manifest)) {
111
+ const absPath = path.join(baseDir, rel);
112
+ if (!fs.existsSync(absPath)) continue;
113
+ const content = fs.readFileSync(absPath);
114
+ const currentHash = crypto.createHash('sha256').update(content).digest('hex');
115
+ if (currentHash !== hash) {
116
+ modified.push({ rel, absPath });
117
+ }
118
+ }
119
+ return modified;
120
+ }
121
+
122
+ /**
123
+ * Back up modified files to a patches directory alongside the install.
124
+ * Returns the backup directory path if any files were backed up.
125
+ */
126
+ function backupModifiedFiles(modified, patchesDir) {
127
+ if (modified.length === 0) return null;
128
+ fs.mkdirSync(patchesDir, { recursive: true });
129
+ for (const { rel, absPath } of modified) {
130
+ const backupPath = path.join(patchesDir, rel);
131
+ fs.mkdirSync(path.dirname(backupPath), { recursive: true });
132
+ fs.copyFileSync(absPath, backupPath);
133
+ }
134
+ // Write metadata so /clancy:update workflow knows what was backed up
135
+ fs.writeFileSync(
136
+ path.join(patchesDir, 'backup-meta.json'),
137
+ JSON.stringify({ backed_up: modified.map(m => m.rel), date: new Date().toISOString() }, null, 2)
138
+ );
139
+ return patchesDir;
140
+ }
141
+
68
142
  async function main() {
69
143
  console.log('');
70
144
  console.log(blue(' ██████╗██╗ █████╗ ███╗ ██╗ ██████╗██╗ ██╗'));
@@ -116,14 +190,42 @@ async function main() {
116
190
  console.log(dim(` Installing to: ${dest}`));
117
191
 
118
192
  try {
193
+ // Determine manifest and patches paths (sibling to commands dir)
194
+ const claudeDir = path.dirname(path.dirname(dest)); // .claude/ (parent of commands/)
195
+ const manifestPath = path.join(claudeDir, 'clancy', 'manifest.json');
196
+ const patchesDir = path.join(claudeDir, 'clancy', 'local-patches');
197
+
119
198
  if (fs.existsSync(dest) || fs.existsSync(workflowsDest)) {
120
199
  console.log('');
200
+
201
+ // Detect user-modified files before overwriting
202
+ const modified = detectModifiedFiles(dest, manifestPath);
203
+ const modifiedWorkflows = detectModifiedFiles(workflowsDest, manifestPath.replace('manifest.json', 'workflows-manifest.json'));
204
+ const allModified = [...modified, ...modifiedWorkflows];
205
+
206
+ if (allModified.length > 0) {
207
+ console.log(blue(' Modified files detected:'));
208
+ for (const { rel } of allModified) {
209
+ console.log(` ${dim('•')} ${rel}`);
210
+ }
211
+ console.log('');
212
+ console.log(dim(' These will be backed up to .claude/clancy/local-patches/'));
213
+ console.log(dim(' before overwriting. You can reapply them after the update.'));
214
+ console.log('');
215
+ }
216
+
121
217
  const overwrite = await ask(blue(` Commands already exist at ${dest}. Overwrite? [y/N] `));
122
218
  if (!overwrite.trim().toLowerCase().startsWith('y')) {
123
219
  console.log('\n Aborted. No files changed.');
124
220
  rl.close();
125
221
  process.exit(0);
126
222
  }
223
+
224
+ // Back up modified files before overwriting
225
+ if (allModified.length > 0) {
226
+ backupModifiedFiles(allModified, patchesDir);
227
+ console.log(green(`\n ✓ ${allModified.length} modified file(s) backed up to local-patches/`));
228
+ }
127
229
  }
128
230
 
129
231
  copyDir(COMMANDS_SRC, dest);
@@ -150,6 +252,14 @@ async function main() {
150
252
  // Write VERSION file so /clancy:doctor and /clancy:update can read the installed version
151
253
  fs.writeFileSync(path.join(dest, 'VERSION'), PKG.version);
152
254
 
255
+ // Write manifests so future updates can detect user-modified files
256
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
257
+ fs.writeFileSync(manifestPath, JSON.stringify(buildManifest(dest), null, 2));
258
+ fs.writeFileSync(
259
+ manifestPath.replace('manifest.json', 'workflows-manifest.json'),
260
+ JSON.stringify(buildManifest(workflowsDest), null, 2)
261
+ );
262
+
153
263
  // Install hooks and register them in Claude settings.json
154
264
  const claudeConfigDir = dest === GLOBAL_DEST
155
265
  ? path.join(homeDir, '.claude')
@@ -161,6 +271,7 @@ async function main() {
161
271
  'clancy-check-update.js',
162
272
  'clancy-statusline.js',
163
273
  'clancy-context-monitor.js',
274
+ 'clancy-credential-guard.js',
164
275
  ];
165
276
 
166
277
  try {
@@ -196,9 +307,11 @@ async function main() {
196
307
  const updateScript = path.join(hooksInstallDir, 'clancy-check-update.js');
197
308
  const statuslineScript = path.join(hooksInstallDir, 'clancy-statusline.js');
198
309
  const monitorScript = path.join(hooksInstallDir, 'clancy-context-monitor.js');
310
+ const guardScript = path.join(hooksInstallDir, 'clancy-credential-guard.js');
199
311
 
200
312
  registerHook('SessionStart', `node ${updateScript}`);
201
313
  registerHook('PostToolUse', `node ${monitorScript}`);
314
+ registerHook('PreToolUse', `node ${guardScript}`);
202
315
 
203
316
  // Statusline: registered as top-level key, not inside hooks
204
317
  if (!settings.statusLine) {
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+ // Clancy Credential Guard — PreToolUse hook.
3
+ // Scans file content being written or edited for credential patterns
4
+ // (API keys, tokens, passwords, private keys) and blocks the operation
5
+ // if a match is found. Best-effort — never fails the tool call on error.
6
+
7
+ 'use strict';
8
+
9
+ const CREDENTIAL_PATTERNS = [
10
+ // Generic API keys and tokens
11
+ { name: 'Generic API key', pattern: /(?:api[_-]?key|apikey)\s*[:=]\s*["']?[A-Za-z0-9\-_.]{20,}["']?/i },
12
+ { name: 'Generic secret', pattern: /(?:secret|private[_-]?key)\s*[:=]\s*["']?[A-Za-z0-9\-_.]{20,}["']?/i },
13
+ { name: 'Generic token', pattern: /(?:auth[_-]?token|access[_-]?token|bearer)\s*[:=]\s*["']?[A-Za-z0-9\-_.]{20,}["']?/i },
14
+ { name: 'Generic password', pattern: /(?:password|passwd|pwd)\s*[:=]\s*["']?[^\s"']{8,}["']?/i },
15
+
16
+ // AWS
17
+ { name: 'AWS Access Key', pattern: /AKIA[0-9A-Z]{16}/ },
18
+ { name: 'AWS Secret Key', pattern: /(?:aws_secret_access_key|aws_secret)\s*[:=]\s*["']?[A-Za-z0-9/+=]{40}["']?/i },
19
+
20
+ // GitHub
21
+ { name: 'GitHub PAT (classic)', pattern: /ghp_[A-Za-z0-9]{36}/ },
22
+ { name: 'GitHub PAT (fine-grained)', pattern: /github_pat_[A-Za-z0-9_]{82}/ },
23
+ { name: 'GitHub OAuth token', pattern: /gho_[A-Za-z0-9]{36}/ },
24
+
25
+ // Slack
26
+ { name: 'Slack token', pattern: /xox[bpors]-[0-9]{10,}-[A-Za-z0-9-]+/ },
27
+
28
+ // Stripe
29
+ { name: 'Stripe key', pattern: /(?:sk|pk)_(?:live|test)_[A-Za-z0-9]{24,}/ },
30
+
31
+ // Private keys
32
+ { name: 'Private key', pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/ },
33
+
34
+ // Jira/Atlassian API tokens (base64-like, 24+ chars after the prefix)
35
+ { name: 'Atlassian API token', pattern: /(?:jira_api_token|atlassian[_-]?token)\s*[:=]\s*["']?[A-Za-z0-9+/=]{24,}["']?/i },
36
+
37
+ // Linear API key
38
+ { name: 'Linear API key', pattern: /lin_api_[A-Za-z0-9]{40,}/ },
39
+
40
+ // Generic connection strings
41
+ { name: 'Database connection string', pattern: /(?:mongodb|postgres|mysql|redis):\/\/[^\s"']+:[^\s"']+@/i },
42
+ ];
43
+
44
+ // Files that are expected to contain credentials — skip them
45
+ const ALLOWED_PATHS = [
46
+ '.clancy/.env',
47
+ '.env.local',
48
+ '.env.example',
49
+ '.env.development',
50
+ '.env.test',
51
+ ];
52
+
53
+ function isAllowedPath(filePath) {
54
+ if (!filePath) return false;
55
+ return ALLOWED_PATHS.some(allowed => filePath.endsWith(allowed));
56
+ }
57
+
58
+ function scanForCredentials(content) {
59
+ if (!content || typeof content !== 'string') return [];
60
+ const matches = [];
61
+ for (const { name, pattern } of CREDENTIAL_PATTERNS) {
62
+ if (pattern.test(content)) {
63
+ matches.push(name);
64
+ }
65
+ }
66
+ return matches;
67
+ }
68
+
69
+ try {
70
+ const input = JSON.parse(process.argv[2] || '{}');
71
+ const toolName = input.tool_name || '';
72
+ const toolInput = input.tool_input || {};
73
+
74
+ // Only check file-writing tools
75
+ if (!['Write', 'Edit', 'MultiEdit'].includes(toolName)) {
76
+ // Pass through — not a file write
77
+ console.log(JSON.stringify({ decision: 'approve' }));
78
+ process.exit(0);
79
+ }
80
+
81
+ const filePath = toolInput.file_path || '';
82
+
83
+ // Skip files that are expected to contain credentials
84
+ if (isAllowedPath(filePath)) {
85
+ console.log(JSON.stringify({ decision: 'approve' }));
86
+ process.exit(0);
87
+ }
88
+
89
+ // Collect content to scan based on tool type
90
+ let contentToScan = '';
91
+ if (toolName === 'Write') {
92
+ contentToScan = toolInput.content || '';
93
+ } else if (toolName === 'Edit') {
94
+ contentToScan = toolInput.new_string || '';
95
+ } else if (toolName === 'MultiEdit') {
96
+ const edits = toolInput.edits || [];
97
+ contentToScan = edits.map(e => e.new_string || '').join('\n');
98
+ }
99
+
100
+ const found = scanForCredentials(contentToScan);
101
+
102
+ if (found.length > 0) {
103
+ console.log(JSON.stringify({
104
+ decision: 'block',
105
+ reason: `Credential guard: blocked writing to ${filePath}. Detected: ${found.join(', ')}. Move credentials to .clancy/.env instead.`,
106
+ }));
107
+ } else {
108
+ console.log(JSON.stringify({ decision: 'approve' }));
109
+ }
110
+ } catch {
111
+ // Best-effort — never block on error
112
+ console.log(JSON.stringify({ decision: 'approve' }));
113
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chief-clancy",
3
- "version": "0.2.0-beta.2",
3
+ "version": "0.2.0",
4
4
  "description": "Autonomous, board-driven development for Claude Code — scaffolds docs, integrates Kanban boards, runs tickets in a loop.",
5
5
  "keywords": [
6
6
  "claude",
@@ -34,7 +34,7 @@
34
34
  "registry/"
35
35
  ],
36
36
  "scripts": {
37
- "test": "bash test/unit/jira.test.sh && bash test/unit/github.test.sh && bash test/unit/linear.test.sh && bash test/unit/scaffold.test.sh",
37
+ "test": "bash test/unit/jira.test.sh && bash test/unit/github.test.sh && bash test/unit/linear.test.sh && bash test/unit/scaffold.test.sh && bash test/unit/credential-guard.test.sh",
38
38
  "smoke": "bash test/smoke/smoke.sh"
39
39
  },
40
40
  "engines": {
@@ -408,9 +408,50 @@ When updating a value:
408
408
 
409
409
  ---
410
410
 
411
+ ### Save as global defaults
412
+
413
+ At the bottom of the settings menu (before Exit), show:
414
+
415
+ ```
416
+ [{N}] Save as defaults save current settings for all future projects
417
+ ```
418
+
419
+ When selected:
420
+
421
+ 1. Read the current `.clancy/.env` and extract only the non-credential, non-board-specific settings:
422
+ - `MAX_ITERATIONS`
423
+ - `CLANCY_MODEL`
424
+ - `CLANCY_BASE_BRANCH`
425
+ - `PLAYWRIGHT_ENABLED`
426
+ - `PLAYWRIGHT_STARTUP_WAIT`
427
+
428
+ 2. Write these to `~/.clancy/defaults.json`:
429
+ ```json
430
+ {
431
+ "MAX_ITERATIONS": "5",
432
+ "CLANCY_MODEL": "claude-sonnet-4-6",
433
+ "CLANCY_BASE_BRANCH": "main"
434
+ }
435
+ ```
436
+
437
+ 3. Print: `✓ Defaults saved to ~/.clancy/defaults.json — new projects will inherit these settings.`
438
+
439
+ 4. Loop back to the settings menu.
440
+
441
+ **Never save credentials, board-specific settings (status filter, sprint, label), or webhook URLs to global defaults.**
442
+
443
+ ---
444
+
445
+ ## Step 6 — Load global defaults during init
446
+
447
+ When `/clancy:init` creates `.clancy/.env`, check if `~/.clancy/defaults.json` exists. If so, pre-populate the `.env` with those values instead of the built-in defaults. The user's answers during init still take priority — defaults are only used for settings that init doesn't ask about (max iterations, model, etc.).
448
+
449
+ ---
450
+
411
451
  ## Notes
412
452
 
413
453
  - All changes are written to `.clancy/.env` immediately after confirmation
414
454
  - Switching boards verifies credentials before making any changes — nothing is written if verification fails
415
455
  - `/clancy:init` remains available for a full re-setup (re-scaffolds scripts and docs)
416
456
  - This command never restarts any servers or triggers any ticket processing
457
+ - Global defaults (`~/.clancy/defaults.json`) are optional — if the file doesn't exist, built-in defaults are used
@@ -50,14 +50,15 @@ Print: `✓ Clancy commands removed from [location].`
50
50
  ### Step 2b — Remove hooks
51
51
 
52
52
  For each location being removed, delete these hook files if they exist:
53
- - Project-local: `.claude/hooks/clancy-check-update.js`, `.claude/hooks/clancy-statusline.js`, `.claude/hooks/clancy-context-monitor.js`
54
- - Global: `~/.claude/hooks/clancy-check-update.js`, `~/.claude/hooks/clancy-statusline.js`, `~/.claude/hooks/clancy-context-monitor.js`
53
+ - Project-local: `.claude/hooks/clancy-check-update.js`, `.claude/hooks/clancy-statusline.js`, `.claude/hooks/clancy-context-monitor.js`, `.claude/hooks/clancy-credential-guard.js`
54
+ - Global: `~/.claude/hooks/clancy-check-update.js`, `~/.claude/hooks/clancy-statusline.js`, `~/.claude/hooks/clancy-context-monitor.js`, `~/.claude/hooks/clancy-credential-guard.js`
55
55
 
56
56
  Then remove the Clancy hook registrations from the corresponding `settings.json` (`.claude/settings.json` for local, `~/.claude/settings.json` for global):
57
57
  - Remove any entry in `hooks.SessionStart` whose `command` contains `clancy-check-update`
58
58
  - Remove any entry in `hooks.PostToolUse` whose `command` contains `clancy-context-monitor`
59
+ - Remove any entry in `hooks.PreToolUse` whose `command` contains `clancy-credential-guard`
59
60
  - Remove the `statusLine` key if its `command` value contains `clancy-statusline`
60
- - If removing an entry leaves a `hooks.SessionStart` or `hooks.PostToolUse` array empty, remove the key entirely
61
+ - If removing an entry leaves a `hooks.SessionStart`, `hooks.PostToolUse`, or `hooks.PreToolUse` array empty, remove the key entirely
61
62
 
62
63
  Also remove the update check cache if it exists: `~/.claude/cache/clancy-update-check.json`
63
64
 
@@ -108,6 +108,9 @@ Extract only the entries between the installed version and the latest version. D
108
108
  - `.claude/commands/clancy/` will be replaced
109
109
  - `.claude/clancy/workflows/` will be replaced
110
110
 
111
+ If you've modified any Clancy files directly, they'll be automatically backed up
112
+ to `.claude/clancy/local-patches/` before overwriting.
113
+
111
114
  Your project files are preserved:
112
115
  - `.clancy/` project folder (scripts, docs, .env, progress log) ✓
113
116
  - `CLAUDE.md` ✓
@@ -167,6 +170,31 @@ View full changelog: github.com/Pushedskydiver/clancy/blob/main/CHANGELOG.md
167
170
 
168
171
  ---
169
172
 
173
+ ## Step 6 — Check for local patches
174
+
175
+ After the update completes, check if the installer backed up any locally modified files:
176
+
177
+ Check for `.claude/clancy/local-patches/backup-meta.json` (local install) or `~/.claude/clancy/local-patches/backup-meta.json` (global install).
178
+
179
+ **If patches were found:**
180
+
181
+ ```
182
+ Local patches were backed up before the update.
183
+ Your modified files are in .claude/clancy/local-patches/
184
+
185
+ To review what changed:
186
+ Compare each file in local-patches/ against its counterpart in
187
+ .claude/commands/clancy/ or .claude/clancy/workflows/ and manually
188
+ reapply any customisations you want to keep.
189
+
190
+ Backed up files:
191
+ {list from backup-meta.json}
192
+ ```
193
+
194
+ **If no patches:** Continue normally (no message needed).
195
+
196
+ ---
197
+
170
198
  ## Notes
171
199
 
172
200
  - If the user installed globally, the update applies globally