create-claude-cabinet 0.8.5 → 0.9.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/lib/cli.js CHANGED
@@ -33,7 +33,7 @@ const MODULES = {
33
33
  mandatory: false,
34
34
  default: true,
35
35
  lean: false,
36
- templates: ['scripts/pib-db.js', 'scripts/pib-db-schema.sql'],
36
+ templates: ['scripts/pib-db.js', 'scripts/pib-db-schema.sql', 'scripts/work-tracker-server.mjs', 'scripts/work-tracker-ui.html'],
37
37
  needsDb: true,
38
38
  },
39
39
  'planning': {
@@ -42,7 +42,7 @@ const MODULES = {
42
42
  mandatory: false,
43
43
  default: true,
44
44
  lean: true,
45
- templates: ['skills/plan', 'skills/execute', 'skills/investigate'],
45
+ templates: ['skills/plan', 'skills/execute', 'skills/execute-plans', 'skills/investigate'],
46
46
  },
47
47
  'compliance': {
48
48
  name: 'Compliance Stack (rules + enforcement)',
@@ -107,6 +107,22 @@ const MODULES = {
107
107
  },
108
108
  };
109
109
 
110
+ /** Recursively collect all relative file paths under a directory. */
111
+ function walkDir(dir, base) {
112
+ if (!base) base = dir;
113
+ const results = [];
114
+ if (!fs.existsSync(dir)) return results;
115
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
116
+ const full = path.join(dir, entry.name);
117
+ if (entry.isDirectory()) {
118
+ results.push(...walkDir(full, base));
119
+ } else {
120
+ results.push(path.relative(base, full));
121
+ }
122
+ }
123
+ return results;
124
+ }
125
+
110
126
  // Signals that a directory contains a real project (not just empty)
111
127
  const PROJECT_SIGNALS = [
112
128
  'package.json', 'Cargo.toml', 'requirements.txt', 'pyproject.toml',
@@ -572,24 +588,152 @@ async function run() {
572
588
  }
573
589
  }
574
590
 
575
- // --- Clean up files removed upstream ---
576
- // Phase files are excluded from the manifest (they're user-customized),
577
- // so skip them during cleanup even if they were in the old manifest.
591
+ // --- Manifest key migration (act:d1f16bee) ---
592
+ // When CC renames directories (e.g., perspectives/ cabinet-*/), old manifest
593
+ // keys no longer match new template paths. Migrate keys BEFORE cleanup so the
594
+ // cleanup loop doesn't treat renamed files as removed.
595
+ if (Object.keys(existingManifest).length > 0) {
596
+ const existing = readMetadata(projectDir);
597
+ const fromVersion = existing.version;
598
+ let migrationCount = 0;
599
+ // v0.5.x → v0.6.x: perspectives/ → cabinet-*/
600
+ if (fromVersion && /^0\.[0-5]\./.test(fromVersion)) {
601
+ for (const key of Object.keys(existingManifest)) {
602
+ const match = key.match(/\.claude\/skills\/perspectives\/([^/]+)\//);
603
+ if (match) {
604
+ const newKey = key.replace(`perspectives/${match[1]}/`, `cabinet-${match[1]}/`);
605
+ existingManifest[newKey] = existingManifest[key];
606
+ delete existingManifest[key];
607
+ migrationCount++;
608
+ }
609
+ }
610
+ }
611
+ // Future manifest key migrations go here
612
+ if (migrationCount > 0) {
613
+ console.log(` 🔄 Migrated ${migrationCount} manifest key${migrationCount === 1 ? '' : 's'} for directory rename`);
614
+ }
615
+ }
616
+
617
+ // --- Clean up files removed upstream (safeguarded) ---
618
+ // Four safeguards prevent the v0.6.8 incident from recurring:
619
+ // S1: Classify — only delete files that map to a known CC template path
620
+ // S2: Scope — only delete files from modules the user selected
621
+ // S3: Itemize — list all deletions and confirm before proceeding
622
+ // S4: Backup — copy files to .cc-backup/<timestamp>/ before deletion
578
623
  if (Object.keys(existingManifest).length > 0) {
579
- let totalRemoved = 0;
624
+ // S1: Build complete template path set (ALL modules) for classification
625
+ const allTemplatePaths = new Set();
626
+ for (const mod of Object.values(MODULES)) {
627
+ for (const tmpl of mod.templates || []) {
628
+ const srcPath = path.join(templateRoot, tmpl);
629
+ if (fs.existsSync(srcPath) && fs.statSync(srcPath).isDirectory()) {
630
+ for (const rel of walkDir(srcPath)) {
631
+ allTemplatePaths.add(manifestPath(tmpl) + '/' + rel);
632
+ }
633
+ } else if (fs.existsSync(srcPath)) {
634
+ allTemplatePaths.add(manifestPath(tmpl));
635
+ }
636
+ }
637
+ }
638
+
639
+ // S2: Build selected-module template paths for scoping
640
+ const selectedTemplatePaths = new Set();
641
+ for (const modKey of selectedModules) {
642
+ const mod = MODULES[modKey];
643
+ for (const tmpl of mod.templates || []) {
644
+ const srcPath = path.join(templateRoot, tmpl);
645
+ if (fs.existsSync(srcPath) && fs.statSync(srcPath).isDirectory()) {
646
+ for (const rel of walkDir(srcPath)) {
647
+ selectedTemplatePaths.add(manifestPath(tmpl) + '/' + rel);
648
+ }
649
+ } else if (fs.existsSync(srcPath)) {
650
+ selectedTemplatePaths.add(manifestPath(tmpl));
651
+ }
652
+ }
653
+ }
654
+
655
+ // Build CC template skill name set for project-skill guard (act:4ac281ba)
656
+ const ccSkillNames = new Set();
657
+ for (const mod of Object.values(MODULES)) {
658
+ for (const tmpl of mod.templates || []) {
659
+ if (tmpl.startsWith('skills/')) {
660
+ const skillName = tmpl.split('/')[1];
661
+ ccSkillNames.add(skillName);
662
+ }
663
+ }
664
+ }
665
+
666
+ // Collect files that would be removed
667
+ const toRemove = [];
580
668
  for (const oldPath of Object.keys(existingManifest)) {
581
669
  if (!allManifest[oldPath]) {
582
670
  // Skip phase files — they may be user-customized
583
671
  if (/\/phases\//.test(oldPath)) continue;
672
+
673
+ // S1: Only delete if the path maps to a known CC template
674
+ if (!allTemplatePaths.has(oldPath)) {
675
+ console.log(` Keeping non-template file: ${oldPath}`);
676
+ continue;
677
+ }
678
+
679
+ // S2: Only delete from selected modules (don't purge deselected modules)
680
+ if (!selectedTemplatePaths.has(oldPath)) {
681
+ continue;
682
+ }
683
+
684
+ // Project-skill guard: never delete skills CC didn't create
685
+ const skillDirMatch = oldPath.match(/^\.claude\/skills\/([^/]+)\//);
686
+ if (skillDirMatch && !ccSkillNames.has(skillDirMatch[1])) {
687
+ console.log(` Keeping project skill: ${oldPath}`);
688
+ continue;
689
+ }
690
+
584
691
  const fullPath = path.join(projectDir, oldPath);
585
692
  if (fs.existsSync(fullPath)) {
586
- if (!flags.dryRun) fs.unlinkSync(fullPath);
587
- totalRemoved++;
693
+ toRemove.push(oldPath);
588
694
  }
589
695
  }
590
696
  }
591
- if (totalRemoved > 0) {
592
- console.log(` 🧹 Removed ${totalRemoved} file${totalRemoved === 1 ? '' : 's'} no longer in upstream`);
697
+
698
+ // S3: Itemize and confirm before deleting
699
+ if (toRemove.length > 0) {
700
+ console.log(`\n Files no longer in upstream (${toRemove.length}):`);
701
+ for (const f of toRemove) {
702
+ console.log(` - ${f}`);
703
+ }
704
+
705
+ let confirmed = flags.yes;
706
+ if (!confirmed && !flags.dryRun) {
707
+ const response = await prompts({
708
+ type: 'confirm',
709
+ name: 'confirmed',
710
+ message: `Remove ${toRemove.length} file${toRemove.length === 1 ? '' : 's'}?`,
711
+ initial: false,
712
+ });
713
+ confirmed = response.confirmed;
714
+ }
715
+
716
+ if (confirmed && !flags.dryRun) {
717
+ // S4: Backup before deleting
718
+ const backupDir = path.join(projectDir, '.cc-backup', new Date().toISOString().replace(/[:.]/g, '-'));
719
+ fs.mkdirSync(backupDir, { recursive: true });
720
+ for (const filePath of toRemove) {
721
+ const src = path.join(projectDir, filePath);
722
+ const dest = path.join(backupDir, filePath);
723
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
724
+ fs.copyFileSync(src, dest);
725
+ }
726
+ console.log(` 📦 Backed up ${toRemove.length} file${toRemove.length === 1 ? '' : 's'} to ${path.relative(projectDir, backupDir)}/`);
727
+
728
+ for (const filePath of toRemove) {
729
+ fs.unlinkSync(path.join(projectDir, filePath));
730
+ }
731
+ console.log(` 🧹 Removed ${toRemove.length} file${toRemove.length === 1 ? '' : 's'} no longer in upstream`);
732
+ } else if (flags.dryRun) {
733
+ console.log(` [dry run — ${toRemove.length} file${toRemove.length === 1 ? '' : 's'} would be removed]`);
734
+ } else {
735
+ console.log(' Skipped file removal.');
736
+ }
593
737
  }
594
738
  }
595
739
 
package/lib/copy.js CHANGED
@@ -45,6 +45,19 @@ async function walkAndCopy(srcRoot, destRoot, currentSrc, results, dryRun, skipC
45
45
  if (fs.existsSync(destPath)) {
46
46
  const existing = fs.readFileSync(destPath, 'utf8');
47
47
 
48
+ // Phase file guard (act:98d74381) — independent of skipPhases flag.
49
+ // If a phase file on disk has been customized (differs from template
50
+ // and isn't empty), never overwrite it regardless of other flags.
51
+ if (relPath.includes('phases' + path.sep) || relPath.includes('phases/')) {
52
+ const trimmedExisting = existing.trim();
53
+ if (trimmedExisting !== '' && trimmedExisting !== incoming.trim()) {
54
+ results.skipped.push(relPath);
55
+ results.manifest[relPath] = hashContent(existing);
56
+ console.log(` Preserved customized phase: ${displayPath}`);
57
+ continue;
58
+ }
59
+ }
60
+
48
61
  if (existing === incoming) {
49
62
  results.skipped.push(relPath);
50
63
  results.manifest[relPath] = incomingHash;
@@ -7,17 +7,18 @@ const OMEGA_HOME = path.join(os.homedir(), '.claude-cabinet');
7
7
  const VENV_DIR = path.join(OMEGA_HOME, 'omega-venv');
8
8
  const VENV_PYTHON = path.join(VENV_DIR, 'bin', 'python3');
9
9
 
10
- // Ordered by preference: stable Cellar symlinks first, newest Python first.
10
+ // Ordered by preference: 3.13 first (ONNX runtime segfaults on 3.14 during
11
+ // shutdown — exit 139, triggers macOS crash dialogs). 3.14 last as fallback.
11
12
  // Apple Silicon paths, then Intel Mac paths, then PATH fallback.
12
13
  const PYTHON_CANDIDATES = [
13
- '/opt/homebrew/opt/python@3.14/bin/python3.14',
14
14
  '/opt/homebrew/opt/python@3.13/bin/python3.13',
15
15
  '/opt/homebrew/opt/python@3.12/bin/python3.12',
16
16
  '/opt/homebrew/opt/python@3.11/bin/python3.11',
17
- '/usr/local/opt/python@3.14/bin/python3.14',
17
+ '/opt/homebrew/opt/python@3.14/bin/python3.14',
18
18
  '/usr/local/opt/python@3.13/bin/python3.13',
19
19
  '/usr/local/opt/python@3.12/bin/python3.12',
20
20
  '/usr/local/opt/python@3.11/bin/python3.11',
21
+ '/usr/local/opt/python@3.14/bin/python3.14',
21
22
  '/opt/homebrew/bin/python3',
22
23
  '/usr/local/bin/python3',
23
24
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-claude-cabinet",
3
- "version": "0.8.5",
3
+ "version": "0.9.0",
4
4
  "description": "Claude Cabinet — opinionated process scaffolding for Claude Code projects",
5
5
  "bin": {
6
6
  "create-claude-cabinet": "bin/create-claude-cabinet.js"
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Work tracker server — serves the work tracker UI and provides
3
+ * read/write access to pib.db (projects and actions).
4
+ *
5
+ * Usage: node work-tracker-server.mjs [--port 3458] [--db path/to/pib.db]
6
+ */
7
+
8
+ import { createServer } from 'node:http';
9
+ import { readFile } from 'node:fs/promises';
10
+ import { existsSync } from 'node:fs';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { dirname, join, resolve } from 'node:path';
13
+ import Database from 'better-sqlite3';
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const PORT = parseInt(process.env.PORT || process.argv.find((_, i, a) => a[i - 1] === '--port') || '3458');
17
+ const DB_PATH = resolve(process.argv.find((_, i, a) => a[i - 1] === '--db') || 'pib.db');
18
+
19
+ if (!existsSync(DB_PATH)) {
20
+ console.error(`Database not found: ${DB_PATH}`);
21
+ console.error('Run: node scripts/pib-db.js init');
22
+ process.exit(1);
23
+ }
24
+
25
+ const db = new Database(DB_PATH, { readonly: false });
26
+ db.pragma('journal_mode = WAL');
27
+
28
+ function json(res, data, status = 200) {
29
+ res.writeHead(status, {
30
+ 'Content-Type': 'application/json',
31
+ 'Access-Control-Allow-Origin': '*',
32
+ 'Access-Control-Allow-Methods': 'GET, POST, PATCH, OPTIONS',
33
+ 'Access-Control-Allow-Headers': 'Content-Type',
34
+ });
35
+ res.end(JSON.stringify(data));
36
+ }
37
+
38
+ async function readBody(req) {
39
+ let body = '';
40
+ for await (const chunk of req) body += chunk;
41
+ return JSON.parse(body);
42
+ }
43
+
44
+ const server = createServer(async (req, res) => {
45
+ const url = new URL(req.url, `http://localhost:${PORT}`);
46
+
47
+ if (req.method === 'OPTIONS') {
48
+ res.writeHead(204, {
49
+ 'Access-Control-Allow-Origin': '*',
50
+ 'Access-Control-Allow-Methods': 'GET, POST, PATCH, OPTIONS',
51
+ 'Access-Control-Allow-Headers': 'Content-Type',
52
+ });
53
+ return res.end();
54
+ }
55
+
56
+ try {
57
+ // Serve HTML
58
+ if (req.method === 'GET' && url.pathname === '/') {
59
+ const html = await readFile(join(__dirname, 'work-tracker-ui.html'), 'utf-8');
60
+ res.writeHead(200, { 'Content-Type': 'text/html' });
61
+ return res.end(html);
62
+ }
63
+
64
+ // GET /api/projects — list projects with action counts
65
+ if (req.method === 'GET' && url.pathname === '/api/projects') {
66
+ const status = url.searchParams.get('status') || 'active';
67
+ const rows = db.prepare(`
68
+ SELECT p.*,
69
+ (SELECT COUNT(*) FROM actions a WHERE a.project_fid = p.fid AND a.completed = 0 AND a.deleted_at IS NULL) as open_actions,
70
+ (SELECT COUNT(*) FROM actions a WHERE a.project_fid = p.fid AND a.completed = 1) as done_actions,
71
+ (SELECT COUNT(*) FROM actions a WHERE a.project_fid = p.fid AND a.deleted_at IS NULL) as total_actions
72
+ FROM projects p
73
+ WHERE p.deleted_at IS NULL AND p.status = ?
74
+ ORDER BY p.created DESC
75
+ `).all(status);
76
+ return json(res, rows);
77
+ }
78
+
79
+ // GET /api/actions — list actions, optionally filtered
80
+ if (req.method === 'GET' && url.pathname === '/api/actions') {
81
+ const project = url.searchParams.get('project');
82
+ const status = url.searchParams.get('status'); // open, done, all
83
+ let query = `
84
+ SELECT a.*, p.name as project_name
85
+ FROM actions a
86
+ LEFT JOIN projects p ON a.project_fid = p.fid
87
+ WHERE a.deleted_at IS NULL
88
+ `;
89
+ const params = [];
90
+
91
+ if (project) {
92
+ query += ' AND a.project_fid = ?';
93
+ params.push(project);
94
+ }
95
+ if (status === 'open') {
96
+ query += ' AND a.completed = 0';
97
+ } else if (status === 'done') {
98
+ query += ' AND a.completed = 1';
99
+ }
100
+
101
+ query += ' ORDER BY a.completed ASC, a.flagged DESC, a.sort_order ASC, a.created DESC';
102
+ return json(res, db.prepare(query).all(...params));
103
+ }
104
+
105
+ // GET /api/action/:fid — single action with full notes
106
+ if (req.method === 'GET' && url.pathname.startsWith('/api/action/')) {
107
+ const fid = decodeURIComponent(url.pathname.slice('/api/action/'.length));
108
+ const row = db.prepare(`
109
+ SELECT a.*, p.name as project_name
110
+ FROM actions a
111
+ LEFT JOIN projects p ON a.project_fid = p.fid
112
+ WHERE a.fid = ?
113
+ `).get(fid);
114
+ if (!row) return json(res, { error: 'Not found' }, 404);
115
+ return json(res, row);
116
+ }
117
+
118
+ // PATCH /api/action/:fid — update action fields
119
+ if (req.method === 'PATCH' && url.pathname.startsWith('/api/action/')) {
120
+ const fid = decodeURIComponent(url.pathname.slice('/api/action/'.length));
121
+ const body = await readBody(req);
122
+ const allowed = ['text', 'status', 'completed', 'flagged', 'notes', 'due', 'tags', 'project_fid'];
123
+ const sets = [];
124
+ const params = [];
125
+ for (const [key, val] of Object.entries(body)) {
126
+ if (!allowed.includes(key)) continue;
127
+ sets.push(`${key} = ?`);
128
+ params.push(val);
129
+ }
130
+ if (body.completed === 1 && !body.completed_at) {
131
+ sets.push('completed_at = ?');
132
+ params.push(new Date().toISOString().split('T')[0]);
133
+ }
134
+ if (sets.length === 0) return json(res, { error: 'Nothing to update' }, 400);
135
+ params.push(fid);
136
+ db.prepare(`UPDATE actions SET ${sets.join(', ')} WHERE fid = ?`).run(...params);
137
+ return json(res, { ok: true });
138
+ }
139
+
140
+ // PATCH /api/project/:fid — update project fields
141
+ if (req.method === 'PATCH' && url.pathname.startsWith('/api/project/')) {
142
+ const fid = decodeURIComponent(url.pathname.slice('/api/project/'.length));
143
+ const body = await readBody(req);
144
+ const allowed = ['name', 'status', 'area', 'notes', 'due'];
145
+ const sets = [];
146
+ const params = [];
147
+ for (const [key, val] of Object.entries(body)) {
148
+ if (!allowed.includes(key)) continue;
149
+ sets.push(`${key} = ?`);
150
+ params.push(val);
151
+ }
152
+ if (body.status === 'done' && !body.completed_at) {
153
+ sets.push('completed_at = ?');
154
+ params.push(new Date().toISOString().split('T')[0]);
155
+ }
156
+ if (sets.length === 0) return json(res, { error: 'Nothing to update' }, 400);
157
+ params.push(fid);
158
+ db.prepare(`UPDATE projects SET ${sets.join(', ')} WHERE fid = ?`).run(...params);
159
+ return json(res, { ok: true });
160
+ }
161
+
162
+ // GET /api/stats — dashboard summary
163
+ if (req.method === 'GET' && url.pathname === '/api/stats') {
164
+ const projects = db.prepare(`
165
+ SELECT status, COUNT(*) as count FROM projects WHERE deleted_at IS NULL GROUP BY status
166
+ `).all();
167
+ const actions = db.prepare(`
168
+ SELECT
169
+ SUM(CASE WHEN completed = 0 THEN 1 ELSE 0 END) as open,
170
+ SUM(CASE WHEN completed = 1 THEN 1 ELSE 0 END) as done,
171
+ SUM(CASE WHEN flagged = 1 AND completed = 0 THEN 1 ELSE 0 END) as flagged
172
+ FROM actions WHERE deleted_at IS NULL
173
+ `).get();
174
+ return json(res, { projects, actions });
175
+ }
176
+
177
+ res.writeHead(404);
178
+ res.end('Not found');
179
+ } catch (err) {
180
+ console.error(err);
181
+ json(res, { error: err.message }, 500);
182
+ }
183
+ });
184
+
185
+ server.listen(PORT, () => {
186
+ console.log(`Work tracker at http://localhost:${PORT}`);
187
+ console.log(`Database: ${DB_PATH}`);
188
+ });