claude-devkit-cli 1.0.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.
@@ -0,0 +1,108 @@
1
+ import { resolve } from 'node:path';
2
+ import { existsSync } from 'node:fs';
3
+ import { copyFile as fsCopyFile, mkdir } from 'node:fs/promises';
4
+ import { dirname } from 'node:path';
5
+ import { readFileSync } from 'node:fs';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { log } from '../lib/logger.js';
8
+ import { hashFile } from '../lib/hasher.js';
9
+ import { readManifest, writeManifest, setFileEntry, refreshCustomizationStatus } from '../lib/manifest.js';
10
+ import { getAllFiles, getTemplateDir, setPermissions } from '../lib/installer.js';
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf-8'));
14
+
15
+ export async function upgradeCommand(path, opts) {
16
+ const targetDir = resolve(path);
17
+ const manifest = await readManifest(targetDir);
18
+
19
+ if (!manifest) {
20
+ log.fail('No manifest found. Run `claude-devkit init` first, or `claude-devkit init --adopt` to adopt existing files.');
21
+ process.exit(1);
22
+ }
23
+
24
+ // Refresh customization status by re-hashing installed files
25
+ await refreshCustomizationStatus(targetDir, manifest);
26
+
27
+ log.info(`claude-devkit upgrade: ${manifest.version} → ${pkg.version}`);
28
+ log.blank();
29
+
30
+ if (opts.dryRun) {
31
+ log.info('Dry run — no changes will be made');
32
+ log.blank();
33
+ }
34
+
35
+ const templateDir = getTemplateDir();
36
+ const allFiles = getAllFiles();
37
+
38
+ let updated = 0;
39
+ let skippedCustomized = 0;
40
+ let added = 0;
41
+ let unchanged = 0;
42
+
43
+ for (const file of allFiles) {
44
+ const templatePath = resolve(templateDir, file);
45
+ const installedPath = resolve(targetDir, file);
46
+ const currentKitHash = await hashFile(templatePath);
47
+ const entry = manifest.files[file];
48
+
49
+ if (!entry) {
50
+ // New file in kit — install it
51
+ if (!opts.dryRun) {
52
+ await mkdir(dirname(installedPath), { recursive: true });
53
+ await fsCopyFile(templatePath, installedPath);
54
+ setFileEntry(manifest, file, currentKitHash, currentKitHash);
55
+ }
56
+ log.copy(`${file} (new)`);
57
+ added++;
58
+ continue;
59
+ }
60
+
61
+ const kitChanged = currentKitHash !== entry.kitHash;
62
+
63
+ if (!kitChanged) {
64
+ log.same(file);
65
+ unchanged++;
66
+ continue;
67
+ }
68
+
69
+ // Kit has changed
70
+ if (entry.customized && !opts.force) {
71
+ log.skip(`${file} (customized — use --force to overwrite)`);
72
+ skippedCustomized++;
73
+ continue;
74
+ }
75
+
76
+ // Kit changed, user hasn't customized (or --force) → update
77
+ if (!opts.dryRun) {
78
+ await mkdir(dirname(installedPath), { recursive: true });
79
+ await fsCopyFile(templatePath, installedPath);
80
+ setFileEntry(manifest, file, currentKitHash, currentKitHash);
81
+ }
82
+ log.copy(file);
83
+ updated++;
84
+ }
85
+
86
+ // Check for files in manifest that no longer exist in kit
87
+ for (const file of Object.keys(manifest.files)) {
88
+ if (!allFiles.includes(file)) {
89
+ log.warn(`${file} — no longer in kit (keeping)`);
90
+ }
91
+ }
92
+
93
+ // Update manifest
94
+ if (!opts.dryRun) {
95
+ manifest.version = pkg.version;
96
+ manifest.updatedAt = new Date().toISOString();
97
+ await setPermissions(targetDir);
98
+ await writeManifest(targetDir, manifest);
99
+ }
100
+
101
+ // Summary
102
+ log.blank();
103
+ log.pass(`Updated ${updated}, added ${added}, unchanged ${unchanged}, skipped ${skippedCustomized} customized.`);
104
+
105
+ if (skippedCustomized > 0) {
106
+ log.warn(`${skippedCustomized} customized file(s) skipped. Run with --force to overwrite.`);
107
+ }
108
+ }
@@ -0,0 +1,93 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * Auto-detect project type from marker files.
6
+ * @param {string} targetDir
7
+ * @returns {{ lang: string, framework: string, srcDir: string, testDir: string } | null}
8
+ */
9
+ export function detectProject(targetDir) {
10
+ const has = (file) => existsSync(join(targetDir, file));
11
+ const hasGlob = (ext) => {
12
+ try {
13
+ return readdirSync(targetDir).some((f) => f.endsWith(ext));
14
+ } catch {
15
+ return false;
16
+ }
17
+ };
18
+ const packageContains = (str) => {
19
+ try {
20
+ return readFileSync(join(targetDir, 'package.json'), 'utf-8').includes(str);
21
+ } catch {
22
+ return false;
23
+ }
24
+ };
25
+ const gemfileContains = (str) => {
26
+ try {
27
+ return readFileSync(join(targetDir, 'Gemfile'), 'utf-8').includes(str);
28
+ } catch {
29
+ return false;
30
+ }
31
+ };
32
+
33
+ // Swift (SPM)
34
+ if (has('Package.swift')) {
35
+ return { lang: 'Swift', framework: 'XCTest (SPM)', srcDir: 'Sources', testDir: 'Tests' };
36
+ }
37
+
38
+ // Swift (Xcode)
39
+ if (hasGlob('.xcworkspace') || hasGlob('.xcodeproj')) {
40
+ return { lang: 'Swift', framework: 'XCTest (Xcode)', srcDir: 'Sources', testDir: 'Tests' };
41
+ }
42
+
43
+ // Node.js / TypeScript
44
+ if (has('package.json')) {
45
+ let framework = 'npm test';
46
+ if (has('vitest.config.ts') || has('vitest.config.js') || has('vitest.config.mts') || packageContains('"vitest"')) {
47
+ framework = 'Vitest';
48
+ } else if (has('jest.config.ts') || has('jest.config.js') || has('jest.config.mjs') || packageContains('"jest"')) {
49
+ framework = 'Jest';
50
+ }
51
+ const testDir = existsSync(join(targetDir, '__tests__')) ? '__tests__' : 'tests';
52
+ return { lang: 'TypeScript/JavaScript', framework, srcDir: 'src', testDir };
53
+ }
54
+
55
+ // Python
56
+ if (has('pyproject.toml') || has('setup.py') || has('pytest.ini')) {
57
+ return { lang: 'Python', framework: 'pytest', srcDir: 'src', testDir: 'tests' };
58
+ }
59
+
60
+ // Rust
61
+ if (has('Cargo.toml')) {
62
+ return { lang: 'Rust', framework: 'cargo test', srcDir: 'src', testDir: 'tests' };
63
+ }
64
+
65
+ // Go
66
+ if (has('go.mod')) {
67
+ return { lang: 'Go', framework: 'go test', srcDir: '.', testDir: '.' };
68
+ }
69
+
70
+ // Java/Kotlin (Gradle)
71
+ if (has('build.gradle') || has('build.gradle.kts')) {
72
+ return { lang: 'Java/Kotlin', framework: 'Gradle', srcDir: 'src/main', testDir: 'src/test' };
73
+ }
74
+
75
+ // Java (Maven)
76
+ if (has('pom.xml')) {
77
+ return { lang: 'Java', framework: 'Maven', srcDir: 'src/main', testDir: 'src/test' };
78
+ }
79
+
80
+ // C# (.NET)
81
+ if (hasGlob('.sln')) {
82
+ return { lang: 'C#', framework: '.NET (dotnet test)', srcDir: 'src', testDir: 'tests' };
83
+ }
84
+
85
+ // Ruby
86
+ if (has('Gemfile')) {
87
+ const framework = gemfileContains('rspec') ? 'RSpec' : 'Minitest';
88
+ const testDir = framework === 'RSpec' ? 'spec' : 'test';
89
+ return { lang: 'Ruby', framework, srcDir: 'lib', testDir };
90
+ }
91
+
92
+ return null;
93
+ }
@@ -0,0 +1,21 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+
4
+ /**
5
+ * Compute SHA-256 hash of a file.
6
+ * @param {string} filePath
7
+ * @returns {Promise<string>} hex digest
8
+ */
9
+ export async function hashFile(filePath) {
10
+ const content = await readFile(filePath);
11
+ return createHash('sha256').update(content).digest('hex');
12
+ }
13
+
14
+ /**
15
+ * Compute SHA-256 hash of a string/buffer.
16
+ * @param {string|Buffer} content
17
+ * @returns {string} hex digest
18
+ */
19
+ export function hashContent(content) {
20
+ return createHash('sha256').update(content).digest('hex');
21
+ }
@@ -0,0 +1,175 @@
1
+ import { copyFile as fsCopyFile, mkdir, readFile, writeFile, access, constants } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { join, dirname, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { chmod } from 'node:fs/promises';
6
+ import { log } from './logger.js';
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+
10
+ /**
11
+ * Component → file mappings.
12
+ */
13
+ export const COMPONENTS = {
14
+ hooks: [
15
+ '.claude/hooks/file-guard.js',
16
+ '.claude/hooks/path-guard.sh',
17
+ '.claude/hooks/comment-guard.js',
18
+ '.claude/hooks/glob-guard.js',
19
+ '.claude/hooks/self-review.sh',
20
+ '.claude/hooks/sensitive-guard.sh',
21
+ ],
22
+ commands: [
23
+ '.claude/commands/plan.md',
24
+ '.claude/commands/test.md',
25
+ '.claude/commands/fix.md',
26
+ '.claude/commands/review.md',
27
+ '.claude/commands/commit.md',
28
+ '.claude/commands/challenge.md',
29
+ ],
30
+ config: [
31
+ '.claude/settings.json',
32
+ '.claude/CLAUDE.md',
33
+ ],
34
+ scripts: [
35
+ 'scripts/build-test.sh',
36
+ ],
37
+ docs: [
38
+ 'docs/WORKFLOW.md',
39
+ ],
40
+ };
41
+
42
+ /**
43
+ * Placeholder directories to create.
44
+ */
45
+ export const PLACEHOLDER_DIRS = [
46
+ 'docs/specs',
47
+ 'docs/test-plans',
48
+ ];
49
+
50
+ /**
51
+ * Files that need +x permission.
52
+ */
53
+ export const EXECUTABLE_FILES = [
54
+ 'scripts/build-test.sh',
55
+ '.claude/hooks/path-guard.sh',
56
+ '.claude/hooks/self-review.sh',
57
+ '.claude/hooks/sensitive-guard.sh',
58
+ ];
59
+
60
+ /**
61
+ * Get path to kit (templates) directory.
62
+ * Published package: cli/templates/ | Dev mode: ../kit/
63
+ */
64
+ export function getTemplateDir() {
65
+ const bundled = resolve(__dirname, '../../templates');
66
+ if (existsSync(bundled)) return bundled;
67
+ return resolve(__dirname, '../../../kit');
68
+ }
69
+
70
+ /**
71
+ * Get all files for the given component list.
72
+ * @param {string[]} components - e.g. ['hooks', 'commands']
73
+ * @returns {string[]} relative file paths
74
+ */
75
+ export function getFilesForComponents(components) {
76
+ const files = [];
77
+ for (const comp of components) {
78
+ if (COMPONENTS[comp]) {
79
+ files.push(...COMPONENTS[comp]);
80
+ }
81
+ }
82
+ return files;
83
+ }
84
+
85
+ /**
86
+ * Get all installable files (all components).
87
+ */
88
+ export function getAllFiles() {
89
+ return Object.values(COMPONENTS).flat();
90
+ }
91
+
92
+ /**
93
+ * Copy a single file from templates to target.
94
+ * @returns {string} 'copied' | 'skipped'
95
+ */
96
+ export async function installFile(relativePath, targetDir, { force = false } = {}) {
97
+ const src = join(getTemplateDir(), relativePath);
98
+ const dst = join(targetDir, relativePath);
99
+
100
+ if (existsSync(dst) && !force) {
101
+ log.skip(`${relativePath} (exists, use --force to overwrite)`);
102
+ return 'skipped';
103
+ }
104
+
105
+ await mkdir(dirname(dst), { recursive: true });
106
+ await fsCopyFile(src, dst);
107
+ log.copy(relativePath);
108
+ return 'copied';
109
+ }
110
+
111
+ /**
112
+ * Create a placeholder directory with .gitkeep.
113
+ */
114
+ export async function ensurePlaceholderDir(dir, targetDir) {
115
+ const fullPath = join(targetDir, dir);
116
+ if (existsSync(fullPath)) {
117
+ log.skip(`${dir}/ (exists)`);
118
+ return;
119
+ }
120
+ await mkdir(fullPath, { recursive: true });
121
+ await writeFile(join(fullPath, '.gitkeep'), '');
122
+ log.make(`${dir}/`);
123
+ }
124
+
125
+ /**
126
+ * Set executable permissions on relevant files.
127
+ */
128
+ export async function setPermissions(targetDir) {
129
+ for (const file of EXECUTABLE_FILES) {
130
+ const fullPath = join(targetDir, file);
131
+ try {
132
+ await chmod(fullPath, 0o755);
133
+ } catch {
134
+ // File might not exist if component not installed
135
+ }
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Fill [CUSTOMIZE] placeholders in CLAUDE.md with detected project info.
141
+ */
142
+ export async function fillTemplate(targetDir, projectInfo) {
143
+ if (!projectInfo) return;
144
+
145
+ const claudeMdPath = join(targetDir, '.claude/CLAUDE.md');
146
+ try {
147
+ let content = await readFile(claudeMdPath, 'utf-8');
148
+ content = content
149
+ .replace(/\[CUSTOMIZE\] Language:.*/, `**Language:** ${projectInfo.lang}`)
150
+ .replace(/\[CUSTOMIZE\] Test framework:.*/, `**Test framework:** ${projectInfo.framework}`)
151
+ .replace(/\[CUSTOMIZE\] Source directory:.*/, `**Source directory:** ${projectInfo.srcDir}`)
152
+ .replace(/\[CUSTOMIZE\] Test directory:.*/, `**Test directory:** ${projectInfo.testDir}`)
153
+ // Also handle the format without [CUSTOMIZE] prefix
154
+ .replace(/\*\*Language:\*\* \[CUSTOMIZE\]/, `**Language:** ${projectInfo.lang}`)
155
+ .replace(/\*\*Test framework:\*\* \[CUSTOMIZE\]/, `**Test framework:** ${projectInfo.framework}`)
156
+ .replace(/\*\*Source directory:\*\* \[CUSTOMIZE\]/, `**Source directory:** ${projectInfo.srcDir}`)
157
+ .replace(/\*\*Test directory:\*\* \[CUSTOMIZE\]/, `**Test directory:** ${projectInfo.testDir}`);
158
+ await writeFile(claudeMdPath, content);
159
+ } catch {
160
+ // CLAUDE.md might not exist
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Verify settings.json is valid JSON.
166
+ */
167
+ export async function verifySettingsJson(targetDir) {
168
+ try {
169
+ const raw = await readFile(join(targetDir, '.claude/settings.json'), 'utf-8');
170
+ JSON.parse(raw);
171
+ return true;
172
+ } catch {
173
+ return false;
174
+ }
175
+ }
@@ -0,0 +1,16 @@
1
+ import chalk from 'chalk';
2
+
3
+ export const log = {
4
+ info: (msg) => console.log(chalk.blue('[INFO]'), msg),
5
+ pass: (msg) => console.log(chalk.green('[PASS]'), msg),
6
+ fail: (msg) => console.log(chalk.red('[FAIL]'), msg),
7
+ warn: (msg) => console.log(chalk.yellow('[WARN]'), msg),
8
+ skip: (msg) => console.log(chalk.gray('[SKIP]'), msg),
9
+ copy: (msg) => console.log(chalk.cyan('[COPY]'), msg),
10
+ del: (msg) => console.log(chalk.red('[DEL]'), ' ', msg),
11
+ keep: (msg) => console.log(chalk.green('[KEEP]'), msg),
12
+ make: (msg) => console.log(chalk.cyan('[MAKE]'), msg),
13
+ adopt: (msg) => console.log(chalk.magenta('[ADOPT]'), msg),
14
+ same: (msg) => console.log(chalk.gray('[SAME]'), msg),
15
+ blank: () => console.log(),
16
+ };
@@ -0,0 +1,79 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
2
+ import { join, dirname } from 'node:path';
3
+ import { hashFile } from './hasher.js';
4
+
5
+ const MANIFEST_FILE = '.claude/.devkit-manifest.json';
6
+
7
+ /**
8
+ * Read manifest from target directory.
9
+ * @returns {object|null}
10
+ */
11
+ export async function readManifest(targetDir) {
12
+ try {
13
+ const raw = await readFile(join(targetDir, MANIFEST_FILE), 'utf-8');
14
+ return JSON.parse(raw);
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Write manifest to target directory.
22
+ */
23
+ export async function writeManifest(targetDir, manifest) {
24
+ const filePath = join(targetDir, MANIFEST_FILE);
25
+ await mkdir(dirname(filePath), { recursive: true });
26
+ await writeFile(filePath, JSON.stringify(manifest, null, 2) + '\n');
27
+ }
28
+
29
+ /**
30
+ * Create a new empty manifest.
31
+ */
32
+ export function createManifest(version, projectType, components) {
33
+ const now = new Date().toISOString();
34
+ return {
35
+ version,
36
+ installedAt: now,
37
+ updatedAt: now,
38
+ projectType: projectType || null,
39
+ components: components || ['hooks', 'commands', 'scripts', 'docs'],
40
+ files: {},
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Add or update a file entry in the manifest.
46
+ */
47
+ export function setFileEntry(manifest, relativePath, kitHash, installedHash) {
48
+ manifest.files[relativePath] = {
49
+ kitHash,
50
+ installedHash: installedHash || kitHash,
51
+ customized: installedHash ? installedHash !== kitHash : false,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Check if a file has been customized by the user.
57
+ */
58
+ export function isCustomized(manifest, relativePath) {
59
+ const entry = manifest?.files?.[relativePath];
60
+ if (!entry) return false;
61
+ return entry.customized;
62
+ }
63
+
64
+ /**
65
+ * Refresh customization status by re-hashing installed files.
66
+ */
67
+ export async function refreshCustomizationStatus(targetDir, manifest) {
68
+ for (const [relativePath, entry] of Object.entries(manifest.files)) {
69
+ try {
70
+ const currentHash = await hashFile(join(targetDir, relativePath));
71
+ entry.installedHash = currentHash;
72
+ entry.customized = currentHash !== entry.kitHash;
73
+ } catch {
74
+ // File was deleted
75
+ entry.installedHash = null;
76
+ entry.customized = true;
77
+ }
78
+ }
79
+ }
@@ -0,0 +1,74 @@
1
+ # Project Rules
2
+
3
+ ## Spec-First Development
4
+
5
+ Every change follows this cycle: **SPEC → TEST PLAN → CODE + TESTS → BUILD PASS**.
6
+
7
+ - Business logic specs live in `docs/specs/`
8
+ - Test plans live in `docs/test-plans/`
9
+ - Never write code before the spec exists. Never auto-modify specs from code.
10
+ - Specs are the source of truth. If code contradicts the spec, the code is wrong.
11
+
12
+ ## Workflow Quick Reference
13
+
14
+ | Trigger | Commands | Details |
15
+ |---------|----------|---------|
16
+ | New feature | `/plan` → code in chunks → `/test` each chunk | Start with spec or description |
17
+ | Update feature | Update spec first → `/plan` (update plan) → code → `/test` | Spec changes before code changes |
18
+ | Bug fix | `/fix "description"` | Test-first: write failing test → fix → green |
19
+ | Remove feature | Mark spec as removed → delete code + tests → build pass | Run full suite after removal |
20
+ | Stress-test plan | `/challenge` | Adversarial review before coding (optional) |
21
+ | Pre-merge check | `/review` | Diff-based quality gate |
22
+ | Commit changes | `/commit` | Secret scan + conventional commit |
23
+
24
+ For detailed workflow steps, templates, and decision trees, see `docs/WORKFLOW.md`.
25
+
26
+ ## Testing
27
+
28
+ - **Run tests:** `bash scripts/build-test.sh [--filter PATTERN]`
29
+ - **Auto-detects:** Swift, Node, Python, Rust, Go, Java, C#, Ruby
30
+ - **Compile/typecheck BEFORE running tests.** Catch syntax errors early.
31
+ - **Max 3 fix loops** for test failures. If tests still fail after 3 attempts, stop and report.
32
+ - **NEVER fix production code** to make a test pass — ask the user first.
33
+ - **No mocks, fakes, stubs, or cheats** to pass builds. Real implementations only.
34
+ Test doubles are acceptable only when they replace external services (APIs, databases)
35
+ that cannot run locally.
36
+
37
+ ## Project Info
38
+
39
+ > Fill these in when setting up the project (or let `setup.sh` do it automatically).
40
+
41
+ - **Language:** [CUSTOMIZE]
42
+ - **Test framework:** [CUSTOMIZE]
43
+ - **Source directory:** [CUSTOMIZE]
44
+ - **Test directory:** [CUSTOMIZE]
45
+
46
+ ## Conventions
47
+
48
+ - **Commits:** Conventional format — `type(scope): description`
49
+ Types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `perf`, `build`, `ci`
50
+ - **File naming:** Descriptive enough that AI tools understand the purpose from the path alone.
51
+ Prefer kebab-case for new files (e.g., `user-authentication-service.ts`).
52
+ - **Dates in filenames:** Use `$(date +%Y-%m-%d)` — never guess dates.
53
+ - **Specs & test plans naming:**
54
+ - kebab-case, lowercase: `user-auth.md`, `file-sync.md`
55
+ - Feature name, not module name: `user-auth.md` not `AuthService.md`
56
+ - Spec and test plan share the SAME name: `docs/specs/user-auth.md` ↔ `docs/test-plans/user-auth.md`
57
+ - Short (2-3 words): `payment-flow.md` not `payment-processing-with-stripe-integration.md`
58
+ - No prefix/suffix: `user-auth.md` not `spec-user-auth.md`
59
+
60
+ ## Forbidden
61
+
62
+ These patterns are never acceptable in this project:
63
+
64
+ - `any` / `Any` type without explicit justification in a comment
65
+ - Force unwrap (`!`) or force cast (`as!`) without a preceding guard
66
+ - Hardcoded secrets, API keys, tokens, or credentials in source files
67
+ - Mocks or fake data used solely to make tests pass
68
+ - `git push --force` to main or master branches
69
+ - Editing generated files, vendor directories, or lock files
70
+ - Committing `.env` files, certificates, or private keys
71
+ - Ignoring compiler/linter warnings without documented reason
72
+ - Replacing real code with placeholder comments like `// ... existing code ...`
73
+ - Renaming parameters to `_param` instead of actually fixing unused parameter issues
74
+ - Reading or writing `.env`, `.pem`, `.key`, or other sensitive files (use `.env.example` for templates)