@eltonssouza/development-utility-kit 0.10.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/.claude/agents/README.md +24 -0
- package/.claude/agents/analyst.md +198 -0
- package/.claude/agents/backend-developer.md +126 -0
- package/.claude/agents/brain-keeper.md +229 -0
- package/.claude/agents/code-reviewer.md +181 -0
- package/.claude/agents/database-engineer.md +94 -0
- package/.claude/agents/devops-engineer.md +141 -0
- package/.claude/agents/frontend-developer.md +97 -0
- package/.claude/agents/gate-keeper.md +118 -0
- package/.claude/agents/migrator.md +291 -0
- package/.claude/agents/mobile-developer.md +80 -0
- package/.claude/agents/n8n-specialist.md +94 -0
- package/.claude/agents/product-owner.md +115 -0
- package/.claude/agents/qa-engineer.md +232 -0
- package/.claude/agents/release-engineer.md +204 -0
- package/.claude/agents/scaffold.md +87 -0
- package/.claude/agents/security-engineer.md +199 -0
- package/.claude/agents/sprint-runner.md +46 -0
- package/.claude/agents/stack-resolver.md +104 -0
- package/.claude/agents/tech-lead.md +182 -0
- package/.claude/agents/update-template.md +54 -0
- package/.claude/agents/ux-designer.md +118 -0
- package/.claude/hooks/flow-guard.js +261 -0
- package/.claude/hooks/flow-state.js +197 -0
- package/.claude/local/CLAUDE.md +71 -0
- package/.claude/settings.json +55 -0
- package/.claude/skills/README.md +331 -0
- package/.claude/skills/active-project/SKILL.md +131 -0
- package/.claude/skills/api-integration-test/SKILL.md +84 -0
- package/.claude/skills/auto-test-guard/SKILL.md +239 -0
- package/.claude/skills/auto-test-guard/resources/backend-tests.md +20 -0
- package/.claude/skills/auto-test-guard/resources/e2e-tests.md +24 -0
- package/.claude/skills/auto-test-guard/resources/execution-report.md +49 -0
- package/.claude/skills/auto-test-guard/resources/frontend-tests.md +18 -0
- package/.claude/skills/auto-test-guard/resources/initial-setup.md +108 -0
- package/.claude/skills/auto-test-guard/resources/run-suite.md +48 -0
- package/.claude/skills/auto-test-guard/resources/senior-gate.md +19 -0
- package/.claude/skills/brain-keeper/SKILL.md +62 -0
- package/.claude/skills/brain-keeper/obsidian/app.json +9 -0
- package/.claude/skills/brain-keeper/obsidian/appearance.json +4 -0
- package/.claude/skills/brain-keeper/obsidian/core-plugins.json +20 -0
- package/.claude/skills/brain-keeper/obsidian/daily-notes.json +5 -0
- package/.claude/skills/brain-keeper/obsidian/graph.json +32 -0
- package/.claude/skills/brain-keeper/obsidian/snippets/folder-colors.css +90 -0
- package/.claude/skills/brain-keeper/obsidian/templates.json +5 -0
- package/.claude/skills/brain-keeper/templates/README.md +51 -0
- package/.claude/skills/brain-keeper/templates/adr.md +40 -0
- package/.claude/skills/brain-keeper/templates/bug.md +35 -0
- package/.claude/skills/brain-keeper/templates/daily.md +38 -0
- package/.claude/skills/brain-keeper/templates/feature.md +62 -0
- package/.claude/skills/brain-keeper/templates/meeting.md +34 -0
- package/.claude/skills/brain-keeper/templates/tech-debt.md +21 -0
- package/.claude/skills/caveman/SKILL.md +189 -0
- package/.claude/skills/create-stack-pack/SKILL.md +281 -0
- package/.claude/skills/grill-me/SKILL.md +80 -0
- package/.claude/skills/pair-debug/SKILL.md +288 -0
- package/.claude/skills/prd-ready-check/SKILL.md +86 -0
- package/.claude/skills/project-manager/SKILL.md +334 -0
- package/.claude/skills/quality-standards/SKILL.md +203 -0
- package/.claude/skills/quick-feature/SKILL.md +266 -0
- package/.claude/skills/run-sprint/SKILL.md +41 -0
- package/.claude/skills/scaffold/SKILL.md +60 -0
- package/.claude/skills/stack-discovery/SKILL.md +161 -0
- package/.claude/skills/test-coverage-auditor/SKILL.md +87 -0
- package/.claude/skills/to-issues/SKILL.md +163 -0
- package/.claude/skills/to-prd/SKILL.md +130 -0
- package/.claude/skills/update-template/SKILL.md +256 -0
- package/.claude/stacks/CODEOWNERS +30 -0
- package/.claude/stacks/README.md +97 -0
- package/.claude/stacks/_template.md +116 -0
- package/.claude/stacks/dotnet/aspire-9.md +528 -0
- package/.claude/stacks/go/gin-1.10.md +570 -0
- package/.claude/stacks/java/spring-boot-3.md +376 -0
- package/.claude/stacks/java/spring-boot-4.md +438 -0
- package/.claude/stacks/node/express-5.md +538 -0
- package/.claude/stacks/python/django-5.md +483 -0
- package/.claude/stacks/python/fastapi-0.115.md +522 -0
- package/.claude/stacks/typescript/angular-18.md +420 -0
- package/.claude/stacks/typescript/angular-19.md +397 -0
- package/.claude/stacks/typescript/angular-21.md +494 -0
- package/CLAUDE.md +472 -0
- package/README.md +412 -0
- package/bin/cli.js +848 -0
- package/bin/lib/adr.js +146 -0
- package/bin/lib/backup.js +62 -0
- package/bin/lib/detect-stack.js +476 -0
- package/bin/lib/doctor.js +527 -0
- package/bin/lib/help.js +328 -0
- package/bin/lib/identity.js +108 -0
- package/bin/lib/lint-allowlist.json +15 -0
- package/bin/lib/lint.js +798 -0
- package/bin/lib/local-dir.js +68 -0
- package/bin/lib/manifest.js +236 -0
- package/bin/lib/sync-all.js +394 -0
- package/bin/lib/version-check.js +398 -0
- package/dashboard/db.js +321 -0
- package/dashboard/package.json +22 -0
- package/dashboard/public/app.js +853 -0
- package/dashboard/public/content/docs/agents-reference.en.md +911 -0
- package/dashboard/public/content/docs/architecture-overview.en.md +252 -0
- package/dashboard/public/content/docs/autonomy-matrix.en.md +186 -0
- package/dashboard/public/content/docs/cli-reference.en.md +538 -0
- package/dashboard/public/content/docs/git-flow.en.md +525 -0
- package/dashboard/public/content/docs/honcho-memory.en.md +394 -0
- package/dashboard/public/content/docs/hooks-reference.en.md +404 -0
- package/dashboard/public/content/docs/pipeline.en.md +414 -0
- package/dashboard/public/content/docs/plugins.en.md +289 -0
- package/dashboard/public/content/docs/quality-gate.en.md +315 -0
- package/dashboard/public/content/docs/skills-reference.en.md +484 -0
- package/dashboard/public/content/docs/stack-rules.en.md +362 -0
- package/dashboard/public/content/docs/troubleshooting.en.md +565 -0
- package/dashboard/public/content/manifest.json +114 -0
- package/dashboard/public/content/manual/backend.en.md +1053 -0
- package/dashboard/public/content/manual/existing-project.en.md +848 -0
- package/dashboard/public/content/manual/frontend.en.md +1008 -0
- package/dashboard/public/content/manual/fullstack.en.md +1459 -0
- package/dashboard/public/content/manual/mobile.en.md +837 -0
- package/dashboard/public/content/manual/quickstart.en.md +169 -0
- package/dashboard/public/index.html +217 -0
- package/dashboard/public/style.css +857 -0
- package/dashboard/public/vendor/marked.min.js +69 -0
- package/dashboard/rtk.js +143 -0
- package/dashboard/server-app.js +421 -0
- package/dashboard/server.js +104 -0
- package/dashboard/test/sprint1.test.js +406 -0
- package/dashboard/test/sprint2.test.js +571 -0
- package/dashboard/test/sprint3.test.js +560 -0
- package/package.json +33 -0
- package/scripts/hooks/subagent-telemetry.sh +14 -0
- package/scripts/hooks/telemetry-writer.js +250 -0
- package/scripts/latest-versions.json +56 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Provision `.claude/local/` with a README explaining the override contract
|
|
8
|
+
* (per ADR-032). Idempotent — never overwrites an existing README.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const LOCAL_README = `# .claude/local/ — project-only overrides
|
|
12
|
+
|
|
13
|
+
This directory is **never touched** by \`duk install\`. Anything you put
|
|
14
|
+
here survives every harness update.
|
|
15
|
+
|
|
16
|
+
Use it for customisations that apply ONLY to this project:
|
|
17
|
+
|
|
18
|
+
local/agents/ project-specific agent overrides
|
|
19
|
+
local/skills/ project-specific skills (non-canonical)
|
|
20
|
+
local/stacks/ proprietary or internal stack packs
|
|
21
|
+
|
|
22
|
+
Loader priority (per ADR-032 §3):
|
|
23
|
+
.claude/local/<path> wins over
|
|
24
|
+
.claude/<path>
|
|
25
|
+
|
|
26
|
+
When to use \`.claude/local/\`:
|
|
27
|
+
- Stack proprietária da empresa (ex: \`local/stacks/internal/cobol.md\`)
|
|
28
|
+
- Regra de projeto que NÃO se aplica a outros
|
|
29
|
+
- Skill experimental que pode virar canônica via PR no harness
|
|
30
|
+
|
|
31
|
+
When NOT to use:
|
|
32
|
+
- Algo que beneficia todos → PR no harness repo
|
|
33
|
+
|
|
34
|
+
Drift detection: files under \`local/\` are excluded from \`.MANIFEST\`,
|
|
35
|
+
so editing them never triggers a drift abort during \`duk install\`.
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Ensure `.claude/local/` exists with a README. Subdirs (agents/, skills/,
|
|
40
|
+
* stacks/) are NOT pre-created — they are lazy, born when the dev adds an
|
|
41
|
+
* override.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} claudeDir absolute path to .claude/
|
|
44
|
+
* @param {boolean} dryRun
|
|
45
|
+
*/
|
|
46
|
+
function ensureLocalDir(claudeDir, dryRun) {
|
|
47
|
+
const localDir = path.join(claudeDir, 'local');
|
|
48
|
+
const readmePath = path.join(localDir, 'README.md');
|
|
49
|
+
|
|
50
|
+
if (dryRun) {
|
|
51
|
+
if (!fs.existsSync(localDir)) {
|
|
52
|
+
process.stdout.write(' [dry-run] would create .claude/local/ with README.md\n');
|
|
53
|
+
}
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!fs.existsSync(localDir)) {
|
|
58
|
+
fs.mkdirSync(localDir, { recursive: true });
|
|
59
|
+
}
|
|
60
|
+
if (!fs.existsSync(readmePath)) {
|
|
61
|
+
fs.writeFileSync(readmePath, LOCAL_README, 'utf8');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = {
|
|
66
|
+
ensureLocalDir,
|
|
67
|
+
LOCAL_README,
|
|
68
|
+
};
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `.claude/.MANIFEST` — hash-based drift detection per ADR-032.
|
|
9
|
+
*
|
|
10
|
+
* generateManifest(claudeDir) walk .claude/ and return Map<relPath, sha256>
|
|
11
|
+
* writeManifest(claudeDir, harnessVersion)
|
|
12
|
+
* loadManifest(claudeDir) parse .claude/.MANIFEST or return null
|
|
13
|
+
* detectDrift(claudeDir) compare current files vs stored manifest
|
|
14
|
+
*
|
|
15
|
+
* Paths excluded from the manifest (managed independently):
|
|
16
|
+
* - local/ (per-project overrides — never touched by install)
|
|
17
|
+
* - .MANIFEST (the manifest file itself)
|
|
18
|
+
* - HARNESS_VERSION
|
|
19
|
+
* - Anything under .claude.bak / .claude.backup-* (lives outside .claude/)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const MANIFEST_FILENAME = '.MANIFEST';
|
|
23
|
+
const HARNESS_VERSION_FILENAME = 'HARNESS_VERSION';
|
|
24
|
+
const LOCAL_DIR = 'local';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Walk dir recursively, returning relative POSIX-style paths of files,
|
|
28
|
+
* skipping any directory or filename that matches `shouldSkip(name, relPath)`.
|
|
29
|
+
* @param {string} root
|
|
30
|
+
* @param {(name: string, relPath: string) => boolean} shouldSkip
|
|
31
|
+
* @returns {string[]} sorted relative paths
|
|
32
|
+
*/
|
|
33
|
+
function walkFiles(root, shouldSkip) {
|
|
34
|
+
const out = [];
|
|
35
|
+
|
|
36
|
+
function recur(absDir, relDir) {
|
|
37
|
+
let entries;
|
|
38
|
+
try {
|
|
39
|
+
entries = fs.readdirSync(absDir, { withFileTypes: true });
|
|
40
|
+
} catch {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
|
|
45
|
+
if (shouldSkip(entry.name, relPath)) continue;
|
|
46
|
+
const absPath = path.join(absDir, entry.name);
|
|
47
|
+
if (entry.isDirectory()) {
|
|
48
|
+
recur(absPath, relPath);
|
|
49
|
+
} else if (entry.isFile()) {
|
|
50
|
+
out.push(relPath);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
recur(root, '');
|
|
56
|
+
out.sort();
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Return sha256 hex of a file's bytes.
|
|
62
|
+
* @param {string} absPath
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
function sha256OfFile(absPath) {
|
|
66
|
+
const buf = fs.readFileSync(absPath);
|
|
67
|
+
return crypto.createHash('sha256').update(buf).digest('hex');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Default skip rule for manifest walks.
|
|
72
|
+
* @param {string} name
|
|
73
|
+
* @param {string} relPath
|
|
74
|
+
* @returns {boolean}
|
|
75
|
+
*/
|
|
76
|
+
function defaultSkip(name, relPath) {
|
|
77
|
+
// Skip top-level local/, MANIFEST, HARNESS_VERSION
|
|
78
|
+
if (relPath === LOCAL_DIR) return true;
|
|
79
|
+
if (relPath.startsWith(`${LOCAL_DIR}/`)) return true;
|
|
80
|
+
if (relPath === MANIFEST_FILENAME) return true;
|
|
81
|
+
if (relPath === HARNESS_VERSION_FILENAME) return true;
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build a Map<relPath, sha256> for every file under .claude/ except the
|
|
87
|
+
* exclusions listed above.
|
|
88
|
+
* @param {string} claudeDir absolute path to the .claude/ directory
|
|
89
|
+
* @returns {Map<string, string>}
|
|
90
|
+
*/
|
|
91
|
+
function generateManifest(claudeDir) {
|
|
92
|
+
const result = new Map();
|
|
93
|
+
if (!fs.existsSync(claudeDir)) return result;
|
|
94
|
+
const files = walkFiles(claudeDir, defaultSkip);
|
|
95
|
+
for (const rel of files) {
|
|
96
|
+
const abs = path.join(claudeDir, rel);
|
|
97
|
+
result.set(rel, sha256OfFile(abs));
|
|
98
|
+
}
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Render a manifest Map to the on-disk format and write it to
|
|
104
|
+
* `<claudeDir>/.MANIFEST`.
|
|
105
|
+
* @param {string} claudeDir
|
|
106
|
+
* @param {string} harnessVersion
|
|
107
|
+
* @param {Map<string, string>} [manifest] - pre-computed; if omitted, regenerates
|
|
108
|
+
*/
|
|
109
|
+
function writeManifest(claudeDir, harnessVersion, manifest) {
|
|
110
|
+
const map = manifest || generateManifest(claudeDir);
|
|
111
|
+
const ts = new Date().toISOString();
|
|
112
|
+
const header = [
|
|
113
|
+
'# .claude/.MANIFEST',
|
|
114
|
+
`# Auto-generated by duk install at ${ts}.`,
|
|
115
|
+
`# Harness version: ${harnessVersion}`,
|
|
116
|
+
'# Format: <relative-path> <sha256>',
|
|
117
|
+
'# Do not edit by hand.',
|
|
118
|
+
];
|
|
119
|
+
const lines = [...map.entries()]
|
|
120
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
121
|
+
.map(([rel, sha]) => `${rel} ${sha}`);
|
|
122
|
+
const body = header.concat(lines).join('\n') + '\n';
|
|
123
|
+
const target = path.join(claudeDir, MANIFEST_FILENAME);
|
|
124
|
+
fs.writeFileSync(target, body, 'utf8');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Read and parse `.claude/.MANIFEST`. Returns null if missing or unreadable.
|
|
129
|
+
* @param {string} claudeDir
|
|
130
|
+
* @returns {Map<string, string> | null}
|
|
131
|
+
*/
|
|
132
|
+
function loadManifest(claudeDir) {
|
|
133
|
+
const target = path.join(claudeDir, MANIFEST_FILENAME);
|
|
134
|
+
if (!fs.existsSync(target)) return null;
|
|
135
|
+
|
|
136
|
+
let content;
|
|
137
|
+
try {
|
|
138
|
+
content = fs.readFileSync(target, 'utf8');
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const map = new Map();
|
|
144
|
+
for (const line of content.split(/\r?\n/)) {
|
|
145
|
+
if (!line || line.startsWith('#')) continue;
|
|
146
|
+
// last whitespace-separated chunk is the sha; rest is path
|
|
147
|
+
const idx = line.lastIndexOf(' ');
|
|
148
|
+
if (idx === -1) continue;
|
|
149
|
+
const rel = line.slice(0, idx).trim();
|
|
150
|
+
const sha = line.slice(idx + 1).trim();
|
|
151
|
+
if (rel && /^[a-f0-9]{64}$/i.test(sha)) {
|
|
152
|
+
map.set(rel, sha);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return map;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Compare current files in .claude/ against the stored manifest. Returns
|
|
160
|
+
* { hasManifest, drifted, added, removed }
|
|
161
|
+
* where each list contains relative paths.
|
|
162
|
+
*
|
|
163
|
+
* - hasManifest=false → no baseline (first install ever) → not blocking.
|
|
164
|
+
* - drifted → file present in both but sha differs.
|
|
165
|
+
* - added → file present on disk but not in manifest.
|
|
166
|
+
* - removed → file in manifest but missing on disk.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} claudeDir
|
|
169
|
+
* @returns {{ hasManifest: boolean, drifted: string[], added: string[], removed: string[] }}
|
|
170
|
+
*/
|
|
171
|
+
function detectDrift(claudeDir) {
|
|
172
|
+
const stored = loadManifest(claudeDir);
|
|
173
|
+
if (stored === null) {
|
|
174
|
+
return { hasManifest: false, drifted: [], added: [], removed: [] };
|
|
175
|
+
}
|
|
176
|
+
const current = generateManifest(claudeDir);
|
|
177
|
+
|
|
178
|
+
const drifted = [];
|
|
179
|
+
const added = [];
|
|
180
|
+
const removed = [];
|
|
181
|
+
|
|
182
|
+
for (const [rel, sha] of current.entries()) {
|
|
183
|
+
if (!stored.has(rel)) {
|
|
184
|
+
added.push(rel);
|
|
185
|
+
} else if (stored.get(rel) !== sha) {
|
|
186
|
+
drifted.push(rel);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
for (const rel of stored.keys()) {
|
|
190
|
+
if (!current.has(rel)) {
|
|
191
|
+
removed.push(rel);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
drifted.sort();
|
|
196
|
+
added.sort();
|
|
197
|
+
removed.sort();
|
|
198
|
+
return { hasManifest: true, drifted, added, removed };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Write the plain-text HARNESS_VERSION file (single line, semver).
|
|
203
|
+
* @param {string} claudeDir
|
|
204
|
+
* @param {string} version
|
|
205
|
+
*/
|
|
206
|
+
function writeHarnessVersion(claudeDir, version) {
|
|
207
|
+
const target = path.join(claudeDir, HARNESS_VERSION_FILENAME);
|
|
208
|
+
fs.writeFileSync(target, version + '\n', 'utf8');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Read HARNESS_VERSION, returning null if missing.
|
|
213
|
+
* @param {string} claudeDir
|
|
214
|
+
* @returns {string | null}
|
|
215
|
+
*/
|
|
216
|
+
function readHarnessVersion(claudeDir) {
|
|
217
|
+
const target = path.join(claudeDir, HARNESS_VERSION_FILENAME);
|
|
218
|
+
if (!fs.existsSync(target)) return null;
|
|
219
|
+
try {
|
|
220
|
+
return fs.readFileSync(target, 'utf8').trim();
|
|
221
|
+
} catch {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = {
|
|
227
|
+
MANIFEST_FILENAME,
|
|
228
|
+
HARNESS_VERSION_FILENAME,
|
|
229
|
+
LOCAL_DIR,
|
|
230
|
+
generateManifest,
|
|
231
|
+
writeManifest,
|
|
232
|
+
loadManifest,
|
|
233
|
+
detectDrift,
|
|
234
|
+
writeHarnessVersion,
|
|
235
|
+
readHarnessVersion,
|
|
236
|
+
};
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* sync-all.js — Batch update of harness across multiple project subdirectories.
|
|
5
|
+
*
|
|
6
|
+
* Default = dry-run preview. `--apply` actually invokes the adoption pipeline
|
|
7
|
+
* via the main CLI's adoptProject() function.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { compareVersions } = require('./version-check');
|
|
13
|
+
|
|
14
|
+
const HARNESS_DIR_SKIP = new Set([
|
|
15
|
+
'node_modules',
|
|
16
|
+
'.git',
|
|
17
|
+
'.idea',
|
|
18
|
+
'.vscode',
|
|
19
|
+
'dist',
|
|
20
|
+
'build',
|
|
21
|
+
'target',
|
|
22
|
+
'out',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {{
|
|
27
|
+
* key: 'stack'|'type'|'age'|'harness-version',
|
|
28
|
+
* value: string|number,
|
|
29
|
+
* op: string
|
|
30
|
+
* }} ParsedFilter
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @typedef {{
|
|
35
|
+
* name: string,
|
|
36
|
+
* path: string,
|
|
37
|
+
* harnessVersion: string|null,
|
|
38
|
+
* projectType: string|null,
|
|
39
|
+
* primaryStack: string|null,
|
|
40
|
+
* ageMs: number
|
|
41
|
+
* }} ProjectCandidate
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
// ─── filter parsing ──────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Convert a duration string ("30d", "6m", "1y", "12h") to milliseconds.
|
|
48
|
+
* @param {string} s
|
|
49
|
+
* @returns {number|null}
|
|
50
|
+
*/
|
|
51
|
+
function parseDuration(s) {
|
|
52
|
+
if (!s || typeof s !== 'string') return null;
|
|
53
|
+
const m = s.match(/^(\d+)([hdmy])$/i);
|
|
54
|
+
if (!m) return null;
|
|
55
|
+
const n = parseInt(m[1], 10);
|
|
56
|
+
const unit = m[2].toLowerCase();
|
|
57
|
+
switch (unit) {
|
|
58
|
+
case 'h':
|
|
59
|
+
return n * 60 * 60 * 1000;
|
|
60
|
+
case 'd':
|
|
61
|
+
return n * 24 * 60 * 60 * 1000;
|
|
62
|
+
case 'm':
|
|
63
|
+
return n * 30 * 24 * 60 * 60 * 1000; // month ≈ 30d
|
|
64
|
+
case 'y':
|
|
65
|
+
return n * 365 * 24 * 60 * 60 * 1000;
|
|
66
|
+
default:
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Parse a single filter expression like "stack:java" or "harness-version:<0.2".
|
|
73
|
+
* @param {string} expr
|
|
74
|
+
* @returns {ParsedFilter|null}
|
|
75
|
+
*/
|
|
76
|
+
function parseFilterExpr(expr) {
|
|
77
|
+
if (!expr || typeof expr !== 'string') return null;
|
|
78
|
+
const idx = expr.indexOf(':');
|
|
79
|
+
if (idx === -1) return null;
|
|
80
|
+
const key = expr.slice(0, idx).trim().toLowerCase();
|
|
81
|
+
const rawValue = expr.slice(idx + 1).trim();
|
|
82
|
+
if (!rawValue) return null;
|
|
83
|
+
|
|
84
|
+
switch (key) {
|
|
85
|
+
case 'stack':
|
|
86
|
+
case 'type':
|
|
87
|
+
return { key, value: rawValue.toLowerCase(), op: '=' };
|
|
88
|
+
case 'age': {
|
|
89
|
+
const ms = parseDuration(rawValue);
|
|
90
|
+
if (ms === null) return null;
|
|
91
|
+
return { key, value: ms, op: '>=' }; // ageMs >= threshold = "older than"
|
|
92
|
+
}
|
|
93
|
+
case 'harness-version': {
|
|
94
|
+
const opMatch = rawValue.match(/^(>=|<=|>|<|=)/);
|
|
95
|
+
if (opMatch) {
|
|
96
|
+
return { key, value: rawValue, op: opMatch[1] };
|
|
97
|
+
}
|
|
98
|
+
return { key, value: '=' + rawValue, op: '=' };
|
|
99
|
+
}
|
|
100
|
+
default:
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─── CLAUDE.md parsing ───────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Extract a key from the `## Project Identity` block of a CLAUDE.md string.
|
|
109
|
+
* Returns the raw value (without surrounding backticks).
|
|
110
|
+
* @param {string} content
|
|
111
|
+
* @param {string} key
|
|
112
|
+
* @returns {string|null}
|
|
113
|
+
*/
|
|
114
|
+
function extractIdentityField(content, key) {
|
|
115
|
+
if (!content) return null;
|
|
116
|
+
const idIdx = content.indexOf('## Project Identity');
|
|
117
|
+
if (idIdx === -1) return null;
|
|
118
|
+
const nextIdx = content.indexOf('\n## ', idIdx + 1);
|
|
119
|
+
const block = nextIdx === -1 ? content.slice(idIdx) : content.slice(idIdx, nextIdx);
|
|
120
|
+
|
|
121
|
+
const re = new RegExp(
|
|
122
|
+
`[*-]\\s*\\*\\*${key.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\*\\*\\s*:\\s*\`?([^\`\n]+?)\`?\\s*$`,
|
|
123
|
+
'im'
|
|
124
|
+
);
|
|
125
|
+
const m = block.match(re);
|
|
126
|
+
return m ? m[1].trim() : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ─── project discovery ──────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Find candidate projects under dir (subdirs containing .claude/).
|
|
133
|
+
* @param {string} dir
|
|
134
|
+
* @returns {ProjectCandidate[]}
|
|
135
|
+
*/
|
|
136
|
+
function discoverProjects(dir) {
|
|
137
|
+
const out = [];
|
|
138
|
+
let entries;
|
|
139
|
+
try {
|
|
140
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
141
|
+
} catch {
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const ent of entries) {
|
|
146
|
+
if (!ent.isDirectory()) continue;
|
|
147
|
+
if (ent.name.startsWith('.')) continue;
|
|
148
|
+
if (HARNESS_DIR_SKIP.has(ent.name)) continue;
|
|
149
|
+
|
|
150
|
+
const projectPath = path.join(dir, ent.name);
|
|
151
|
+
const claudeDir = path.join(projectPath, '.claude');
|
|
152
|
+
if (!fs.existsSync(claudeDir)) continue;
|
|
153
|
+
|
|
154
|
+
const candidate = inspectProject(projectPath);
|
|
155
|
+
if (candidate) out.push(candidate);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Build a ProjectCandidate by reading .claude/HARNESS_VERSION + CLAUDE.md.
|
|
163
|
+
* @param {string} projectPath
|
|
164
|
+
* @returns {ProjectCandidate|null}
|
|
165
|
+
*/
|
|
166
|
+
function inspectProject(projectPath) {
|
|
167
|
+
const claudeDir = path.join(projectPath, '.claude');
|
|
168
|
+
const versionFile = path.join(claudeDir, 'HARNESS_VERSION');
|
|
169
|
+
const claudeMdPath = path.join(projectPath, 'CLAUDE.md');
|
|
170
|
+
|
|
171
|
+
let harnessVersion = null;
|
|
172
|
+
try {
|
|
173
|
+
harnessVersion = fs.readFileSync(versionFile, 'utf8').trim() || null;
|
|
174
|
+
} catch {
|
|
175
|
+
harnessVersion = null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let projectType = null;
|
|
179
|
+
let primaryStack = null;
|
|
180
|
+
try {
|
|
181
|
+
const content = fs.readFileSync(claudeMdPath, 'utf8');
|
|
182
|
+
projectType = extractIdentityField(content, 'Project type');
|
|
183
|
+
primaryStack = extractIdentityField(content, 'Primary stack');
|
|
184
|
+
} catch {
|
|
185
|
+
// no CLAUDE.md or unreadable
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let ageMs = 0;
|
|
189
|
+
try {
|
|
190
|
+
const stat = fs.statSync(claudeDir);
|
|
191
|
+
ageMs = Date.now() - stat.mtimeMs;
|
|
192
|
+
} catch {
|
|
193
|
+
ageMs = 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
name: path.basename(projectPath),
|
|
198
|
+
path: projectPath,
|
|
199
|
+
harnessVersion,
|
|
200
|
+
projectType,
|
|
201
|
+
primaryStack,
|
|
202
|
+
ageMs,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ─── filter application ──────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Check whether a project matches one parsed filter.
|
|
210
|
+
* @param {ProjectCandidate} proj
|
|
211
|
+
* @param {ParsedFilter} filter
|
|
212
|
+
* @returns {boolean}
|
|
213
|
+
*/
|
|
214
|
+
function matchesFilter(proj, filter) {
|
|
215
|
+
if (!filter) return true;
|
|
216
|
+
switch (filter.key) {
|
|
217
|
+
case 'stack': {
|
|
218
|
+
const stack = (proj.primaryStack || '').toLowerCase();
|
|
219
|
+
return stack.includes(String(filter.value));
|
|
220
|
+
}
|
|
221
|
+
case 'type': {
|
|
222
|
+
const t = (proj.projectType || '').toLowerCase();
|
|
223
|
+
return t === String(filter.value) || t.includes(String(filter.value));
|
|
224
|
+
}
|
|
225
|
+
case 'age': {
|
|
226
|
+
return proj.ageMs >= Number(filter.value);
|
|
227
|
+
}
|
|
228
|
+
case 'harness-version': {
|
|
229
|
+
if (!proj.harnessVersion) return false;
|
|
230
|
+
const expr = String(filter.value);
|
|
231
|
+
const cmp = compareVersions(proj.harnessVersion, expr);
|
|
232
|
+
// compareVersions returns -1 when the version satisfies the constraint
|
|
233
|
+
// (or 0 for "=X" matches)
|
|
234
|
+
return cmp <= 0;
|
|
235
|
+
}
|
|
236
|
+
default:
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Apply all filters (AND) and excludes (NOT) to a candidate list.
|
|
243
|
+
* @param {ProjectCandidate[]} candidates
|
|
244
|
+
* @param {ParsedFilter[]} filters
|
|
245
|
+
* @param {string[]} excludes
|
|
246
|
+
* @returns {ProjectCandidate[]}
|
|
247
|
+
*/
|
|
248
|
+
function applyFilters(candidates, filters, excludes) {
|
|
249
|
+
const excludeSet = new Set((excludes || []).map((x) => x.toLowerCase()));
|
|
250
|
+
return candidates.filter((c) => {
|
|
251
|
+
if (excludeSet.has(c.name.toLowerCase())) return false;
|
|
252
|
+
return filters.every((f) => matchesFilter(c, f));
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ─── humanizers ──────────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Convert ageMs to a short human-readable string.
|
|
260
|
+
* @param {number} ms
|
|
261
|
+
* @returns {string}
|
|
262
|
+
*/
|
|
263
|
+
function humanAge(ms) {
|
|
264
|
+
if (!ms || ms < 0) return 'unknown';
|
|
265
|
+
const days = Math.floor(ms / (24 * 60 * 60 * 1000));
|
|
266
|
+
if (days < 1) return '<1d';
|
|
267
|
+
if (days < 30) return `${days}d`;
|
|
268
|
+
const months = Math.floor(days / 30);
|
|
269
|
+
if (months < 12) return `${months}m`;
|
|
270
|
+
const years = Math.floor(days / 365);
|
|
271
|
+
return `${years}y`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ─── runSyncAll ──────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* @typedef {{ filters: string[], excludes: string[], apply: boolean,
|
|
278
|
+
* adopt?: (opts: {cwd: string, sub: string|null, dryRun: boolean}) => void }} SyncOptions
|
|
279
|
+
*/
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Run sync-all over a directory.
|
|
283
|
+
*
|
|
284
|
+
* The `adopt` option lets the CLI inject its own adoptProject implementation
|
|
285
|
+
* (avoids a circular require with bin/cli.js). When omitted, lazy-loads
|
|
286
|
+
* `../cli.js#adoptProject`.
|
|
287
|
+
*
|
|
288
|
+
* @param {string} dir
|
|
289
|
+
* @param {SyncOptions} options
|
|
290
|
+
* @returns {{ matched: ProjectCandidate[], updated: number, skipped: number, errors: number }}
|
|
291
|
+
*/
|
|
292
|
+
function runSyncAll(dir, options) {
|
|
293
|
+
const opts = options || {};
|
|
294
|
+
const filterStrs = opts.filters || [];
|
|
295
|
+
const excludes = opts.excludes || [];
|
|
296
|
+
const apply = !!opts.apply;
|
|
297
|
+
|
|
298
|
+
if (!dir || !fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
|
299
|
+
process.stderr.write(`Error: directory "${dir}" not found or not a directory.\n`);
|
|
300
|
+
process.exit(1);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Parse filters
|
|
304
|
+
const parsedFilters = [];
|
|
305
|
+
for (const f of filterStrs) {
|
|
306
|
+
const parsed = parseFilterExpr(f);
|
|
307
|
+
if (!parsed) {
|
|
308
|
+
process.stderr.write(`Warning: ignoring unrecognized filter "${f}"\n`);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
parsedFilters.push(parsed);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Discover + filter
|
|
315
|
+
const candidates = discoverProjects(dir);
|
|
316
|
+
const matched = applyFilters(candidates, parsedFilters, excludes);
|
|
317
|
+
|
|
318
|
+
// Print plan
|
|
319
|
+
process.stdout.write(`\nkud sync-all — scanning ${dir}\n`);
|
|
320
|
+
process.stdout.write(`Found ${candidates.length} project(s) with .claude/\n`);
|
|
321
|
+
if (parsedFilters.length) {
|
|
322
|
+
process.stdout.write(`Filters: ${filterStrs.join(', ')}\n`);
|
|
323
|
+
}
|
|
324
|
+
if (excludes.length) {
|
|
325
|
+
process.stdout.write(`Excludes: ${excludes.join(', ')}\n`);
|
|
326
|
+
}
|
|
327
|
+
process.stdout.write(`Matched ${matched.length} project(s)${apply ? '' : ' (dry-run)'}\n\n`);
|
|
328
|
+
|
|
329
|
+
if (matched.length === 0) {
|
|
330
|
+
process.stdout.write('Nothing to do.\n');
|
|
331
|
+
return { matched: [], updated: 0, skipped: 0, errors: 0 };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Print table
|
|
335
|
+
for (const p of matched) {
|
|
336
|
+
process.stdout.write(
|
|
337
|
+
` ${p.name.padEnd(30)} ` +
|
|
338
|
+
`v${(p.harnessVersion || '?').padEnd(8)} ` +
|
|
339
|
+
`${(p.projectType || '?').padEnd(15)} ` +
|
|
340
|
+
`age:${humanAge(p.ageMs).padEnd(6)} ` +
|
|
341
|
+
`${(p.primaryStack || '').slice(0, 40)}\n`
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (!apply) {
|
|
346
|
+
process.stdout.write('\nDry-run only. Re-run with --apply to perform updates.\n');
|
|
347
|
+
return { matched, updated: 0, skipped: 0, errors: 0 };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Resolve adopt function (lazy require to avoid cycles)
|
|
351
|
+
let adopt = opts.adopt;
|
|
352
|
+
if (!adopt) {
|
|
353
|
+
try {
|
|
354
|
+
const cli = require('../cli');
|
|
355
|
+
adopt = cli.adoptProject;
|
|
356
|
+
} catch (e) {
|
|
357
|
+
process.stderr.write(`Error: could not load adoptProject from bin/cli.js — ${e.message}\n`);
|
|
358
|
+
return { matched, updated: 0, skipped: 0, errors: matched.length };
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Apply per project
|
|
363
|
+
let updated = 0;
|
|
364
|
+
let errors = 0;
|
|
365
|
+
process.stdout.write('\nApplying updates...\n');
|
|
366
|
+
for (const p of matched) {
|
|
367
|
+
try {
|
|
368
|
+
adopt({ cwd: p.path, sub: null, dryRun: false });
|
|
369
|
+
process.stdout.write(` [OK] ${p.name}\n`);
|
|
370
|
+
updated += 1;
|
|
371
|
+
} catch (e) {
|
|
372
|
+
process.stdout.write(` [FAIL] ${p.name} — ${e.message}\n`);
|
|
373
|
+
errors += 1;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
process.stdout.write(
|
|
378
|
+
`\nSummary: updated=${updated}, errors=${errors}, skipped=${matched.length - updated - errors}\n`
|
|
379
|
+
);
|
|
380
|
+
return { matched, updated, skipped: matched.length - updated - errors, errors };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
module.exports = {
|
|
384
|
+
runSyncAll,
|
|
385
|
+
// exported for tests
|
|
386
|
+
parseDuration,
|
|
387
|
+
parseFilterExpr,
|
|
388
|
+
extractIdentityField,
|
|
389
|
+
discoverProjects,
|
|
390
|
+
inspectProject,
|
|
391
|
+
matchesFilter,
|
|
392
|
+
applyFilters,
|
|
393
|
+
humanAge,
|
|
394
|
+
};
|