@kodevibe/harness 0.11.0 → 0.11.1
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.ko.md +70 -3
- package/README.md +82 -3
- package/harness/core-rules.md +2 -0
- package/harness/project-brief.md +18 -0
- package/harness/skills/docs-bridge.md +161 -0
- package/harness/skills/setup.md +10 -0
- package/harness/skills/state-check.md +19 -0
- package/harness/skills/wrap-up.md +9 -0
- package/package.json +1 -1
- package/src/init.js +754 -8
package/src/init.js
CHANGED
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
const readline = require('node:readline');
|
|
6
|
+
const crypto = require('node:crypto');
|
|
6
7
|
|
|
7
8
|
const HARNESS_DIR = path.join(__dirname, '..', 'harness');
|
|
9
|
+
const MANIFEST_PATH = '.harness/install-manifest.json';
|
|
8
10
|
let currentBackupTimestamp = null;
|
|
11
|
+
let currentInstallRecords = null;
|
|
12
|
+
let currentInstallPatches = null;
|
|
9
13
|
|
|
10
14
|
// ─── Template reader ─────────────────────────────────────────
|
|
11
15
|
function readTemplate(name) {
|
|
@@ -23,12 +27,77 @@ function getBackupTimestamp() {
|
|
|
23
27
|
return currentBackupTimestamp;
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
function toPosixPath(filePath) {
|
|
31
|
+
return filePath.split(path.sep).join('/');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hashContent(content) {
|
|
35
|
+
return `sha256:${crypto.createHash('sha256').update(content).digest('hex')}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function hashFile(fullPath) {
|
|
39
|
+
return hashContent(fs.readFileSync(fullPath));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isStatePath(relPath) {
|
|
43
|
+
return STATE_FILES.some(file => relPath === `docs/${file}` || relPath === `.harness/${file}`)
|
|
44
|
+
|| AGENT_MEMORY_FILES.some(file => relPath === `docs/${file}` || relPath === `.harness/${file}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function classifyManagedPath(relPath) {
|
|
48
|
+
if (isStatePath(relPath)) return 'state';
|
|
49
|
+
if (relPath === '.gitignore' || relPath === '.gitattributes') return 'team-helper';
|
|
50
|
+
if (relPath === 'CLAUDE.md' || relPath === 'AGENTS.md') return 'root-dispatcher';
|
|
51
|
+
if (relPath.includes('/skills/')) return 'skill';
|
|
52
|
+
if (relPath.includes('/agents/') || relPath.includes('/rules/')) return 'agent-or-rule';
|
|
53
|
+
return 'ide-config';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isProtectedPath(relPath) {
|
|
57
|
+
return isStatePath(relPath) || classifyManagedPath(relPath) === 'team-helper';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function fileHasFrameworkMarker(fullPath) {
|
|
61
|
+
try {
|
|
62
|
+
const stat = fs.lstatSync(fullPath);
|
|
63
|
+
if (!stat.isFile() || stat.isSymbolicLink()) return false;
|
|
64
|
+
return hasFrameworkMarker(fs.readFileSync(fullPath, 'utf8'));
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function startInstallRecording() {
|
|
71
|
+
currentInstallRecords = [];
|
|
72
|
+
currentInstallPatches = [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function stopInstallRecording() {
|
|
76
|
+
const records = currentInstallRecords || [];
|
|
77
|
+
const patches = currentInstallPatches || [];
|
|
78
|
+
currentInstallRecords = null;
|
|
79
|
+
currentInstallPatches = null;
|
|
80
|
+
return { records, patches };
|
|
81
|
+
}
|
|
82
|
+
|
|
26
83
|
// ─── File writer (mkdir -p + conflict check) ─────────────────
|
|
27
84
|
function writeFile(targetDir, relPath, content, overwrite) {
|
|
28
85
|
const fullPath = path.join(targetDir, relPath);
|
|
29
86
|
const exists = fs.existsSync(fullPath);
|
|
87
|
+
const hashBefore = exists ? hashFile(fullPath) : null;
|
|
30
88
|
if (exists && !overwrite) {
|
|
31
89
|
console.log(` ⏭ Skipped (exists): ${relPath}`);
|
|
90
|
+
if (currentInstallRecords && relPath !== MANIFEST_PATH) {
|
|
91
|
+
currentInstallRecords.push({
|
|
92
|
+
path: toPosixPath(relPath),
|
|
93
|
+
type: classifyManagedPath(relPath),
|
|
94
|
+
action: 'skipped',
|
|
95
|
+
protected: isProtectedPath(relPath),
|
|
96
|
+
hashBefore,
|
|
97
|
+
hashAfter: hashBefore,
|
|
98
|
+
backupPath: null,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
32
101
|
return false;
|
|
33
102
|
}
|
|
34
103
|
let backupRelPath = null;
|
|
@@ -42,8 +111,20 @@ function writeFile(targetDir, relPath, content, overwrite) {
|
|
|
42
111
|
}
|
|
43
112
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
44
113
|
fs.writeFileSync(fullPath, content, 'utf8');
|
|
114
|
+
const hashAfter = hashFile(fullPath);
|
|
45
115
|
const backupNote = backupRelPath ? ` (backup: ${backupRelPath})` : '';
|
|
46
116
|
console.log(` ${exists ? '↻' : '✓'} ${relPath}${backupNote}`);
|
|
117
|
+
if (currentInstallRecords && relPath !== MANIFEST_PATH) {
|
|
118
|
+
currentInstallRecords.push({
|
|
119
|
+
path: toPosixPath(relPath),
|
|
120
|
+
type: classifyManagedPath(relPath),
|
|
121
|
+
action: exists ? 'overwritten' : 'created',
|
|
122
|
+
protected: isProtectedPath(relPath),
|
|
123
|
+
hashBefore,
|
|
124
|
+
hashAfter,
|
|
125
|
+
backupPath: backupRelPath ? toPosixPath(backupRelPath) : null,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
47
128
|
return true;
|
|
48
129
|
}
|
|
49
130
|
|
|
@@ -55,6 +136,7 @@ const SKILLS = [
|
|
|
55
136
|
{ id: 'debug', desc: 'Investigate and diagnose issues. Use when debugging or analyzing unexpected behavior.' },
|
|
56
137
|
{ id: 'check-impact', desc: 'Assess change blast radius. Use when modifying shared modules or interfaces.' },
|
|
57
138
|
{ id: 'breakdown', desc: 'Break down features into implementable stories. Use when planning new features.' },
|
|
139
|
+
{ id: 'docs-bridge', desc: 'Connect project-local documentation to a wiki or docs hub through a safe local index while preserving original sources and visibility boundaries.' },
|
|
58
140
|
{ id: 'setup', desc: 'Onboard project into kode:harness. Scans codebase and fills state files. Use after harness init or when state files are empty.' },
|
|
59
141
|
{ id: 'wrap-up', desc: 'Capture session lessons and update state files. Use at the end of every session.' },
|
|
60
142
|
{ id: 'pivot', desc: 'Propagate direction changes across all state files. Use when project goals, technology, scope, or architecture changes.' },
|
|
@@ -90,6 +172,11 @@ const PERSONAL_DIRS = ['agent-memory/'];
|
|
|
90
172
|
|
|
91
173
|
const STATE_DEST_DIR = 'docs';
|
|
92
174
|
const PERSONAL_DEST_DIR = '.harness';
|
|
175
|
+
const TEAM_GITIGNORE_ENTRY = '\n# kode:harness personal state (Team mode)\n.harness/\n';
|
|
176
|
+
const TEAM_GITATTRIBUTES_CONTENT =
|
|
177
|
+
'# kode:harness Team mode — merge strategy for shared state files\n' +
|
|
178
|
+
'docs/features.md merge=union\n' +
|
|
179
|
+
'docs/dependency-map.md merge=union\n';
|
|
93
180
|
|
|
94
181
|
function hasFrameworkMarker(content) {
|
|
95
182
|
return content.includes('kode:harness')
|
|
@@ -451,16 +538,47 @@ async function promptMode() {
|
|
|
451
538
|
// ─── Team mode helpers ───────────────────────────────────────
|
|
452
539
|
function appendGitignore(targetDir) {
|
|
453
540
|
const gitignorePath = path.join(targetDir, '.gitignore');
|
|
454
|
-
const entry =
|
|
541
|
+
const entry = TEAM_GITIGNORE_ENTRY;
|
|
455
542
|
if (fs.existsSync(gitignorePath)) {
|
|
456
543
|
const content = fs.readFileSync(gitignorePath, 'utf8');
|
|
457
544
|
if (content.includes('.harness/')) {
|
|
458
545
|
console.log(' ⏭ Skipped (exists): .gitignore entry');
|
|
546
|
+
if (currentInstallPatches) {
|
|
547
|
+
currentInstallPatches.push({
|
|
548
|
+
path: '.gitignore',
|
|
549
|
+
action: 'skipped',
|
|
550
|
+
type: 'team-helper',
|
|
551
|
+
block: entry,
|
|
552
|
+
hashBefore: hashFile(gitignorePath),
|
|
553
|
+
hashAfter: hashFile(gitignorePath),
|
|
554
|
+
});
|
|
555
|
+
}
|
|
459
556
|
return;
|
|
460
557
|
}
|
|
461
558
|
fs.appendFileSync(gitignorePath, entry);
|
|
559
|
+
if (currentInstallPatches) {
|
|
560
|
+
currentInstallPatches.push({
|
|
561
|
+
path: '.gitignore',
|
|
562
|
+
action: 'appended',
|
|
563
|
+
type: 'team-helper',
|
|
564
|
+
block: entry,
|
|
565
|
+
hashBefore: hashContent(content),
|
|
566
|
+
hashAfter: hashFile(gitignorePath),
|
|
567
|
+
});
|
|
568
|
+
}
|
|
462
569
|
} else {
|
|
463
570
|
fs.writeFileSync(gitignorePath, entry.trimStart());
|
|
571
|
+
if (currentInstallRecords) {
|
|
572
|
+
currentInstallRecords.push({
|
|
573
|
+
path: '.gitignore',
|
|
574
|
+
type: 'team-helper',
|
|
575
|
+
action: 'created',
|
|
576
|
+
protected: true,
|
|
577
|
+
hashBefore: null,
|
|
578
|
+
hashAfter: hashFile(gitignorePath),
|
|
579
|
+
backupPath: null,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
464
582
|
}
|
|
465
583
|
console.log(' ✓ .gitignore — added .harness/');
|
|
466
584
|
}
|
|
@@ -500,11 +618,72 @@ function detectExistingInstall(targetDir) {
|
|
|
500
618
|
}
|
|
501
619
|
|
|
502
620
|
function writeGitattributes(targetDir) {
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
621
|
+
writeFile(targetDir, '.gitattributes', TEAM_GITATTRIBUTES_CONTENT, false);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function readPackageVersion() {
|
|
625
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
626
|
+
return pkg.version;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function readManifest(targetDir) {
|
|
630
|
+
const manifestFile = path.join(targetDir, MANIFEST_PATH);
|
|
631
|
+
if (!fs.existsSync(manifestFile)) return null;
|
|
632
|
+
try {
|
|
633
|
+
return JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
|
|
634
|
+
} catch {
|
|
635
|
+
return null;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function writeManifest(targetDir, manifest) {
|
|
640
|
+
const manifestFile = path.join(targetDir, MANIFEST_PATH);
|
|
641
|
+
fs.mkdirSync(path.dirname(manifestFile), { recursive: true });
|
|
642
|
+
fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function saveInstallManifest(targetDir, runRecord) {
|
|
646
|
+
const manifest = readManifest(targetDir) || {
|
|
647
|
+
schemaVersion: 1,
|
|
648
|
+
tool: '@kodevibe/harness',
|
|
649
|
+
packageVersion: readPackageVersion(),
|
|
650
|
+
runs: [],
|
|
651
|
+
};
|
|
652
|
+
manifest.schemaVersion = 1;
|
|
653
|
+
manifest.tool = '@kodevibe/harness';
|
|
654
|
+
manifest.packageVersion = readPackageVersion();
|
|
655
|
+
manifest.updatedAt = new Date().toISOString();
|
|
656
|
+
manifest.runs = Array.isArray(manifest.runs) ? manifest.runs : [];
|
|
657
|
+
manifest.runs.push(runRecord);
|
|
658
|
+
writeManifest(targetDir, manifest);
|
|
659
|
+
console.log(` ✓ ${MANIFEST_PATH}`);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function isSafeRelativePath(relPath) {
|
|
663
|
+
if (!relPath || path.isAbsolute(relPath)) return false;
|
|
664
|
+
const parts = relPath.split(/[\\/]+/);
|
|
665
|
+
return !parts.includes('..') && !parts.includes('');
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function safeResolve(targetDir, relPath) {
|
|
669
|
+
const normalized = toPosixPath(relPath);
|
|
670
|
+
if (!isSafeRelativePath(normalized)) {
|
|
671
|
+
throw new Error(`Unsafe path in manifest: ${relPath}`);
|
|
672
|
+
}
|
|
673
|
+
const root = path.resolve(targetDir);
|
|
674
|
+
const resolved = path.resolve(root, normalized);
|
|
675
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
|
|
676
|
+
throw new Error(`Path escapes target directory: ${relPath}`);
|
|
677
|
+
}
|
|
678
|
+
return resolved;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function isDirectoryEmpty(dirPath) {
|
|
682
|
+
try {
|
|
683
|
+
return fs.readdirSync(dirPath).length === 0;
|
|
684
|
+
} catch {
|
|
685
|
+
return false;
|
|
686
|
+
}
|
|
508
687
|
}
|
|
509
688
|
|
|
510
689
|
// ─── Post-install guide ──────────────────────────────────────
|
|
@@ -686,6 +865,516 @@ function runValidate(targetDir) {
|
|
|
686
865
|
return warnings === 0;
|
|
687
866
|
}
|
|
688
867
|
|
|
868
|
+
function getKnownIdeFiles(ide) {
|
|
869
|
+
const skillIds = SKILLS.map(skill => skill.id);
|
|
870
|
+
const agentIds = AGENTS.map(agent => agent.id);
|
|
871
|
+
const skillFiles = (base) => skillIds.map(id => `${base}/${id}/SKILL.md`);
|
|
872
|
+
const filesByIde = {
|
|
873
|
+
vscode: [
|
|
874
|
+
'.github/copilot-instructions.md',
|
|
875
|
+
...skillFiles('.github/skills'),
|
|
876
|
+
...agentIds.map(id => `.github/agents/${id}.agent.md`),
|
|
877
|
+
],
|
|
878
|
+
claude: [
|
|
879
|
+
'CLAUDE.md',
|
|
880
|
+
'.claude/rules/core.md',
|
|
881
|
+
...skillFiles('.claude/skills'),
|
|
882
|
+
...agentIds.map(id => `.claude/agents/${id}.md`),
|
|
883
|
+
],
|
|
884
|
+
cursor: [
|
|
885
|
+
'.cursor/rules/core.mdc',
|
|
886
|
+
'AGENTS.md',
|
|
887
|
+
...skillFiles('.agents/skills'),
|
|
888
|
+
...agentIds.map(id => `.cursor/rules/${id}.mdc`),
|
|
889
|
+
],
|
|
890
|
+
codex: [
|
|
891
|
+
'AGENTS.md',
|
|
892
|
+
...skillFiles('.agents/skills'),
|
|
893
|
+
...agentIds.map(id => `.codex/agents/${id}.toml`),
|
|
894
|
+
],
|
|
895
|
+
windsurf: [
|
|
896
|
+
'.windsurf/rules/core.md',
|
|
897
|
+
...skillFiles('.windsurf/skills'),
|
|
898
|
+
...agentIds.map(id => `.windsurf/skills/${id}/SKILL.md`),
|
|
899
|
+
],
|
|
900
|
+
antigravity: [
|
|
901
|
+
'AGENTS.md',
|
|
902
|
+
'.agents/rules/core.md',
|
|
903
|
+
...skillFiles('.agents/skills'),
|
|
904
|
+
...agentIds.map(id => `.agents/rules/${id}.md`),
|
|
905
|
+
],
|
|
906
|
+
};
|
|
907
|
+
return filesByIde[ide] || [];
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function getLegacyRuns(targetDir, ideFilter) {
|
|
911
|
+
const ides = Object.keys(GENERATORS).filter(ide => (ideFilter ? ide === ideFilter : hasIdeLayout(targetDir, ide)));
|
|
912
|
+
return ides
|
|
913
|
+
.filter(ide => hasIdeLayout(targetDir, ide))
|
|
914
|
+
.map(ide => ({
|
|
915
|
+
runId: `legacy-${ide}`,
|
|
916
|
+
ide,
|
|
917
|
+
mode: 'unknown',
|
|
918
|
+
crew: false,
|
|
919
|
+
status: 'installed',
|
|
920
|
+
legacy: true,
|
|
921
|
+
files: getKnownIdeFiles(ide)
|
|
922
|
+
.filter(relPath => fs.existsSync(path.join(targetDir, relPath)))
|
|
923
|
+
.filter(relPath => fileHasFrameworkMarker(path.join(targetDir, relPath)))
|
|
924
|
+
.map(relPath => {
|
|
925
|
+
const fullPath = path.join(targetDir, relPath);
|
|
926
|
+
return {
|
|
927
|
+
path: relPath,
|
|
928
|
+
type: classifyManagedPath(relPath),
|
|
929
|
+
action: 'legacy',
|
|
930
|
+
protected: false,
|
|
931
|
+
hashBefore: null,
|
|
932
|
+
hashAfter: hashFile(fullPath),
|
|
933
|
+
backupPath: null,
|
|
934
|
+
};
|
|
935
|
+
}),
|
|
936
|
+
patches: [],
|
|
937
|
+
}));
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function getInstalledRuns(targetDir, manifest) {
|
|
941
|
+
const manifestRuns = manifest && Array.isArray(manifest.runs)
|
|
942
|
+
? manifest.runs.filter(run => run && run.status !== 'uninstalled')
|
|
943
|
+
: [];
|
|
944
|
+
const manifestIdes = new Set(manifestRuns.map(run => run.ide).filter(Boolean));
|
|
945
|
+
const legacyRuns = getLegacyRuns(targetDir, null).filter(run => !manifestIdes.has(run.ide));
|
|
946
|
+
return [...manifestRuns, ...legacyRuns];
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function mergeSelectedFileRecords(records) {
|
|
950
|
+
let candidate = null;
|
|
951
|
+
let restoreBackupPath = null;
|
|
952
|
+
let previousManagedHash = null;
|
|
953
|
+
let fallbackSkipped = null;
|
|
954
|
+
let sawManagedRecord = false;
|
|
955
|
+
|
|
956
|
+
for (const record of records) {
|
|
957
|
+
if (record.action === 'skipped') {
|
|
958
|
+
fallbackSkipped = record;
|
|
959
|
+
continue;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
if (!sawManagedRecord) {
|
|
963
|
+
if (record.action === 'overwritten' && record.backupPath) {
|
|
964
|
+
restoreBackupPath = record.backupPath;
|
|
965
|
+
}
|
|
966
|
+
sawManagedRecord = true;
|
|
967
|
+
} else if (record.action === 'overwritten' && record.backupPath && record.hashBefore && previousManagedHash && record.hashBefore !== previousManagedHash) {
|
|
968
|
+
restoreBackupPath = record.backupPath;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
previousManagedHash = record.hashAfter || previousManagedHash;
|
|
972
|
+
candidate = { ...record };
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
if (!candidate) return fallbackSkipped || records[records.length - 1];
|
|
976
|
+
candidate.backupPath = restoreBackupPath;
|
|
977
|
+
if (restoreBackupPath) candidate.action = 'overwritten';
|
|
978
|
+
else if (candidate.action === 'overwritten') candidate.action = 'created';
|
|
979
|
+
return candidate;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function getSelectedFiles(selectedRuns) {
|
|
983
|
+
const filesByPath = new Map();
|
|
984
|
+
for (const run of selectedRuns) {
|
|
985
|
+
for (const file of (run.files || [])) {
|
|
986
|
+
const relPath = toPosixPath(file.path);
|
|
987
|
+
if (!filesByPath.has(relPath)) filesByPath.set(relPath, []);
|
|
988
|
+
filesByPath.get(relPath).push({ ...file, path: relPath });
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
return [...filesByPath.values()].map(mergeSelectedFileRecords);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function getHistoricalFileRecord(manifest, relPath) {
|
|
995
|
+
if (!manifest || !Array.isArray(manifest.runs)) return null;
|
|
996
|
+
const records = [];
|
|
997
|
+
for (const run of manifest.runs) {
|
|
998
|
+
for (const file of (run.files || [])) {
|
|
999
|
+
if (toPosixPath(file.path) === relPath) records.push({ ...file, path: relPath });
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
return records.length > 0 ? mergeSelectedFileRecords(records) : null;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function getOtherOwnerRecords(activeRuns, selectedRunIds, relPath, includeProtected = false) {
|
|
1006
|
+
const owners = [];
|
|
1007
|
+
for (const run of activeRuns) {
|
|
1008
|
+
if (selectedRunIds.has(run.runId)) continue;
|
|
1009
|
+
const files = Array.isArray(run.files) ? run.files : [];
|
|
1010
|
+
for (const file of files) {
|
|
1011
|
+
const skippedProtectedOwner = includeProtected && file.protected && file.action === 'skipped';
|
|
1012
|
+
if (toPosixPath(file.path) === relPath && (file.action !== 'skipped' || skippedProtectedOwner) && (includeProtected || !file.protected)) {
|
|
1013
|
+
owners.push({ ...file, path: relPath, runId: run.runId, ide: run.ide });
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
return owners;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function findLatestUserBackupPath(targetDir, manifest, relPath) {
|
|
1021
|
+
if (!manifest || !Array.isArray(manifest.runs)) return null;
|
|
1022
|
+
let backupPath = null;
|
|
1023
|
+
for (const run of manifest.runs) {
|
|
1024
|
+
for (const file of (run.files || [])) {
|
|
1025
|
+
if (toPosixPath(file.path) !== relPath || !file.backupPath) continue;
|
|
1026
|
+
let fullPath;
|
|
1027
|
+
try {
|
|
1028
|
+
fullPath = safeResolve(targetDir, file.backupPath);
|
|
1029
|
+
} catch {
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
if (fs.existsSync(fullPath) && !fileHasFrameworkMarker(fullPath)) {
|
|
1033
|
+
backupPath = file.backupPath;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
return backupPath;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function getRestoreBackupPath(targetDir, manifest, file) {
|
|
1041
|
+
if (!file.backupPath) return null;
|
|
1042
|
+
const backupPath = safeResolve(targetDir, file.backupPath);
|
|
1043
|
+
if (!fs.existsSync(backupPath)) return null;
|
|
1044
|
+
if (!fileHasFrameworkMarker(backupPath)) return file.backupPath;
|
|
1045
|
+
return findLatestUserBackupPath(targetDir, manifest, toPosixPath(file.path));
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function shouldKeepFile(file, options) {
|
|
1049
|
+
if (file.action === 'skipped') return true;
|
|
1050
|
+
if (file.protected && !options.purgeState) return true;
|
|
1051
|
+
return false;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function buildUninstallPlan(targetDir, options) {
|
|
1055
|
+
const manifest = readManifest(targetDir);
|
|
1056
|
+
const activeRuns = getInstalledRuns(targetDir, manifest);
|
|
1057
|
+
const activeIdes = [...new Set(activeRuns.map(run => run.ide))].filter(Boolean);
|
|
1058
|
+
|
|
1059
|
+
if (activeIdes.length === 0) {
|
|
1060
|
+
return { manifest, selectedRuns: [], actions: [], conflicts: [], keeps: [], warnings: ['No kode:harness install manifest or known IDE layout found.'] };
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
let selectedIde = options.ide;
|
|
1064
|
+
if (selectedIde === 'all') selectedIde = null;
|
|
1065
|
+
if (selectedIde && !GENERATORS[selectedIde]) {
|
|
1066
|
+
throw new Error(`Unknown IDE: ${selectedIde}`);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
if (!options.all && !selectedIde) {
|
|
1070
|
+
if (activeIdes.length > 1) {
|
|
1071
|
+
throw new Error(`Multiple IDE installs detected (${activeIdes.join(', ')}). Pass --ide <name> or --all.`);
|
|
1072
|
+
}
|
|
1073
|
+
selectedIde = activeIdes[0];
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
const selectedRuns = activeRuns.filter(run => options.all || run.ide === selectedIde);
|
|
1077
|
+
const selectedRunIds = new Set(selectedRuns.map(run => run.runId));
|
|
1078
|
+
const hasUnselectedRuns = activeRuns.some(run => !selectedRunIds.has(run.runId));
|
|
1079
|
+
const actions = [];
|
|
1080
|
+
const keeps = [];
|
|
1081
|
+
const conflicts = [];
|
|
1082
|
+
const warnings = [];
|
|
1083
|
+
|
|
1084
|
+
if (selectedRuns.length === 0) {
|
|
1085
|
+
warnings.push(selectedIde ? `No active ${selectedIde} install found.` : 'No active install selected.');
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
for (let file of getSelectedFiles(selectedRuns)) {
|
|
1089
|
+
const relPath = toPosixPath(file.path);
|
|
1090
|
+
|
|
1091
|
+
if (file.protected && options.purgeState && hasUnselectedRuns) {
|
|
1092
|
+
keeps.push({ path: relPath, reason: 'shared-with-other-install' });
|
|
1093
|
+
continue;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
if (file.protected && options.purgeState && file.action === 'skipped' && !hasUnselectedRuns) {
|
|
1097
|
+
const historicalFile = getHistoricalFileRecord(manifest, relPath);
|
|
1098
|
+
if (historicalFile && historicalFile.action !== 'skipped') file = historicalFile;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
if (shouldKeepFile(file, options)) {
|
|
1102
|
+
keeps.push({ path: relPath, reason: file.protected ? 'state-preserved' : 'not-owned' });
|
|
1103
|
+
continue;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const fullPath = safeResolve(targetDir, relPath);
|
|
1107
|
+
if (!fs.existsSync(fullPath)) {
|
|
1108
|
+
keeps.push({ path: relPath, reason: 'already-missing' });
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
const stat = fs.lstatSync(fullPath);
|
|
1113
|
+
if (stat.isDirectory()) {
|
|
1114
|
+
keeps.push({ path: relPath, reason: 'directory-entry-skipped' });
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
1117
|
+
if (stat.isSymbolicLink()) {
|
|
1118
|
+
conflicts.push({ path: relPath, reason: 'symlink-skipped' });
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
const currentHash = hashFile(fullPath);
|
|
1123
|
+
const otherOwners = getOtherOwnerRecords(activeRuns, selectedRunIds, relPath, file.protected && options.purgeState);
|
|
1124
|
+
if (otherOwners.length > 0) {
|
|
1125
|
+
const latestOtherOwner = otherOwners[otherOwners.length - 1];
|
|
1126
|
+
if (file.protected) {
|
|
1127
|
+
keeps.push({ path: relPath, reason: `shared-with-${otherOwners.length}-other-install${otherOwners.length > 1 ? 's' : ''}` });
|
|
1128
|
+
continue;
|
|
1129
|
+
}
|
|
1130
|
+
if (!latestOtherOwner.hashAfter || currentHash === latestOtherOwner.hashAfter) {
|
|
1131
|
+
keeps.push({ path: relPath, reason: `shared-with-${otherOwners.length}-other-install${otherOwners.length > 1 ? 's' : ''}` });
|
|
1132
|
+
continue;
|
|
1133
|
+
}
|
|
1134
|
+
if (file.backupPath) {
|
|
1135
|
+
const backupPath = safeResolve(targetDir, file.backupPath);
|
|
1136
|
+
if (fs.existsSync(backupPath) && hashFile(backupPath) === latestOtherOwner.hashAfter) {
|
|
1137
|
+
actions.push({ type: 'restore', path: relPath, backupPath: file.backupPath });
|
|
1138
|
+
continue;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
conflicts.push({ path: relPath, reason: 'shared-content-mismatch' });
|
|
1142
|
+
continue;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
const unchanged = !file.hashAfter || currentHash === file.hashAfter;
|
|
1146
|
+
const canUseLegacyMarker = file.action === 'legacy' && hasFrameworkMarker(fs.readFileSync(fullPath, 'utf8'));
|
|
1147
|
+
if (!unchanged && !options.force && !canUseLegacyMarker) {
|
|
1148
|
+
conflicts.push({ path: relPath, reason: 'modified-since-install' });
|
|
1149
|
+
continue;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
if (file.action === 'overwritten' && file.backupPath) {
|
|
1153
|
+
const backupPath = getRestoreBackupPath(targetDir, manifest, file);
|
|
1154
|
+
if (backupPath) {
|
|
1155
|
+
actions.push({ type: 'restore', path: relPath, backupPath });
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
const originalBackupPath = safeResolve(targetDir, file.backupPath);
|
|
1159
|
+
if (!fs.existsSync(originalBackupPath)) {
|
|
1160
|
+
warnings.push(`Backup missing for ${relPath}; deleting generated file instead.`);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
actions.push({ type: 'delete', path: relPath });
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
if (options.purgeState) {
|
|
1168
|
+
if (hasUnselectedRuns) {
|
|
1169
|
+
warnings.push('State and team helper patches are preserved because other active installs remain. Use --all to purge them together.');
|
|
1170
|
+
}
|
|
1171
|
+
for (const run of selectedRuns) {
|
|
1172
|
+
for (const patch of (run.patches || [])) {
|
|
1173
|
+
if (patch.action !== 'appended') continue;
|
|
1174
|
+
if (hasUnselectedRuns) {
|
|
1175
|
+
keeps.push({ path: patch.path, reason: 'shared-with-other-install' });
|
|
1176
|
+
continue;
|
|
1177
|
+
}
|
|
1178
|
+
const fullPath = safeResolve(targetDir, patch.path);
|
|
1179
|
+
if (fs.existsSync(fullPath)) {
|
|
1180
|
+
actions.push({ type: 'remove-block', path: patch.path, block: patch.block });
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
if (!hasUnselectedRuns) {
|
|
1185
|
+
const helperBlocks = [
|
|
1186
|
+
{ path: '.gitignore', block: TEAM_GITIGNORE_ENTRY },
|
|
1187
|
+
{ path: '.gitattributes', block: TEAM_GITATTRIBUTES_CONTENT },
|
|
1188
|
+
];
|
|
1189
|
+
for (const helper of helperBlocks) {
|
|
1190
|
+
if (actions.some(action => action.path === helper.path)) continue;
|
|
1191
|
+
const fullPath = safeResolve(targetDir, helper.path);
|
|
1192
|
+
if (!fs.existsSync(fullPath)) continue;
|
|
1193
|
+
const content = fs.readFileSync(fullPath, 'utf8');
|
|
1194
|
+
if (content.includes(helper.block) || content.includes(helper.block.trimStart())) {
|
|
1195
|
+
actions.push({ type: 'remove-block', path: helper.path, block: helper.block });
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
if (options.purgeBackups) {
|
|
1202
|
+
if (hasUnselectedRuns) {
|
|
1203
|
+
warnings.push('Backup purge skipped because other active installs remain. Use --all to purge backups after all installs are removed.');
|
|
1204
|
+
} else {
|
|
1205
|
+
for (const relPath of ['.harness/init-backups', '.harness/uninstall-backups']) {
|
|
1206
|
+
actions.push({ type: 'delete-tree', path: relPath });
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
return { manifest, selectedRuns, actions, conflicts, keeps, warnings };
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
function backupBeforeUninstall(targetDir, backupRoot, relPath) {
|
|
1215
|
+
const source = safeResolve(targetDir, relPath);
|
|
1216
|
+
if (!fs.existsSync(source)) return null;
|
|
1217
|
+
const stat = fs.lstatSync(source);
|
|
1218
|
+
if (!stat.isFile()) return null;
|
|
1219
|
+
const backupPath = path.join(backupRoot, relPath);
|
|
1220
|
+
fs.mkdirSync(path.dirname(backupPath), { recursive: true });
|
|
1221
|
+
fs.copyFileSync(source, backupPath);
|
|
1222
|
+
return toPosixPath(path.relative(targetDir, backupPath));
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
function removeEmptyParents(targetDir, relPath) {
|
|
1226
|
+
let current = path.dirname(safeResolve(targetDir, relPath));
|
|
1227
|
+
const root = path.resolve(targetDir);
|
|
1228
|
+
while (current !== root && current.startsWith(root + path.sep)) {
|
|
1229
|
+
if (!fs.existsSync(current) || !isDirectoryEmpty(current)) break;
|
|
1230
|
+
fs.rmdirSync(current);
|
|
1231
|
+
current = path.dirname(current);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
function applyUninstallPlan(targetDir, plan, options) {
|
|
1236
|
+
const uninstallRunId = getBackupTimestamp();
|
|
1237
|
+
const backupRoot = path.join(targetDir, '.harness', 'uninstall-backups', uninstallRunId);
|
|
1238
|
+
const applied = [];
|
|
1239
|
+
|
|
1240
|
+
for (const action of plan.actions) {
|
|
1241
|
+
if (action.type === 'delete') {
|
|
1242
|
+
const fullPath = safeResolve(targetDir, action.path);
|
|
1243
|
+
if (!fs.existsSync(fullPath)) continue;
|
|
1244
|
+
backupBeforeUninstall(targetDir, backupRoot, action.path);
|
|
1245
|
+
fs.unlinkSync(fullPath);
|
|
1246
|
+
removeEmptyParents(targetDir, action.path);
|
|
1247
|
+
applied.push(action);
|
|
1248
|
+
} else if (action.type === 'restore') {
|
|
1249
|
+
const fullPath = safeResolve(targetDir, action.path);
|
|
1250
|
+
const backupPath = safeResolve(targetDir, action.backupPath);
|
|
1251
|
+
backupBeforeUninstall(targetDir, backupRoot, action.path);
|
|
1252
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
1253
|
+
fs.copyFileSync(backupPath, fullPath);
|
|
1254
|
+
applied.push(action);
|
|
1255
|
+
} else if (action.type === 'remove-block') {
|
|
1256
|
+
const fullPath = safeResolve(targetDir, action.path);
|
|
1257
|
+
if (!fs.existsSync(fullPath)) continue;
|
|
1258
|
+
const content = fs.readFileSync(fullPath, 'utf8');
|
|
1259
|
+
let updated = content;
|
|
1260
|
+
if (updated.includes(action.block)) updated = updated.replace(action.block, '');
|
|
1261
|
+
else if (updated.includes(action.block.trimStart())) updated = updated.replace(action.block.trimStart(), '');
|
|
1262
|
+
else continue;
|
|
1263
|
+
backupBeforeUninstall(targetDir, backupRoot, action.path);
|
|
1264
|
+
if (updated.trim() === '') {
|
|
1265
|
+
fs.unlinkSync(fullPath);
|
|
1266
|
+
removeEmptyParents(targetDir, action.path);
|
|
1267
|
+
} else {
|
|
1268
|
+
fs.writeFileSync(fullPath, updated, 'utf8');
|
|
1269
|
+
}
|
|
1270
|
+
applied.push(action);
|
|
1271
|
+
} else if (action.type === 'delete-tree') {
|
|
1272
|
+
const fullPath = safeResolve(targetDir, action.path);
|
|
1273
|
+
if (!fs.existsSync(fullPath)) continue;
|
|
1274
|
+
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
1275
|
+
applied.push(action);
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
if (plan.manifest && Array.isArray(plan.manifest.runs)) {
|
|
1280
|
+
const selectedRunIds = new Set(plan.selectedRuns.map(run => run.runId));
|
|
1281
|
+
const purgesUninstallBackups = plan.actions.some(action => action.type === 'delete-tree' && action.path === '.harness/uninstall-backups');
|
|
1282
|
+
for (const run of plan.manifest.runs) {
|
|
1283
|
+
if (selectedRunIds.has(run.runId)) {
|
|
1284
|
+
run.status = 'uninstalled';
|
|
1285
|
+
run.uninstalledAt = new Date().toISOString();
|
|
1286
|
+
run.uninstallBackupPath = purgesUninstallBackups ? null : toPosixPath(path.relative(targetDir, backupRoot));
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
plan.manifest.updatedAt = new Date().toISOString();
|
|
1290
|
+
const hasActiveRuns = plan.manifest.runs.some(run => run.status !== 'uninstalled');
|
|
1291
|
+
if (options.purgeState && options.purgeBackups && !hasActiveRuns) {
|
|
1292
|
+
const manifestPath = safeResolve(targetDir, MANIFEST_PATH);
|
|
1293
|
+
if (fs.existsSync(manifestPath)) fs.unlinkSync(manifestPath);
|
|
1294
|
+
removeEmptyParents(targetDir, MANIFEST_PATH);
|
|
1295
|
+
} else {
|
|
1296
|
+
writeManifest(targetDir, plan.manifest);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
if (fs.existsSync(backupRoot) && isDirectoryEmpty(backupRoot)) {
|
|
1301
|
+
fs.rmdirSync(backupRoot);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
return applied;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
function printUninstallPlan(plan, options) {
|
|
1308
|
+
const mode = options.dryRun ? 'dry-run' : 'apply';
|
|
1309
|
+
console.log(`\n kode:harness uninstall plan (${mode})\n`);
|
|
1310
|
+
for (const warning of plan.warnings) console.log(` WARN ${warning}`);
|
|
1311
|
+
for (const keep of plan.keeps) console.log(` KEEP ${keep.path} — ${keep.reason}`);
|
|
1312
|
+
for (const conflict of plan.conflicts) console.log(` SKIP ${conflict.path} — ${conflict.reason}`);
|
|
1313
|
+
for (const action of plan.actions) {
|
|
1314
|
+
const verb = action.type === 'restore' ? 'RESTORE' : action.type === 'remove-block' ? 'PATCH' : 'REMOVE';
|
|
1315
|
+
console.log(` ${verb.padEnd(7)}${action.path}`);
|
|
1316
|
+
}
|
|
1317
|
+
console.log(`\n Summary: ${plan.actions.length} action(s), ${plan.keeps.length} kept, ${plan.conflicts.length} conflict(s)\n`);
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
async function runUninstall(args) {
|
|
1321
|
+
const options = {
|
|
1322
|
+
ide: args.ide,
|
|
1323
|
+
all: args.all,
|
|
1324
|
+
dryRun: args.dryRun,
|
|
1325
|
+
yes: args.yes,
|
|
1326
|
+
purgeState: args.purgeState,
|
|
1327
|
+
purgeBackups: args.purgeBackups,
|
|
1328
|
+
force: args.force,
|
|
1329
|
+
};
|
|
1330
|
+
|
|
1331
|
+
let plan;
|
|
1332
|
+
try {
|
|
1333
|
+
plan = buildUninstallPlan(args.dir, options);
|
|
1334
|
+
} catch (error) {
|
|
1335
|
+
console.error(` ${error.message}`);
|
|
1336
|
+
process.exit(1);
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
if (args.json) {
|
|
1340
|
+
console.log(JSON.stringify(plan, null, 2));
|
|
1341
|
+
} else {
|
|
1342
|
+
printUninstallPlan(plan, options);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
if (options.dryRun || plan.actions.length === 0) return true;
|
|
1346
|
+
|
|
1347
|
+
if (!options.yes) {
|
|
1348
|
+
if (!process.stdin.isTTY) {
|
|
1349
|
+
console.error(' Refusing to uninstall non-interactively without --yes. Re-run with --dry-run to preview or --yes to apply.');
|
|
1350
|
+
process.exit(1);
|
|
1351
|
+
}
|
|
1352
|
+
const answer = await askQuestion(' Apply this uninstall plan? (y/N): ');
|
|
1353
|
+
if (!['y', 'yes'].includes(answer.toLowerCase())) {
|
|
1354
|
+
console.log(' Uninstall cancelled.');
|
|
1355
|
+
process.exit(2);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
if (options.purgeState && !options.yes && process.stdin.isTTY) {
|
|
1360
|
+
const answer = await askQuestion(' Type "delete state" to confirm state deletion: ');
|
|
1361
|
+
if (answer !== 'delete state') {
|
|
1362
|
+
console.log(' State purge cancelled.');
|
|
1363
|
+
process.exit(2);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
try {
|
|
1368
|
+
const applied = applyUninstallPlan(args.dir, plan, options);
|
|
1369
|
+
if (!args.json) console.log(` Applied ${applied.length} action(s).`);
|
|
1370
|
+
if (plan.conflicts.length > 0) process.exit(3);
|
|
1371
|
+
return true;
|
|
1372
|
+
} catch (error) {
|
|
1373
|
+
console.error(` Uninstall failed: ${error.message}`);
|
|
1374
|
+
process.exit(3);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
689
1378
|
// ─── CLI entry ───────────────────────────────────────────────
|
|
690
1379
|
function showHelp() {
|
|
691
1380
|
console.log(`
|
|
@@ -695,17 +1384,26 @@ function showHelp() {
|
|
|
695
1384
|
npx @kodevibe/harness init [options]
|
|
696
1385
|
npx @kodevibe/harness doctor [--dir <path>]
|
|
697
1386
|
npx @kodevibe/harness validate [--dir <path>]
|
|
1387
|
+
npx @kodevibe/harness uninstall [options]
|
|
698
1388
|
|
|
699
1389
|
Commands:
|
|
700
1390
|
init Install kode:harness files for your IDE
|
|
701
1391
|
doctor Check if kode:harness files are installed and healthy
|
|
702
1392
|
validate Verify state files have content (not just placeholders)
|
|
1393
|
+
uninstall Safely remove kode:harness IDE files (state preserved by default)
|
|
703
1394
|
|
|
704
1395
|
Options:
|
|
705
1396
|
--ide <name> IDE target: vscode, claude, cursor, codex, windsurf, antigravity
|
|
1397
|
+
--all Uninstall all detected IDE layouts
|
|
706
1398
|
--mode <mode> Project mode: solo (default) or team
|
|
707
1399
|
--dir <path> Target directory (default: current directory)
|
|
708
1400
|
--overwrite Overwrite existing files (including state files)
|
|
1401
|
+
--dry-run Print uninstall plan without changing files
|
|
1402
|
+
--yes Confirm uninstall non-interactively
|
|
1403
|
+
--purge-state Also remove generated state files when unchanged
|
|
1404
|
+
--purge-backups Also remove kode:harness backup directories
|
|
1405
|
+
--force Apply despite changed managed files
|
|
1406
|
+
--json Print machine-readable uninstall plan
|
|
709
1407
|
--batch Non-interactive mode (requires --ide; defaults to solo mode)
|
|
710
1408
|
--version Show version number
|
|
711
1409
|
--help Show this help
|
|
@@ -717,16 +1415,36 @@ function showHelp() {
|
|
|
717
1415
|
npx @kodevibe/harness init --ide claude --dir ./my-project
|
|
718
1416
|
npx @kodevibe/harness doctor
|
|
719
1417
|
npx @kodevibe/harness validate
|
|
1418
|
+
npx @kodevibe/harness uninstall --ide claude --dry-run
|
|
1419
|
+
npx @kodevibe/harness uninstall --ide claude --yes
|
|
720
1420
|
`);
|
|
721
1421
|
}
|
|
722
1422
|
|
|
723
1423
|
function parseArgs(argv) {
|
|
724
|
-
const args = {
|
|
1424
|
+
const args = {
|
|
1425
|
+
command: null,
|
|
1426
|
+
ide: null,
|
|
1427
|
+
mode: null,
|
|
1428
|
+
dir: process.cwd(),
|
|
1429
|
+
overwrite: false,
|
|
1430
|
+
help: false,
|
|
1431
|
+
batch: false,
|
|
1432
|
+
version: false,
|
|
1433
|
+
crew: false,
|
|
1434
|
+
all: false,
|
|
1435
|
+
dryRun: false,
|
|
1436
|
+
yes: false,
|
|
1437
|
+
purgeState: false,
|
|
1438
|
+
purgeBackups: false,
|
|
1439
|
+
force: false,
|
|
1440
|
+
json: false,
|
|
1441
|
+
};
|
|
725
1442
|
for (let i = 0; i < argv.length; i++) {
|
|
726
1443
|
const arg = argv[i];
|
|
727
1444
|
if (arg === 'init') args.command = 'init';
|
|
728
1445
|
else if (arg === 'doctor') args.command = 'doctor';
|
|
729
1446
|
else if (arg === 'validate') args.command = 'validate';
|
|
1447
|
+
else if (arg === 'uninstall') args.command = 'uninstall';
|
|
730
1448
|
else if (arg === '--ide' && argv[i + 1]) { args.ide = argv[++i]; }
|
|
731
1449
|
else if (arg === '--mode' && argv[i + 1]) { args.mode = argv[++i]; }
|
|
732
1450
|
else if (arg === '--team') { args.mode = 'team'; }
|
|
@@ -734,6 +1452,13 @@ function parseArgs(argv) {
|
|
|
734
1452
|
else if (arg === '--dir' && argv[i + 1]) { args.dir = path.resolve(argv[++i]); }
|
|
735
1453
|
else if (arg === '--overwrite') args.overwrite = true;
|
|
736
1454
|
else if (arg === '--batch') args.batch = true;
|
|
1455
|
+
else if (arg === '--all') args.all = true;
|
|
1456
|
+
else if (arg === '--dry-run') args.dryRun = true;
|
|
1457
|
+
else if (arg === '--yes' || arg === '-y') args.yes = true;
|
|
1458
|
+
else if (arg === '--purge-state' || arg === '--include-state') args.purgeState = true;
|
|
1459
|
+
else if (arg === '--purge-backups') args.purgeBackups = true;
|
|
1460
|
+
else if (arg === '--force') args.force = true;
|
|
1461
|
+
else if (arg === '--json') args.json = true;
|
|
737
1462
|
else if (arg === '--help' || arg === '-h') args.help = true;
|
|
738
1463
|
else if (arg === '--version') args.version = true;
|
|
739
1464
|
}
|
|
@@ -764,6 +1489,11 @@ async function run(argv) {
|
|
|
764
1489
|
process.exit(ok ? 0 : 1);
|
|
765
1490
|
}
|
|
766
1491
|
|
|
1492
|
+
if (args.command === 'uninstall') {
|
|
1493
|
+
await runUninstall(args);
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
767
1497
|
if (args.command === 'init') {
|
|
768
1498
|
console.log('\n kode:harness — Harness Engineering\n');
|
|
769
1499
|
|
|
@@ -833,6 +1563,8 @@ async function run(argv) {
|
|
|
833
1563
|
const modeDesc = crew ? `${mode} + crew` : mode;
|
|
834
1564
|
console.log(`\n Installing for ${gen.name} (${modeDesc} mode)... (detected language: ${lang})\n`);
|
|
835
1565
|
resetBackupTimestamp();
|
|
1566
|
+
const runId = getBackupTimestamp();
|
|
1567
|
+
startInstallRecording();
|
|
836
1568
|
gen.fn(args.dir, overwrite, mode, crew);
|
|
837
1569
|
|
|
838
1570
|
// Team mode extras
|
|
@@ -841,8 +1573,22 @@ async function run(argv) {
|
|
|
841
1573
|
writeGitattributes(args.dir);
|
|
842
1574
|
}
|
|
843
1575
|
|
|
1576
|
+
const { records, patches } = stopInstallRecording();
|
|
1577
|
+
saveInstallManifest(args.dir, {
|
|
1578
|
+
runId,
|
|
1579
|
+
command: 'init',
|
|
1580
|
+
ide,
|
|
1581
|
+
mode,
|
|
1582
|
+
crew,
|
|
1583
|
+
status: 'installed',
|
|
1584
|
+
packageVersion: readPackageVersion(),
|
|
1585
|
+
createdAt: new Date().toISOString(),
|
|
1586
|
+
files: records,
|
|
1587
|
+
patches,
|
|
1588
|
+
});
|
|
1589
|
+
|
|
844
1590
|
showPostInstallGuide(gen.name, mode);
|
|
845
1591
|
}
|
|
846
1592
|
}
|
|
847
1593
|
|
|
848
|
-
module.exports = { run, detectLanguage, runDoctor, runValidate };
|
|
1594
|
+
module.exports = { run, detectLanguage, runDoctor, runValidate, buildUninstallPlan };
|