harness-everything 0.16.4 → 0.16.6

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.
Files changed (36) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +12 -0
  4. package/ci/mechanism-2am-openai-packaged-helper-runtime.test.js +110 -0
  5. package/ci/mechanism-2an-environment-detection-readonly.test.js +113 -0
  6. package/ci/skill-routing-check.js +1 -1
  7. package/docs/workflows/environment-detection.md +15 -7
  8. package/environment-detection/SKILL.md +15 -16
  9. package/harness-everything/SKILL.md +1 -1
  10. package/harness-everything/scripts/bootstrap.js +4 -3
  11. package/harness-everything/scripts/runtime-paths.js +37 -0
  12. package/harness-everything/scripts/self-heal.js +31 -8
  13. package/harness-everything/scripts/tier-router.js +2 -6
  14. package/hooks/scripts/lib/platforms/claude.js +3 -3
  15. package/opencode-plugin/plugin.json +1 -1
  16. package/package.json +1 -1
  17. package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
  18. package/plugins/harness-everything/hooks/scripts/lib/platforms/claude.js +3 -3
  19. package/plugins/harness-everything/plugin.json +1 -1
  20. package/plugins/harness-everything/scripts/installer.js +629 -0
  21. package/plugins/harness-everything/scripts/lib/claude-hooks.js +84 -0
  22. package/plugins/harness-everything/scripts/lib/execution-contract.js +70 -0
  23. package/plugins/harness-everything/scripts/lib/gitignore.js +199 -0
  24. package/plugins/harness-everything/scripts/lib/manifest.js +142 -0
  25. package/plugins/harness-everything/scripts/lib/path-boundary.js +90 -0
  26. package/plugins/harness-everything/scripts/lib/prompts.js +241 -0
  27. package/plugins/harness-everything/scripts/lib/skills.js +314 -0
  28. package/plugins/harness-everything/scripts/lib/workspace.js +290 -0
  29. package/plugins/harness-everything/skills/environment-detection/SKILL.md +15 -16
  30. package/plugins/harness-everything/skills/harness-everything/SKILL.md +1 -1
  31. package/plugins/harness-everything/skills/harness-everything/scripts/bootstrap.js +4 -3
  32. package/plugins/harness-everything/skills/harness-everything/scripts/runtime-paths.js +37 -0
  33. package/plugins/harness-everything/skills/harness-everything/scripts/self-heal.js +31 -8
  34. package/plugins/harness-everything/skills/harness-everything/scripts/tier-router.js +2 -6
  35. package/scripts/installer.js +12 -4
  36. package/scripts/sync-openai-plugin.js +5 -2
@@ -0,0 +1,84 @@
1
+ // Merge/remove Harness's hook entries in a Claude Code settings.json without
2
+ // disturbing anything else a user (or another tool) put there.
3
+ const fs = require('fs');
4
+
5
+ // Every hook this package ships is namespaced "harness:<phase>:<name>" (see
6
+ // hooks/hooks.json) specifically so removal can match on that prefix alone.
7
+ // To handle legacy installations (pre-0.3.0 or older) where hooks were installed
8
+ // without an "id" field, we also check if any command contains keywords like
9
+ // "harness-everything" or "harness-skills".
10
+ function isHarnessHook(hook) {
11
+ if (!hook) return false;
12
+ if (hook.id && hook.id.startsWith('harness:')) {
13
+ return true;
14
+ }
15
+ if (Array.isArray(hook.hooks)) {
16
+ return hook.hooks.some(h => {
17
+ return h && typeof h.command === 'string' && (
18
+ h.command.includes('harness-everything') ||
19
+ h.command.includes('harness-skills') ||
20
+ h.command.includes('harness-state')
21
+ );
22
+ });
23
+ }
24
+ return false;
25
+ }
26
+
27
+ function mergeHarnessHooks(claudeConfig, resolvedHooks) {
28
+ claudeConfig.hooks = claudeConfig.hooks || {};
29
+ for (const [hookType, hookList] of Object.entries(resolvedHooks)) {
30
+ const existingList = claudeConfig.hooks[hookType] || [];
31
+ const mergedList = [...existingList];
32
+ hookList.forEach(newHook => {
33
+ const existsIdx = mergedList.findIndex(h => h.id === newHook.id);
34
+ if (existsIdx !== -1) {
35
+ mergedList[existsIdx] = newHook;
36
+ } else {
37
+ mergedList.push(newHook);
38
+ }
39
+ });
40
+ claudeConfig.hooks[hookType] = mergedList;
41
+ }
42
+ return claudeConfig;
43
+ }
44
+
45
+ function removeHarnessHooks(claudeSettingsFile) {
46
+ if (!fs.existsSync(claudeSettingsFile)) return false;
47
+ try {
48
+ const content = fs.readFileSync(claudeSettingsFile, 'utf8');
49
+ let config = JSON.parse(content);
50
+ if (!config.hooks) return false;
51
+
52
+ let modified = false;
53
+ for (const [hookType, hookList] of Object.entries(config.hooks)) {
54
+ if (!Array.isArray(hookList)) continue;
55
+ const originalLength = hookList.length;
56
+ config.hooks[hookType] = hookList.filter(hook => !isHarnessHook(hook));
57
+ if (config.hooks[hookType].length !== originalLength) {
58
+ modified = true;
59
+ }
60
+ if (config.hooks[hookType].length === 0) {
61
+ delete config.hooks[hookType];
62
+ }
63
+ }
64
+ if (Object.keys(config.hooks || {}).length === 0) {
65
+ delete config.hooks;
66
+ }
67
+
68
+ if (modified) {
69
+ if (Object.keys(config).length === 0) {
70
+ fs.unlinkSync(claudeSettingsFile);
71
+ console.log(` ✅ Cleaned up and removed empty Claude settings file: ${claudeSettingsFile}`);
72
+ } else {
73
+ fs.writeFileSync(claudeSettingsFile, JSON.stringify(config, null, 2), 'utf8');
74
+ console.log(` ✅ Safely removed Harness hooks from Claude settings: ${claudeSettingsFile}`);
75
+ }
76
+ return true;
77
+ }
78
+ } catch (e) {
79
+ console.warn(` ⚠️ Error cleaning Claude settings file ${claudeSettingsFile}: ${e.message}`);
80
+ }
81
+ return false;
82
+ }
83
+
84
+ module.exports = { isHarnessHook, mergeHarnessHooks, removeHarnessHooks };
@@ -0,0 +1,70 @@
1
+ const path = require('path');
2
+
3
+ function normalizeCommand(command) {
4
+ if (Array.isArray(command)) {
5
+ return command.map((part) => String(part));
6
+ }
7
+ if (typeof command !== 'string') return null;
8
+ return command.trim().replace(/\s+/g, ' ');
9
+ }
10
+
11
+ function commandsEqual(actual, expected) {
12
+ const left = normalizeCommand(actual);
13
+ const right = normalizeCommand(expected);
14
+ if (Array.isArray(left) || Array.isArray(right)) {
15
+ return Array.isArray(left) && Array.isArray(right) &&
16
+ left.length === right.length && left.every((part, index) => part === right[index]);
17
+ }
18
+ return left !== null && left === right;
19
+ }
20
+
21
+ function commandTokens(command) {
22
+ if (typeof command !== 'string') return [];
23
+ return command.match(/"[^"]*"|'[^']*'|\S+/g) || [];
24
+ }
25
+
26
+ function commandMatches(actual, expected) {
27
+ if (commandsEqual(actual, expected)) return true;
28
+ if (typeof actual !== 'string' || typeof expected !== 'string') return false;
29
+ const target = expected.trim();
30
+ if (!target || /\s/.test(target) || !/\.(?:c?js|mjs|py|sh|ps1|bat|cmd)$/i.test(target)) return false;
31
+ const expectedName = path.basename(target.replace(/\\/g, '/')).toLowerCase();
32
+ const tokens = commandTokens(actual).map((token) => token.replace(/^['"]|['"]$/g, ''));
33
+ if (tokens.length < 2) return false;
34
+ const launcher = path.basename(tokens[0].replace(/\\/g, '/')).toLowerCase();
35
+ if (!new Set(['node', 'node.exe', 'bun', 'bun.exe', 'deno', 'deno.exe', 'python', 'python.exe', 'python3', 'python3.exe', 'ruby', 'ruby.exe']).has(launcher)) return false;
36
+ return tokens.slice(1).some((token) => path.basename(token.replace(/\\/g, '/')).toLowerCase() === expectedName);
37
+ }
38
+
39
+ function normalizeRepoPath(value) {
40
+ if (typeof value !== 'string') return null;
41
+ const normalized = value.replace(/\\/g, '/').replace(/^\.\//, '');
42
+ const segments = normalized.split('/');
43
+ if (!normalized || normalized === '.' || path.posix.isAbsolute(normalized) || /^[A-Za-z]:\//.test(normalized) || segments.includes('..')) return null;
44
+ return normalized;
45
+ }
46
+
47
+ function extractPatchPaths(patchText) {
48
+ if (typeof patchText !== 'string') return [];
49
+ const paths = [];
50
+ const marker = /^\*\*\* (?:Update File|Add File|Delete File|Move to):\s*(.+?)\s*$/gm;
51
+ for (const match of patchText.matchAll(marker)) {
52
+ if (!paths.includes(match[1])) paths.push(match[1]);
53
+ }
54
+ return paths;
55
+ }
56
+
57
+ function patchPathsAuthorized(patchText, allowedPaths) {
58
+ const paths = extractPatchPaths(patchText).map(normalizeRepoPath);
59
+ const allowed = new Set(Array.from(allowedPaths || []).map(normalizeRepoPath));
60
+ return paths.length > 0 && paths.every((candidate) => candidate && allowed.has(candidate));
61
+ }
62
+
63
+ module.exports = {
64
+ commandMatches,
65
+ commandsEqual,
66
+ extractPatchPaths,
67
+ normalizeCommand,
68
+ normalizeRepoPath,
69
+ patchPathsAuthorized,
70
+ };
@@ -0,0 +1,199 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ /**
5
+ * Checks if a target pattern is already covered by a line in an ignore file.
6
+ * Handles exact matches, trailing slash differences, and parent directory subsumption
7
+ * (e.g. `.claude/` or `.claude` subsumes `.claude/harness-everything/state/`).
8
+ */
9
+ function isPatternCoveredByLine(pattern, gitignoreLine) {
10
+ const normPattern = pattern.trim().replace(/\\/g, '/');
11
+ const normLine = gitignoreLine.trim().replace(/\\/g, '/');
12
+
13
+ if (!normLine || normLine.startsWith('#') || normLine.startsWith('!')) {
14
+ return false;
15
+ }
16
+
17
+ const cleanLine = normLine.endsWith('/') ? normLine.slice(0, -1) : normLine;
18
+ const cleanPattern = normPattern.endsWith('/') ? normPattern.slice(0, -1) : normPattern;
19
+
20
+ // Exact match or slash variant (e.g. '.claude' vs '.claude/')
21
+ if (cleanLine === cleanPattern) {
22
+ return true;
23
+ }
24
+
25
+ // Parent directory check: e.g. '.claude' covers '.claude/harness-everything/'
26
+ if (cleanPattern.startsWith(cleanLine + '/')) {
27
+ return true;
28
+ }
29
+
30
+ return false;
31
+ }
32
+
33
+ /**
34
+ * Resolves the real git directory for a workspace. `.git` is usually a
35
+ * directory, but in submodules and worktrees it is a FILE containing
36
+ * `gitdir: <path>` - resolve that pointer so info/exclude lands in the right
37
+ * place. Returns null when the workspace has no git repository.
38
+ */
39
+ function resolveGitDir(wsRoot) {
40
+ const dotGit = path.join(wsRoot, '.git');
41
+ if (!fs.existsSync(dotGit)) return null;
42
+ try {
43
+ if (fs.statSync(dotGit).isDirectory()) return dotGit;
44
+ const content = fs.readFileSync(dotGit, 'utf8');
45
+ const m = content.match(/gitdir:\s*(.+)/);
46
+ if (!m) return null;
47
+ let gitdir = m[1].trim();
48
+ if (!path.isAbsolute(gitdir)) gitdir = path.resolve(wsRoot, gitdir);
49
+ return fs.existsSync(gitdir) ? gitdir : null;
50
+ } catch (e) {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ const EXCLUDE_BANNER_BEGIN = '# --- Harness OS state (auto-managed; local only) ---';
56
+ const EXCLUDE_BANNER_END = '# --- end Harness OS state ---';
57
+ // Legacy marker written by older installers straight into .gitignore.
58
+ const LEGACY_GITIGNORE_BANNER = '# Harness OS Auto-generated Ignore Rules';
59
+
60
+ function readExcludeLines(gitDir) {
61
+ try {
62
+ return fs.readFileSync(path.join(gitDir, 'info', 'exclude'), 'utf8').split(/\r?\n/);
63
+ } catch (e) {
64
+ return null; // no exclude file yet - treat as empty but writable
65
+ }
66
+ }
67
+
68
+ function writeExcludeSection(gitDir, patterns) {
69
+ const infoDir = path.join(gitDir, 'info');
70
+ fs.mkdirSync(infoDir, { recursive: true });
71
+ const excludePath = path.join(infoDir, 'exclude');
72
+ let lines = [];
73
+ try {
74
+ lines = fs.readFileSync(excludePath, 'utf8').split(/\r?\n/);
75
+ } catch (e) {}
76
+
77
+ // Drop our previous section (if any), then rebuild it from the requested
78
+ // patterns - so removals from getIgnorePatterns() propagate on reinstall.
79
+ const beginIdx = lines.indexOf(EXCLUDE_BANNER_BEGIN);
80
+ const endIdx = lines.indexOf(EXCLUDE_BANNER_END);
81
+ if (beginIdx !== -1 && endIdx !== -1) {
82
+ lines.splice(beginIdx, endIdx - beginIdx + 1);
83
+ }
84
+ while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop();
85
+
86
+ const section = ['', EXCLUDE_BANNER_BEGIN, ...patterns, EXCLUDE_BANNER_END, ''];
87
+ fs.writeFileSync(excludePath, lines.concat(section).join('\n'), 'utf8');
88
+ }
89
+
90
+ /**
91
+ * Idempotently records Harness state directories as LOCAL-ONLY ignores.
92
+ *
93
+ * Patterns go to `<gitdir>/info/exclude`, never the working tree's
94
+ * .gitignore - installing or self-healing must not produce a diff in the
95
+ * user's project. Also migrates away the legacy banner block that old
96
+ * versions appended to .gitignore.
97
+ */
98
+ function ensureWorkspaceGitignorePatterns(wsRoot, patterns) {
99
+ const wanted = (patterns || []).filter(Boolean);
100
+
101
+ // Migration: strip the legacy banner + its pattern lines from .gitignore.
102
+ const gitignorePath = path.join(wsRoot, '.gitignore');
103
+ try {
104
+ if (fs.existsSync(gitignorePath)) {
105
+ const content = fs.readFileSync(gitignorePath, 'utf8');
106
+ if (content.includes(LEGACY_GITIGNORE_BANNER)) {
107
+ const lines = content.split(/\r?\n/);
108
+ const start = lines.findIndex(l => l.trim() === LEGACY_GITIGNORE_BANNER);
109
+ let end = start;
110
+ // The legacy block ran until the first non-pattern line (blank/comment/other rule).
111
+ for (let i = start + 1; i < lines.length; i++) {
112
+ const t = lines[i].trim();
113
+ if (t === '' || t.startsWith('#')) break;
114
+ end = i;
115
+ }
116
+ lines.splice(start, end - start + 1);
117
+ while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop();
118
+ fs.writeFileSync(gitignorePath, lines.join('\n') + '\n', 'utf8');
119
+ }
120
+ }
121
+ } catch (err) {
122
+ console.warn(` ⚠️ Failed to migrate legacy .gitignore entries: ${err.message}`);
123
+ }
124
+
125
+ const gitDir = resolveGitDir(wsRoot);
126
+ if (!gitDir) {
127
+ // No git repo -> nothing local-only to write into. State dirs are gated
128
+ // elsewhere (bootstrap refuses unmanaged workspaces), so this is fine.
129
+ return;
130
+ }
131
+
132
+ try {
133
+ const existing = readExcludeLines(gitDir) || [];
134
+ // Skip the write entirely when every wanted pattern is already inside our
135
+ // managed section OR covered by any ignore line (user's own broader rule).
136
+ const outside = existing.filter(
137
+ l => l !== EXCLUDE_BANNER_BEGIN && l !== EXCLUDE_BANNER_END
138
+ );
139
+ const missing = wanted.filter(p => !outside.some(line => isPatternCoveredByLine(p, line)));
140
+ const currentSection =
141
+ beginEnd(existing).start === -1
142
+ ? []
143
+ : existing.slice(beginEnd(existing).start + 1, beginEnd(existing).end);
144
+ const sameSet =
145
+ currentSection.filter(l => l.trim() && !l.trim().startsWith('#')).join('\n') ===
146
+ wanted.join('\n');
147
+ if (missing.length === 0 && sameSet) return;
148
+
149
+ writeExcludeSection(gitDir, wanted);
150
+ console.log(` ✅ Recorded local-only ignore rules in .git/info/exclude: ${wanted.join(', ')}`);
151
+ } catch (err) {
152
+ console.warn(` ⚠️ Failed to update .git/info/exclude: ${err.message}`);
153
+ }
154
+ }
155
+
156
+ function beginEnd(lines) {
157
+ return { start: lines.indexOf(EXCLUDE_BANNER_BEGIN), end: lines.indexOf(EXCLUDE_BANNER_END) };
158
+ }
159
+
160
+ /**
161
+ * Removes Harness's section from .git/info/exclude (uninstall cleanup).
162
+ * Never touches user-authored exclude rules.
163
+ */
164
+ function removeWorkspaceExcludePatterns(wsRoot) {
165
+ const gitDir = resolveGitDir(wsRoot);
166
+ if (!gitDir) return;
167
+ try {
168
+ const excludePath = path.join(gitDir, 'info', 'exclude');
169
+ const lines = fs.readFileSync(excludePath, 'utf8').split(/\r?\n/);
170
+ const { start, end } = beginEnd(lines);
171
+ if (start === -1 || end === -1) return;
172
+ lines.splice(start, end - start + 1);
173
+ while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop();
174
+ fs.writeFileSync(excludePath, lines.join('\n') + '\n', 'utf8');
175
+ } catch (err) {
176
+ // Best-effort
177
+ }
178
+ }
179
+
180
+ // Only ever deletes a directory that is genuinely empty, and never recurses
181
+ // past one of `stopDirs` (workspace root / user home / filesystem root) - so
182
+ // this can only ever tidy up directories Harness itself just emptied out,
183
+ // never reach into a parent that still holds unrelated content.
184
+ function cleanEmptyDirs(dir, stopDirs) {
185
+ if (!fs.existsSync(dir)) return;
186
+ if ((stopDirs || []).includes(dir) || dir === path.parse(dir).root) return;
187
+ try {
188
+ const files = fs.readdirSync(dir);
189
+ if (files.length === 0) {
190
+ fs.rmdirSync(dir);
191
+ console.log(` ✅ Cleaned up empty directory: ${dir}`);
192
+ cleanEmptyDirs(path.dirname(dir), stopDirs);
193
+ }
194
+ } catch (e) {
195
+ // ignore
196
+ }
197
+ }
198
+
199
+ module.exports = { ensureWorkspaceGitignorePatterns, removeWorkspaceExcludePatterns, resolveGitDir, cleanEmptyDirs };
@@ -0,0 +1,142 @@
1
+ // Tracks exactly what this package installed, so uninstall can remove
2
+ // precisely those paths instead of sweeping shared directories that may also
3
+ // hold content from other tools, the user's own manual additions, or
4
+ // self-evolve's locally-generated skills.
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+
8
+ const PACKAGE_NAME = 'harness-everything';
9
+ const HARNESS_AUTHOR = 'Miya Daniel';
10
+ const HARNESS_DIR_NAME = 'harness-everything';
11
+
12
+ function getHarnessDir(homeDir) {
13
+ return path.join(homeDir, HARNESS_DIR_NAME);
14
+ }
15
+
16
+ function getManifestPath(homeDir) {
17
+ return path.join(getHarnessDir(homeDir), 'manifest.json');
18
+ }
19
+
20
+ function readManifest(manifestPath) {
21
+ if (!fs.existsSync(manifestPath)) return { package: PACKAGE_NAME, skills: [], agents: [] };
22
+ try {
23
+ const data = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
24
+ if (!Array.isArray(data.skills)) data.skills = [];
25
+ if (!Array.isArray(data.agents)) data.agents = [];
26
+ return data;
27
+ } catch (e) {
28
+ return { package: PACKAGE_NAME, skills: [], agents: [] };
29
+ }
30
+ }
31
+
32
+ function writeManifest(manifestPath, data) {
33
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
34
+ fs.writeFileSync(manifestPath, JSON.stringify(data, null, 2), 'utf8');
35
+ }
36
+
37
+ function isManifestEmpty(data) {
38
+ return (data.skills || []).length === 0 &&
39
+ (data.generated || []).length === 0 &&
40
+ (data.agents || []).length === 0;
41
+ }
42
+
43
+ // `references/` is copied by the installer into the same Harness-owned
44
+ // bookkeeping directory as manifest.json. Once the manifest contains no
45
+ // owned artifacts, both are package-owned leftovers and can be removed as a
46
+ // unit without touching the platform home around them.
47
+ function removeManifestArtifactsIfEmpty(manifestPath, data) {
48
+ if (!isManifestEmpty(data)) return false;
49
+ if (fs.existsSync(manifestPath)) fs.unlinkSync(manifestPath);
50
+ const harnessDir = path.dirname(manifestPath);
51
+ const referencesDir = path.join(harnessDir, 'references');
52
+ if (fs.existsSync(referencesDir)) fs.rmSync(referencesDir, { recursive: true, force: true });
53
+ try {
54
+ if (fs.existsSync(harnessDir) && fs.readdirSync(harnessDir).length === 0) fs.rmdirSync(harnessDir);
55
+ } catch (e) {
56
+ // Another owned runtime artifact may still live here; leave it intact.
57
+ }
58
+ return true;
59
+ }
60
+
61
+ function recordSkillInstall(manifestPath, packageVersion, skillId, dirPath, linkInfo = {}) {
62
+ const data = readManifest(manifestPath);
63
+ data.package = PACKAGE_NAME;
64
+ data.version = packageVersion;
65
+ data.updatedAt = new Date().toISOString();
66
+ const idx = data.skills.findIndex(s => s.dirPath === dirPath);
67
+ const entry = { id: skillId, dirPath, installedAt: new Date().toISOString() };
68
+ if (linkInfo.kind && linkInfo.kind !== 'copy') {
69
+ entry.kind = linkInfo.kind;
70
+ entry.canonicalPath = linkInfo.canonicalPath;
71
+ }
72
+ if (idx !== -1) data.skills[idx] = entry;
73
+ else data.skills.push(entry);
74
+ writeManifest(manifestPath, data);
75
+ }
76
+
77
+ function recordAgentInstall(manifestPath, packageVersion, agentId, filePath) {
78
+ const data = readManifest(manifestPath);
79
+ data.package = PACKAGE_NAME;
80
+ data.version = packageVersion;
81
+ data.updatedAt = new Date().toISOString();
82
+ const idx = data.agents.findIndex(agent => agent.filePath === filePath);
83
+ const entry = { id: agentId, filePath, installedAt: new Date().toISOString() };
84
+ if (idx !== -1) data.agents[idx] = entry;
85
+ else data.agents.push(entry);
86
+ writeManifest(manifestPath, data);
87
+ }
88
+
89
+ function recordGeneratedSkill(manifestPath, skillId, dirPath, description, triggers = []) {
90
+ const data = readManifest(manifestPath);
91
+ if (!Array.isArray(data.generated)) data.generated = [];
92
+ data.updatedAt = new Date().toISOString();
93
+ const idx = data.generated.findIndex(s => s.dirPath === dirPath || s.id === skillId);
94
+ const entry = {
95
+ id: skillId,
96
+ dirPath,
97
+ description,
98
+ triggers: Array.isArray(triggers) ? triggers : [],
99
+ generatedAt: new Date().toISOString()
100
+ };
101
+ if (idx !== -1) data.generated[idx] = { ...data.generated[idx], ...entry };
102
+ else data.generated.push(entry);
103
+ writeManifest(manifestPath, data);
104
+ }
105
+
106
+ function removeGeneratedSkill(manifestPath, dirPath) {
107
+ if (!fs.existsSync(manifestPath)) return;
108
+ const data = readManifest(manifestPath);
109
+ if (!Array.isArray(data.generated)) return;
110
+ data.generated = data.generated.filter(s => s.dirPath !== dirPath);
111
+ if (!removeManifestArtifactsIfEmpty(manifestPath, data)) writeManifest(manifestPath, data);
112
+ }
113
+
114
+ function removeSkillFromManifest(manifestPath, dirPath) {
115
+ if (!fs.existsSync(manifestPath)) return;
116
+ const data = readManifest(manifestPath);
117
+ data.skills = data.skills.filter(s => s.dirPath !== dirPath);
118
+ if (!removeManifestArtifactsIfEmpty(manifestPath, data)) writeManifest(manifestPath, data);
119
+ }
120
+
121
+ function removeAgentFromManifest(manifestPath, filePath) {
122
+ if (!fs.existsSync(manifestPath)) return;
123
+ const data = readManifest(manifestPath);
124
+ data.agents = data.agents.filter(agent => agent.filePath !== filePath);
125
+ if (!removeManifestArtifactsIfEmpty(manifestPath, data)) writeManifest(manifestPath, data);
126
+ }
127
+
128
+ module.exports = {
129
+ PACKAGE_NAME,
130
+ HARNESS_AUTHOR,
131
+ HARNESS_DIR_NAME,
132
+ getHarnessDir,
133
+ getManifestPath,
134
+ readManifest,
135
+ writeManifest,
136
+ recordSkillInstall,
137
+ recordAgentInstall,
138
+ recordGeneratedSkill,
139
+ removeGeneratedSkill,
140
+ removeSkillFromManifest,
141
+ removeAgentFromManifest,
142
+ };
@@ -0,0 +1,90 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ function boundaryError(code, message) {
5
+ const error = new Error(message);
6
+ error.code = code;
7
+ return error;
8
+ }
9
+
10
+ function realpath(p) {
11
+ const resolver = fs.realpathSync.native || fs.realpathSync;
12
+ return resolver(p);
13
+ }
14
+
15
+ // Resolve the deepest existing ancestor first, then append missing segments.
16
+ // This preserves the physical location of an existing junction/symlink even
17
+ // when the final file has not been created yet.
18
+ function canonicalizePath(candidate) {
19
+ const absolute = path.resolve(candidate);
20
+ const missing = [];
21
+ let cursor = absolute;
22
+
23
+ while (true) {
24
+ try {
25
+ fs.lstatSync(cursor);
26
+ return path.join(realpath(cursor), ...missing);
27
+ } catch (error) {
28
+ if (!['ENOENT', 'ENOTDIR'].includes(error.code)) throw error;
29
+ const parent = path.dirname(cursor);
30
+ if (parent === cursor) return path.join(cursor, ...missing);
31
+ missing.unshift(path.basename(cursor));
32
+ cursor = parent;
33
+ }
34
+ }
35
+ }
36
+
37
+ function isWithin(root, candidate) {
38
+ const relative = path.relative(root, candidate);
39
+ return relative === '' || (
40
+ relative !== '..' &&
41
+ !relative.startsWith(`..${path.sep}`) &&
42
+ !path.isAbsolute(relative)
43
+ );
44
+ }
45
+
46
+ // A caller may intentionally choose a symlink as the boundary root. Links
47
+ // below that root are rejected so a later write/delete cannot follow an alias
48
+ // whose target was changed after the initial lexical path check.
49
+ function assertNoLinkComponents(root, candidate) {
50
+ const lexicalRoot = path.resolve(root);
51
+ const lexicalCandidate = path.resolve(candidate);
52
+ const relative = path.relative(lexicalRoot, lexicalCandidate);
53
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return;
54
+
55
+ let cursor = lexicalRoot;
56
+ for (const segment of relative ? relative.split(path.sep) : []) {
57
+ cursor = path.join(cursor, segment);
58
+ try {
59
+ if (fs.lstatSync(cursor).isSymbolicLink()) {
60
+ throw boundaryError('LINK_COMPONENT_IN_BOUNDARY', `Link component is not allowed below boundary: ${cursor}`);
61
+ }
62
+ } catch (error) {
63
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') break;
64
+ throw error;
65
+ }
66
+ }
67
+ }
68
+
69
+ function assertContainedPath(root, candidate) {
70
+ if (typeof root !== 'string' || typeof candidate !== 'string') {
71
+ throw boundaryError('INVALID_BOUNDARY_PATH', 'Boundary root and candidate must be strings');
72
+ }
73
+
74
+ assertNoLinkComponents(root, candidate);
75
+ const physicalRoot = canonicalizePath(root);
76
+ const physicalCandidate = canonicalizePath(candidate);
77
+ if (!isWithin(physicalRoot, physicalCandidate)) {
78
+ throw boundaryError(
79
+ 'PATH_OUTSIDE_BOUNDARY',
80
+ `Path escapes boundary: ${candidate} is outside ${root}`
81
+ );
82
+ }
83
+ return { root: physicalRoot, target: physicalCandidate };
84
+ }
85
+
86
+ module.exports = {
87
+ assertContainedPath,
88
+ canonicalizePath,
89
+ isWithin,
90
+ };