@light-merlin-dark/skill-sync 0.1.2 → 0.1.4
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/CHANGELOG.md +14 -0
- package/README.md +43 -12
- package/SKILL.md +16 -9
- package/dist/index.js +444 -72
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.1.4
|
|
6
|
+
- Make bare `skill-sync` / `ss` show a high-signal landing/help view instead of mutating
|
|
7
|
+
- Add `doctor` as the high-signal diagnostic command and `execute` as the explicit mutating command; keep `check` and `sync` as compatibility aliases
|
|
8
|
+
- Discover harness-installed skills as fallback sources, while keeping project-root sources authoritative for the same slug
|
|
9
|
+
- Add frontmatter-based install scoping so harness-native skills can stay local-only or target specific harness ids instead of syncing everywhere
|
|
10
|
+
- Tighten symlink-first behavior by repairing matching copied installs instead of treating them as fully healthy
|
|
11
|
+
- Align CLI `--version` output with the package version
|
|
12
|
+
- Harden `make release` so patch releases can auto-bump version/changelog, publish, commit, tag, push, and refresh the GitHub release in one path
|
|
13
|
+
|
|
14
|
+
## 0.1.3
|
|
15
|
+
- Makefile release: robust GitHub release notes generation via `--notes-file` and correct escaping of `awk $0`.
|
|
16
|
+
|
|
3
17
|
## 0.1.2
|
|
4
18
|
- Report installed “orphan” skills during `skill-sync check` (helps explain why a harness skill UI didn’t update)
|
|
5
19
|
- Add `make release` target (pre-publish → npm publish → git tag + push → GitHub release create/update)
|
package/README.md
CHANGED
|
@@ -29,6 +29,7 @@ Sync local repo-backed agent skills across Codex, Claude Code, Cursor, Gemini, H
|
|
|
29
29
|
- top-level `SKILL.md`
|
|
30
30
|
- nested `skills/*/SKILL.md`
|
|
31
31
|
- Detects installed harness skill roots
|
|
32
|
+
- Supports scoped installs for harness-local skills
|
|
32
33
|
- Plans drift without changing anything
|
|
33
34
|
- Syncs symlinked installs into harnesses
|
|
34
35
|
- Warns when the same skill slug appears multiple times in your configured project roots
|
|
@@ -64,19 +65,23 @@ skill-sync sources
|
|
|
64
65
|
Check drift:
|
|
65
66
|
|
|
66
67
|
```bash
|
|
67
|
-
skill-sync
|
|
68
|
+
skill-sync doctor
|
|
69
|
+
skill-sync doctor --verbose
|
|
68
70
|
```
|
|
69
71
|
|
|
70
|
-
|
|
72
|
+
Execute:
|
|
71
73
|
|
|
72
74
|
```bash
|
|
73
|
-
skill-sync
|
|
75
|
+
skill-sync execute
|
|
76
|
+
skill-sync sync
|
|
74
77
|
```
|
|
75
78
|
|
|
76
79
|
Shortcut:
|
|
77
80
|
|
|
78
81
|
```bash
|
|
79
82
|
ss
|
|
83
|
+
ss doctor
|
|
84
|
+
ss execute
|
|
80
85
|
```
|
|
81
86
|
|
|
82
87
|
## Default Model
|
|
@@ -94,8 +99,9 @@ It complements `npx skills`; it does not replace it.
|
|
|
94
99
|
Core:
|
|
95
100
|
|
|
96
101
|
```bash
|
|
102
|
+
skill-sync doctor
|
|
97
103
|
skill-sync check
|
|
98
|
-
skill-sync
|
|
104
|
+
skill-sync execute
|
|
99
105
|
skill-sync sync
|
|
100
106
|
skill-sync sources
|
|
101
107
|
skill-sync harnesses
|
|
@@ -124,13 +130,15 @@ skill-sync harness remove my-tool
|
|
|
124
130
|
Agent-friendly options:
|
|
125
131
|
|
|
126
132
|
```bash
|
|
127
|
-
skill-sync
|
|
128
|
-
skill-sync
|
|
133
|
+
skill-sync doctor --json
|
|
134
|
+
skill-sync execute --dry-run --json
|
|
129
135
|
skill-sync backup restore <id> --dry-run
|
|
130
|
-
skill-sync
|
|
131
|
-
skill-sync
|
|
136
|
+
skill-sync doctor --projects-root /path/to/projects --harness codex
|
|
137
|
+
skill-sync doctor --home /tmp/fake-home
|
|
132
138
|
```
|
|
133
139
|
|
|
140
|
+
Bare `skill-sync` / `ss` prints a high-signal landing/help view. Human output defaults to a concise summary. Use `--verbose` when you want the full per-entry plan, including orphan install details during `doctor`.
|
|
141
|
+
|
|
134
142
|
## Supported Harness Model
|
|
135
143
|
|
|
136
144
|
Built-in harness roots currently include:
|
|
@@ -156,10 +164,12 @@ skill-sync harness add codex-beta ~/.codex-beta/skills
|
|
|
156
164
|
|
|
157
165
|
## Safety Rules
|
|
158
166
|
|
|
159
|
-
- `
|
|
160
|
-
- `sync` only
|
|
167
|
+
- `doctor` never mutates
|
|
168
|
+
- `execute` / `sync` only replace entries the tool owns or missing entries
|
|
161
169
|
- unmanaged conflicts are reported, not overwritten
|
|
162
170
|
- duplicate `_dev` slugs are surfaced before harness-level sync planning
|
|
171
|
+
- harness-installed skills can be promoted as fallback sources when no project-root source exists for the same slug
|
|
172
|
+
- harness-native skills can stay local-only via frontmatter instead of being fanned out everywhere
|
|
163
173
|
- backups snapshot harness `skills` directories before or after risky changes
|
|
164
174
|
- restore can recreate symlinks when the original source still exists
|
|
165
175
|
- restore falls back to minimal backed-up `SKILL.md` content when the source no longer exists
|
|
@@ -210,7 +220,7 @@ Each backup includes:
|
|
|
210
220
|
Example:
|
|
211
221
|
|
|
212
222
|
```bash
|
|
213
|
-
skill-sync
|
|
223
|
+
skill-sync doctor --json
|
|
214
224
|
```
|
|
215
225
|
|
|
216
226
|
Returns structured data for:
|
|
@@ -229,6 +239,13 @@ For every immediate child repo under each configured projects root, `skill-sync`
|
|
|
229
239
|
- `<repo>/SKILL.md`
|
|
230
240
|
- `<repo>/skills/*/SKILL.md`
|
|
231
241
|
|
|
242
|
+
It also inspects detected harness roots and can treat installed skills there as fallback sources. Project-root sources win over harness-installed sources when the same slug exists in both places.
|
|
243
|
+
|
|
244
|
+
Harness-root sources can also declare install scope in frontmatter:
|
|
245
|
+
|
|
246
|
+
- `skill-sync-scope: local-only` keeps that source on its owning harness only
|
|
247
|
+
- `skill-sync-install-on: [codex, hermes]` limits installs to specific harness ids
|
|
248
|
+
|
|
232
249
|
Canonical install names default to:
|
|
233
250
|
|
|
234
251
|
- `slugify(frontmatter name)` when `name:` exists
|
|
@@ -262,7 +279,7 @@ bun install
|
|
|
262
279
|
Run the CLI directly:
|
|
263
280
|
|
|
264
281
|
```bash
|
|
265
|
-
bun run src/index.ts
|
|
282
|
+
bun run src/index.ts doctor
|
|
266
283
|
```
|
|
267
284
|
|
|
268
285
|
Run tests:
|
|
@@ -277,6 +294,16 @@ Build:
|
|
|
277
294
|
bun run build
|
|
278
295
|
```
|
|
279
296
|
|
|
297
|
+
## Release
|
|
298
|
+
|
|
299
|
+
Patch release:
|
|
300
|
+
|
|
301
|
+
```bash
|
|
302
|
+
make release
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
This bumps the patch version, moves the `## Unreleased` notes into the new versioned changelog section, runs lint/test/build, publishes to npm, commits the release, pushes `main`, tags the release, and creates or updates the GitHub release.
|
|
306
|
+
|
|
280
307
|
## Positioning
|
|
281
308
|
|
|
282
309
|
Use `npx skills` when:
|
|
@@ -293,3 +320,7 @@ Use `skill-sync` when:
|
|
|
293
320
|
## License
|
|
294
321
|
|
|
295
322
|
MIT
|
|
323
|
+
|
|
324
|
+
---
|
|
325
|
+
|
|
326
|
+
Built by [Robert E. Beckner III (Merlin)](https://rbeckner.com)
|
package/SKILL.md
CHANGED
|
@@ -9,9 +9,9 @@ Use `skill-sync` as the default interface for local skill-harness maintenance.
|
|
|
9
9
|
|
|
10
10
|
## Core Workflow
|
|
11
11
|
1. Inspect harness detection and discovered skill sources.
|
|
12
|
-
2. Run a
|
|
12
|
+
2. Run a doctor pass before making changes.
|
|
13
13
|
3. Create a backup before risky cleanup or restore work.
|
|
14
|
-
4.
|
|
14
|
+
4. Execute or restore.
|
|
15
15
|
5. Verify the resulting symlinks or restored content.
|
|
16
16
|
|
|
17
17
|
Start with:
|
|
@@ -19,19 +19,21 @@ Start with:
|
|
|
19
19
|
```bash
|
|
20
20
|
skill-sync harnesses
|
|
21
21
|
skill-sync sources
|
|
22
|
-
skill-sync
|
|
22
|
+
skill-sync doctor
|
|
23
|
+
skill-sync doctor --verbose
|
|
23
24
|
```
|
|
24
25
|
|
|
25
26
|
Apply changes:
|
|
26
27
|
|
|
27
28
|
```bash
|
|
28
|
-
skill-sync
|
|
29
|
+
skill-sync execute
|
|
30
|
+
skill-sync sync
|
|
29
31
|
```
|
|
30
32
|
|
|
31
33
|
Or explicitly:
|
|
32
34
|
|
|
33
35
|
```bash
|
|
34
|
-
skill-sync
|
|
36
|
+
skill-sync execute
|
|
35
37
|
```
|
|
36
38
|
|
|
37
39
|
## Backup Workflow
|
|
@@ -65,17 +67,22 @@ skill-sync backup restore <backup-id>
|
|
|
65
67
|
Use JSON when the output will be consumed by another tool or agent:
|
|
66
68
|
|
|
67
69
|
```bash
|
|
68
|
-
skill-sync
|
|
70
|
+
skill-sync doctor --json
|
|
69
71
|
skill-sync sources --json
|
|
70
72
|
skill-sync harnesses --json
|
|
73
|
+
skill-sync execute --json
|
|
71
74
|
```
|
|
72
75
|
|
|
76
|
+
Bare `skill-sync` prints a high-signal landing/help view. Default human output is concise. Add `--verbose` when you need the full per-entry plan and orphan listing.
|
|
77
|
+
|
|
73
78
|
## Safety Rules
|
|
74
79
|
|
|
75
|
-
- Prefer `
|
|
76
|
-
- If `
|
|
77
|
-
- removing the unmanaged directory/file and re-running `
|
|
80
|
+
- Prefer `doctor` before `execute`.
|
|
81
|
+
- If `doctor` reports a `conflict` due to an existing *unmanaged* install (common case: a skill folder already exists in a harness root like `~/.hermes/skills/<skill>`), resolve by either:
|
|
82
|
+
- removing the unmanaged directory/file and re-running `execute`, or
|
|
78
83
|
- restoring via `skill-sync backup restore <backup-id>`.
|
|
79
84
|
Do not leave mixed symlink + real directories behind.
|
|
80
85
|
- Use `--home` for isolated testing against a fake home directory.
|
|
81
86
|
- Use `--projects-root` when you need to constrain discovery to a specific source tree.
|
|
87
|
+
- Project-root sources are authoritative. Harness-installed skills act as fallback sources when no project-root source exists for the same slug.
|
|
88
|
+
- If a harness-native skill should not fan out globally, add `skill-sync-scope: local-only` to its frontmatter. Use `skill-sync-install-on: [harness-a, harness-b]` for explicit multi-harness targeting.
|
package/dist/index.js
CHANGED
|
@@ -666,18 +666,53 @@ function timestampId() {
|
|
|
666
666
|
function slugify(input) {
|
|
667
667
|
return input.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[_\s]+/g, "-").replace(/[^a-zA-Z0-9.-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase();
|
|
668
668
|
}
|
|
669
|
-
function
|
|
670
|
-
const
|
|
671
|
-
if (!
|
|
672
|
-
return;
|
|
673
|
-
}
|
|
674
|
-
const
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
669
|
+
function parseSkillFrontmatterContent(content) {
|
|
670
|
+
const frontmatterLines = extractFrontmatterLines(content);
|
|
671
|
+
if (!frontmatterLines) {
|
|
672
|
+
return {};
|
|
673
|
+
}
|
|
674
|
+
const frontmatter = {};
|
|
675
|
+
for (let index = 0;index < frontmatterLines.length; index += 1) {
|
|
676
|
+
const line = frontmatterLines[index];
|
|
677
|
+
const nameMatch = line.match(/^name:\s*(.+)\s*$/);
|
|
678
|
+
if (nameMatch) {
|
|
679
|
+
frontmatter.name = stripYamlQuotes(nameMatch[1].trim());
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
const scopeMatch = line.match(/^skill-sync-scope:\s*(.+)\s*$/);
|
|
683
|
+
if (scopeMatch) {
|
|
684
|
+
const value = stripYamlQuotes(scopeMatch[1].trim()).toLowerCase();
|
|
685
|
+
if (value === "global" || value === "local-only") {
|
|
686
|
+
frontmatter.skillSyncScope = value;
|
|
687
|
+
}
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
const installOnInlineMatch = line.match(/^skill-sync-install-on:\s*(.+)\s*$/);
|
|
691
|
+
if (installOnInlineMatch) {
|
|
692
|
+
const parsed = parseFrontmatterListValue(installOnInlineMatch[1]);
|
|
693
|
+
if (parsed.length > 0) {
|
|
694
|
+
frontmatter.skillSyncInstallOn = parsed;
|
|
695
|
+
}
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
if (!/^skill-sync-install-on:\s*$/.test(line)) {
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
const values = [];
|
|
702
|
+
while (index + 1 < frontmatterLines.length) {
|
|
703
|
+
const nextLine = frontmatterLines[index + 1];
|
|
704
|
+
const itemMatch = nextLine.match(/^\s*-\s+(.+)\s*$/);
|
|
705
|
+
if (!itemMatch) {
|
|
706
|
+
break;
|
|
707
|
+
}
|
|
708
|
+
values.push(stripYamlQuotes(itemMatch[1].trim()));
|
|
709
|
+
index += 1;
|
|
710
|
+
}
|
|
711
|
+
if (values.length > 0) {
|
|
712
|
+
frontmatter.skillSyncInstallOn = values;
|
|
713
|
+
}
|
|
678
714
|
}
|
|
679
|
-
|
|
680
|
-
return match?.[1]?.trim();
|
|
715
|
+
return frontmatter;
|
|
681
716
|
}
|
|
682
717
|
function hashContent(content) {
|
|
683
718
|
return createHash("sha1").update(content).digest("hex");
|
|
@@ -718,13 +753,45 @@ function readFileSyncLink(path) {
|
|
|
718
753
|
return readlinkSync(path);
|
|
719
754
|
}
|
|
720
755
|
function pathOwnsEntry(rootPath, entryPath) {
|
|
721
|
-
const normalizedRoot =
|
|
722
|
-
const normalizedEntry =
|
|
756
|
+
const normalizedRoot = normalizeComparablePath(rootPath);
|
|
757
|
+
const normalizedEntry = normalizeComparablePath(entryPath);
|
|
723
758
|
return normalizedEntry === normalizedRoot || normalizedEntry.startsWith(`${normalizedRoot}/`);
|
|
724
759
|
}
|
|
725
760
|
function removePath(path) {
|
|
726
761
|
rmSync(path, { recursive: true, force: true });
|
|
727
762
|
}
|
|
763
|
+
function extractFrontmatterLines(content) {
|
|
764
|
+
const lines = content.split(/\r?\n/);
|
|
765
|
+
if (lines[0] !== "---") {
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
const closingIndex = lines.findIndex((line, index) => index > 0 && line === "---");
|
|
769
|
+
if (closingIndex === -1) {
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
return lines.slice(1, closingIndex);
|
|
773
|
+
}
|
|
774
|
+
function stripYamlQuotes(value) {
|
|
775
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
776
|
+
return value.slice(1, -1);
|
|
777
|
+
}
|
|
778
|
+
return value;
|
|
779
|
+
}
|
|
780
|
+
function parseFrontmatterListValue(rawValue) {
|
|
781
|
+
const value = stripYamlQuotes(rawValue.trim());
|
|
782
|
+
if (!value) {
|
|
783
|
+
return [];
|
|
784
|
+
}
|
|
785
|
+
const inner = value.startsWith("[") && value.endsWith("]") ? value.slice(1, -1) : value;
|
|
786
|
+
return [...new Set(inner.split(",").map((item) => stripYamlQuotes(item.trim())).filter(Boolean))];
|
|
787
|
+
}
|
|
788
|
+
function normalizeComparablePath(path) {
|
|
789
|
+
try {
|
|
790
|
+
return realpathSync(path);
|
|
791
|
+
} catch {
|
|
792
|
+
return resolve(path);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
728
795
|
|
|
729
796
|
// src/core/backup.ts
|
|
730
797
|
function createBackup(runtime, harnesses, state) {
|
|
@@ -950,7 +1017,8 @@ function getDefaultConfig(homeDir) {
|
|
|
950
1017
|
projectsRoots: [join3(homeDir, "_dev")],
|
|
951
1018
|
discovery: {
|
|
952
1019
|
ignorePathPrefixes: [],
|
|
953
|
-
preferPathPrefixes: []
|
|
1020
|
+
preferPathPrefixes: [],
|
|
1021
|
+
includeHarnessRoots: true
|
|
954
1022
|
},
|
|
955
1023
|
harnesses: {
|
|
956
1024
|
custom: []
|
|
@@ -975,7 +1043,8 @@ function loadConfig(runtime) {
|
|
|
975
1043
|
projectsRoots: (config.projectsRoots || []).map((root) => expandHomePath(root, runtime.homeDir)),
|
|
976
1044
|
discovery: {
|
|
977
1045
|
ignorePathPrefixes: (config.discovery?.ignorePathPrefixes || []).map((path) => expandHomePath(path, runtime.homeDir)),
|
|
978
|
-
preferPathPrefixes: (config.discovery?.preferPathPrefixes || []).map((path) => expandHomePath(path, runtime.homeDir))
|
|
1046
|
+
preferPathPrefixes: (config.discovery?.preferPathPrefixes || []).map((path) => expandHomePath(path, runtime.homeDir)),
|
|
1047
|
+
includeHarnessRoots: config.discovery?.includeHarnessRoots !== false
|
|
979
1048
|
},
|
|
980
1049
|
harnesses: {
|
|
981
1050
|
custom: config.harnesses?.custom || []
|
|
@@ -1089,9 +1158,9 @@ function filterHarnesses(harnesses, selectedIds) {
|
|
|
1089
1158
|
}
|
|
1090
1159
|
|
|
1091
1160
|
// src/core/sources.ts
|
|
1092
|
-
import { existsSync as existsSync5, readFileSync as readFileSync3 } from "node:fs";
|
|
1161
|
+
import { existsSync as existsSync5, readdirSync as readdirSync3, readFileSync as readFileSync3, realpathSync as realpathSync3 } from "node:fs";
|
|
1093
1162
|
import { basename as basename2, join as join4, relative as relative2, resolve as resolve4 } from "node:path";
|
|
1094
|
-
function discoverSkillSet(config) {
|
|
1163
|
+
function discoverSkillSet(config, harnesses = []) {
|
|
1095
1164
|
const discovered = [];
|
|
1096
1165
|
const discovery = getDiscoveryConfig(config);
|
|
1097
1166
|
for (const projectsRoot of config.projectsRoots) {
|
|
@@ -1110,6 +1179,9 @@ function discoverSkillSet(config) {
|
|
|
1110
1179
|
}
|
|
1111
1180
|
}
|
|
1112
1181
|
}
|
|
1182
|
+
if (discovery.includeHarnessRoots) {
|
|
1183
|
+
discovered.push(...discoverHarnessSkills(harnesses));
|
|
1184
|
+
}
|
|
1113
1185
|
const filtered = discovered.filter((skill) => !isIgnoredSource(skill.sourcePath, discovery.ignorePathPrefixes));
|
|
1114
1186
|
const deduped = new Map;
|
|
1115
1187
|
for (const skill of filtered) {
|
|
@@ -1119,7 +1191,7 @@ function discoverSkillSet(config) {
|
|
|
1119
1191
|
deduped.set(key, skill);
|
|
1120
1192
|
continue;
|
|
1121
1193
|
}
|
|
1122
|
-
if (existing
|
|
1194
|
+
if (compareEquivalentSourcePreference(skill, existing) < 0) {
|
|
1123
1195
|
deduped.set(key, skill);
|
|
1124
1196
|
}
|
|
1125
1197
|
}
|
|
@@ -1129,27 +1201,39 @@ function discoverSkillSet(config) {
|
|
|
1129
1201
|
sourceDiagnostics
|
|
1130
1202
|
};
|
|
1131
1203
|
}
|
|
1132
|
-
function buildDiscoveredSkill(projectsRoot, repoPath, sourcePath, skillFilePath, sourceType) {
|
|
1133
|
-
const
|
|
1134
|
-
const
|
|
1204
|
+
function buildDiscoveredSkill(projectsRoot, repoPath, sourcePath, skillFilePath, sourceType, harnessId) {
|
|
1205
|
+
const normalizedProjectsRoot = normalizeExistingPath(projectsRoot);
|
|
1206
|
+
const normalizedRepoPath = normalizeExistingPath(repoPath);
|
|
1207
|
+
const normalizedSourcePath = normalizeExistingPath(sourcePath);
|
|
1208
|
+
const normalizedSkillFilePath = normalizeExistingPath(skillFilePath);
|
|
1209
|
+
const skillContent = readFileSync3(normalizedSkillFilePath, "utf8");
|
|
1210
|
+
const frontmatter = parseSkillFrontmatterContent(skillContent);
|
|
1211
|
+
const metadataName = frontmatter.name;
|
|
1212
|
+
const contentHash = hashContent(skillContent);
|
|
1135
1213
|
const fallbackName = sourceType === "repo-root" ? basename2(repoPath) : basename2(sourcePath);
|
|
1136
1214
|
const canonicalSlug = slugify(metadataName || fallbackName);
|
|
1137
|
-
const sourceKey =
|
|
1215
|
+
const sourceKey = normalizedSourcePath;
|
|
1138
1216
|
return {
|
|
1139
1217
|
sourceKey,
|
|
1140
|
-
sourcePath:
|
|
1141
|
-
skillFilePath:
|
|
1142
|
-
repoPath:
|
|
1143
|
-
projectsRoot:
|
|
1218
|
+
sourcePath: normalizedSourcePath,
|
|
1219
|
+
skillFilePath: normalizedSkillFilePath,
|
|
1220
|
+
repoPath: normalizedRepoPath,
|
|
1221
|
+
projectsRoot: normalizedProjectsRoot,
|
|
1144
1222
|
sourceType,
|
|
1223
|
+
harnessId,
|
|
1145
1224
|
metadataName,
|
|
1225
|
+
installHarnessIds: resolveInstallHarnessIds(sourceType, harnessId, frontmatter),
|
|
1146
1226
|
canonicalSlug,
|
|
1147
1227
|
contentHash
|
|
1148
1228
|
};
|
|
1149
1229
|
}
|
|
1150
1230
|
function describeSkill(skill) {
|
|
1231
|
+
const scopeSuffix = describeInstallScope(skill);
|
|
1232
|
+
if (skill.sourceType === "harness-root" && skill.harnessId) {
|
|
1233
|
+
return `${skill.canonicalSlug} <= ${skill.harnessId}:${skill.sourcePath}${scopeSuffix}`;
|
|
1234
|
+
}
|
|
1151
1235
|
const repoRelative = relative2(skill.projectsRoot, skill.sourcePath) || basename2(skill.sourcePath);
|
|
1152
|
-
return `${skill.canonicalSlug} <= ${repoRelative}`;
|
|
1236
|
+
return `${skill.canonicalSlug} <= ${repoRelative}${scopeSuffix}`;
|
|
1153
1237
|
}
|
|
1154
1238
|
function isIgnoredSource(sourcePath, ignorePrefixes) {
|
|
1155
1239
|
return ignorePrefixes.some((prefix) => sourcePath === prefix || sourcePath.startsWith(`${prefix}/`));
|
|
@@ -1165,29 +1249,41 @@ function resolveGlobalDuplicates(skills, preferPrefixes) {
|
|
|
1165
1249
|
const warnings = [];
|
|
1166
1250
|
const errors = [];
|
|
1167
1251
|
for (const group of grouped.values()) {
|
|
1168
|
-
|
|
1169
|
-
|
|
1252
|
+
const uniqueGroup = dedupeEquivalentSources(group);
|
|
1253
|
+
const projectBacked = uniqueGroup.filter((skill) => skill.sourceType !== "harness-root");
|
|
1254
|
+
const preferredGroup = projectBacked.length > 0 ? projectBacked : uniqueGroup;
|
|
1255
|
+
if (preferredGroup.length === 1) {
|
|
1256
|
+
resolved.push(preferredGroup[0]);
|
|
1257
|
+
if (uniqueGroup.length > preferredGroup.length) {
|
|
1258
|
+
warnings.push({
|
|
1259
|
+
slug: uniqueGroup[0].canonicalSlug,
|
|
1260
|
+
severity: "warning",
|
|
1261
|
+
resolution: "resolved-by-preference",
|
|
1262
|
+
chosenSourcePath: preferredGroup[0].sourcePath,
|
|
1263
|
+
sourcePaths: uniqueGroup.map((skill) => skill.sourcePath).sort()
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1170
1266
|
continue;
|
|
1171
1267
|
}
|
|
1172
|
-
const distinctHashes = new Set(
|
|
1268
|
+
const distinctHashes = new Set(preferredGroup.map((skill) => skill.contentHash));
|
|
1173
1269
|
if (distinctHashes.size !== 1) {
|
|
1174
|
-
resolved.push(...
|
|
1270
|
+
resolved.push(...preferredGroup);
|
|
1175
1271
|
errors.push({
|
|
1176
|
-
slug:
|
|
1272
|
+
slug: preferredGroup[0].canonicalSlug,
|
|
1177
1273
|
severity: "error",
|
|
1178
1274
|
resolution: "unresolved",
|
|
1179
|
-
sourcePaths:
|
|
1275
|
+
sourcePaths: preferredGroup.map((skill) => skill.sourcePath).sort()
|
|
1180
1276
|
});
|
|
1181
1277
|
continue;
|
|
1182
1278
|
}
|
|
1183
|
-
const sorted = [...
|
|
1279
|
+
const sorted = [...preferredGroup].sort((a, b) => compareDiscoveredSkills(a, b, preferPrefixes));
|
|
1184
1280
|
resolved.push(sorted[0]);
|
|
1185
1281
|
warnings.push({
|
|
1186
|
-
slug:
|
|
1282
|
+
slug: preferredGroup[0].canonicalSlug,
|
|
1187
1283
|
severity: "warning",
|
|
1188
1284
|
resolution: "resolved-by-preference",
|
|
1189
1285
|
chosenSourcePath: sorted[0].sourcePath,
|
|
1190
|
-
sourcePaths:
|
|
1286
|
+
sourcePaths: uniqueGroup.map((skill) => skill.sourcePath).sort()
|
|
1191
1287
|
});
|
|
1192
1288
|
}
|
|
1193
1289
|
return {
|
|
@@ -1201,10 +1297,15 @@ function resolveGlobalDuplicates(skills, preferPrefixes) {
|
|
|
1201
1297
|
function getDiscoveryConfig(config) {
|
|
1202
1298
|
return {
|
|
1203
1299
|
ignorePathPrefixes: config.discovery?.ignorePathPrefixes ?? [],
|
|
1204
|
-
preferPathPrefixes: config.discovery?.preferPathPrefixes ?? []
|
|
1300
|
+
preferPathPrefixes: config.discovery?.preferPathPrefixes ?? [],
|
|
1301
|
+
includeHarnessRoots: config.discovery?.includeHarnessRoots !== false
|
|
1205
1302
|
};
|
|
1206
1303
|
}
|
|
1207
1304
|
function compareDiscoveredSkills(a, b, preferPrefixes) {
|
|
1305
|
+
const typePriority = compareSourceTypePriority(a, b);
|
|
1306
|
+
if (typePriority !== 0) {
|
|
1307
|
+
return typePriority;
|
|
1308
|
+
}
|
|
1208
1309
|
const rankA = getPreferenceRank(a.sourcePath, preferPrefixes);
|
|
1209
1310
|
const rankB = getPreferenceRank(b.sourcePath, preferPrefixes);
|
|
1210
1311
|
if (rankA !== rankB) {
|
|
@@ -1212,6 +1313,18 @@ function compareDiscoveredSkills(a, b, preferPrefixes) {
|
|
|
1212
1313
|
}
|
|
1213
1314
|
return a.sourcePath.localeCompare(b.sourcePath);
|
|
1214
1315
|
}
|
|
1316
|
+
function compareSourceTypePriority(a, b) {
|
|
1317
|
+
return getSourceTypePriority(a.sourceType) - getSourceTypePriority(b.sourceType);
|
|
1318
|
+
}
|
|
1319
|
+
function getSourceTypePriority(sourceType) {
|
|
1320
|
+
if (sourceType === "repo-root") {
|
|
1321
|
+
return 0;
|
|
1322
|
+
}
|
|
1323
|
+
if (sourceType === "nested") {
|
|
1324
|
+
return 1;
|
|
1325
|
+
}
|
|
1326
|
+
return 2;
|
|
1327
|
+
}
|
|
1215
1328
|
function getPreferenceRank(sourcePath, preferPrefixes) {
|
|
1216
1329
|
const matchIndex = preferPrefixes.findIndex((prefix) => sourcePath === prefix || sourcePath.startsWith(`${prefix}/`));
|
|
1217
1330
|
return matchIndex === -1 ? Number.MAX_SAFE_INTEGER : matchIndex;
|
|
@@ -1221,10 +1334,124 @@ function compareDiagnostics(a, b) {
|
|
|
1221
1334
|
`).localeCompare(b.sourcePaths.join(`
|
|
1222
1335
|
`));
|
|
1223
1336
|
}
|
|
1337
|
+
function discoverHarnessSkills(harnesses) {
|
|
1338
|
+
const discovered = [];
|
|
1339
|
+
for (const harness of harnesses) {
|
|
1340
|
+
if (!harness.detected) {
|
|
1341
|
+
continue;
|
|
1342
|
+
}
|
|
1343
|
+
let children = [];
|
|
1344
|
+
try {
|
|
1345
|
+
children = readdirSync3(harness.rootPath);
|
|
1346
|
+
} catch {
|
|
1347
|
+
continue;
|
|
1348
|
+
}
|
|
1349
|
+
for (const child of children) {
|
|
1350
|
+
if (shouldIgnoreHarnessSkillName(child)) {
|
|
1351
|
+
continue;
|
|
1352
|
+
}
|
|
1353
|
+
const entryPath = join4(harness.rootPath, child);
|
|
1354
|
+
const resolved = resolveHarnessSkillSource(entryPath);
|
|
1355
|
+
if (!resolved) {
|
|
1356
|
+
continue;
|
|
1357
|
+
}
|
|
1358
|
+
const ownerHarnessId = resolveSourceHarnessId(resolved.sourcePath, harnesses) || harness.id;
|
|
1359
|
+
const ownerHarnessRoot = harnesses.find((candidate) => candidate.id === ownerHarnessId)?.rootPath || harness.rootPath;
|
|
1360
|
+
discovered.push(buildDiscoveredSkill(ownerHarnessRoot, resolved.sourcePath, resolved.sourcePath, resolved.skillFilePath, "harness-root", ownerHarnessId));
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
return discovered;
|
|
1364
|
+
}
|
|
1365
|
+
function resolveHarnessSkillSource(entryPath) {
|
|
1366
|
+
const inspection = inspectEntry(entryPath);
|
|
1367
|
+
if (!inspection.exists) {
|
|
1368
|
+
return null;
|
|
1369
|
+
}
|
|
1370
|
+
if (inspection.type === "directory") {
|
|
1371
|
+
const skillFilePath = join4(entryPath, "SKILL.md");
|
|
1372
|
+
if (!existsSync5(skillFilePath)) {
|
|
1373
|
+
return null;
|
|
1374
|
+
}
|
|
1375
|
+
return {
|
|
1376
|
+
sourcePath: resolve4(entryPath),
|
|
1377
|
+
skillFilePath: resolve4(skillFilePath)
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
if (inspection.type === "symlink" && inspection.resolvedTarget) {
|
|
1381
|
+
const skillFilePath = join4(inspection.resolvedTarget, "SKILL.md");
|
|
1382
|
+
if (!existsSync5(skillFilePath)) {
|
|
1383
|
+
return null;
|
|
1384
|
+
}
|
|
1385
|
+
return {
|
|
1386
|
+
sourcePath: resolve4(inspection.resolvedTarget),
|
|
1387
|
+
skillFilePath: resolve4(skillFilePath)
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
return null;
|
|
1391
|
+
}
|
|
1392
|
+
function shouldIgnoreHarnessSkillName(name) {
|
|
1393
|
+
return name.startsWith(".") || name.includes(".backup-");
|
|
1394
|
+
}
|
|
1395
|
+
function normalizeExistingPath(path) {
|
|
1396
|
+
try {
|
|
1397
|
+
return realpathSync3(path);
|
|
1398
|
+
} catch {
|
|
1399
|
+
return resolve4(path);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
function dedupeEquivalentSources(skills) {
|
|
1403
|
+
const unique = new Map;
|
|
1404
|
+
for (const skill of skills) {
|
|
1405
|
+
const key = `${skill.canonicalSlug}::${skill.sourcePath}`;
|
|
1406
|
+
const existing = unique.get(key);
|
|
1407
|
+
if (!existing || compareEquivalentSourcePreference(skill, existing) < 0) {
|
|
1408
|
+
unique.set(key, skill);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
return [...unique.values()];
|
|
1412
|
+
}
|
|
1413
|
+
function resolveInstallHarnessIds(sourceType, harnessId, frontmatter) {
|
|
1414
|
+
if (frontmatter.skillSyncInstallOn && frontmatter.skillSyncInstallOn.length > 0) {
|
|
1415
|
+
return frontmatter.skillSyncInstallOn;
|
|
1416
|
+
}
|
|
1417
|
+
if (sourceType === "harness-root" && harnessId && frontmatter.skillSyncScope === "local-only") {
|
|
1418
|
+
return [harnessId];
|
|
1419
|
+
}
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
function describeInstallScope(skill) {
|
|
1423
|
+
if (!skill.installHarnessIds || skill.installHarnessIds.length === 0) {
|
|
1424
|
+
return "";
|
|
1425
|
+
}
|
|
1426
|
+
if (skill.sourceType === "harness-root" && skill.harnessId && skill.installHarnessIds.length === 1 && skill.installHarnessIds[0] === skill.harnessId) {
|
|
1427
|
+
return ` [local-only: ${skill.harnessId}]`;
|
|
1428
|
+
}
|
|
1429
|
+
return ` [install-on: ${skill.installHarnessIds.join(", ")}]`;
|
|
1430
|
+
}
|
|
1431
|
+
function resolveSourceHarnessId(sourcePath, harnesses) {
|
|
1432
|
+
return harnesses.filter((harness) => pathOwnsEntry(harness.rootPath, sourcePath)).sort((a, b) => b.rootPath.length - a.rootPath.length || a.id.localeCompare(b.id))[0]?.id;
|
|
1433
|
+
}
|
|
1434
|
+
function compareEquivalentSourcePreference(a, b) {
|
|
1435
|
+
const sourceTypePriority = compareSourceTypePriority(a, b);
|
|
1436
|
+
if (sourceTypePriority !== 0) {
|
|
1437
|
+
return sourceTypePriority;
|
|
1438
|
+
}
|
|
1439
|
+
const ownerPriorityA = a.harnessId ? 0 : 1;
|
|
1440
|
+
const ownerPriorityB = b.harnessId ? 0 : 1;
|
|
1441
|
+
if (ownerPriorityA !== ownerPriorityB) {
|
|
1442
|
+
return ownerPriorityA - ownerPriorityB;
|
|
1443
|
+
}
|
|
1444
|
+
const scopePriorityA = a.installHarnessIds ? 0 : 1;
|
|
1445
|
+
const scopePriorityB = b.installHarnessIds ? 0 : 1;
|
|
1446
|
+
if (scopePriorityA !== scopePriorityB) {
|
|
1447
|
+
return scopePriorityA - scopePriorityB;
|
|
1448
|
+
}
|
|
1449
|
+
return a.sourcePath.localeCompare(b.sourcePath);
|
|
1450
|
+
}
|
|
1224
1451
|
|
|
1225
1452
|
// src/core/sync.ts
|
|
1226
1453
|
import { join as join5, resolve as resolve5 } from "node:path";
|
|
1227
|
-
import { existsSync as existsSync6, readdirSync as
|
|
1454
|
+
import { existsSync as existsSync6, readdirSync as readdirSync4, readFileSync as readFileSync4, realpathSync as realpathSync4, symlinkSync as symlinkSync2 } from "node:fs";
|
|
1228
1455
|
function buildSyncPlan(skills, harnesses, config, state, sourceDiagnostics) {
|
|
1229
1456
|
const harnessPlans = harnesses.map((harness) => ({
|
|
1230
1457
|
harness,
|
|
@@ -1238,6 +1465,9 @@ function buildSyncPlan(skills, harnesses, config, state, sourceDiagnostics) {
|
|
|
1238
1465
|
desiredByHarness.set(harnessPlan.harness.id, new Set);
|
|
1239
1466
|
const pathClaims = new Map;
|
|
1240
1467
|
for (const skill of skills) {
|
|
1468
|
+
if (!shouldInstallOnHarness(skill, harnessPlan.harness.id)) {
|
|
1469
|
+
continue;
|
|
1470
|
+
}
|
|
1241
1471
|
const installName = resolveInstallName(skill, harnessPlan.harness.id, config);
|
|
1242
1472
|
const destinationPath = join5(harnessPlan.harness.rootPath, installName);
|
|
1243
1473
|
const existingClaim = pathClaims.get(destinationPath);
|
|
@@ -1293,7 +1523,7 @@ function buildSyncPlan(skills, harnesses, config, state, sourceDiagnostics) {
|
|
|
1293
1523
|
const desiredSet = desiredByHarness.get(harnessPlan.harness.id) || new Set;
|
|
1294
1524
|
let children = [];
|
|
1295
1525
|
try {
|
|
1296
|
-
children =
|
|
1526
|
+
children = readdirSync4(harnessPlan.harness.rootPath);
|
|
1297
1527
|
} catch {
|
|
1298
1528
|
continue;
|
|
1299
1529
|
}
|
|
@@ -1339,7 +1569,7 @@ function buildSyncPlan(skills, harnesses, config, state, sourceDiagnostics) {
|
|
|
1339
1569
|
function buildPlannedEntry(skill, harness, installName, destinationPath, state, config) {
|
|
1340
1570
|
const inspection = inspectEntry(destinationPath);
|
|
1341
1571
|
const stateEntry = state.managedEntries[destinationPath];
|
|
1342
|
-
const sameSource = inspection.type === "symlink" && inspection.resolvedTarget === resolve5(skill.sourcePath);
|
|
1572
|
+
const sameSource = normalizeComparablePath2(destinationPath) === normalizeComparablePath2(skill.sourcePath) || inspection.type === "symlink" && inspection.resolvedTarget === resolve5(skill.sourcePath);
|
|
1343
1573
|
const compatibility = inspectCompatibility(destinationPath, skill);
|
|
1344
1574
|
if (!inspection.exists) {
|
|
1345
1575
|
return makePlannedEntry(skill, harness, installName, destinationPath, "create", "missing entry will be created");
|
|
@@ -1348,7 +1578,7 @@ function buildPlannedEntry(skill, harness, installName, destinationPath, state,
|
|
|
1348
1578
|
return makePlannedEntry(skill, harness, installName, destinationPath, "ok", "already synced");
|
|
1349
1579
|
}
|
|
1350
1580
|
if (compatibility === "matching-skill") {
|
|
1351
|
-
return makePlannedEntry(skill, harness, installName, destinationPath, "
|
|
1581
|
+
return makePlannedEntry(skill, harness, installName, destinationPath, "repair", "matching install will be replaced with a symlink to the authoritative source");
|
|
1352
1582
|
}
|
|
1353
1583
|
if (stateEntry) {
|
|
1354
1584
|
return makePlannedEntry(skill, harness, installName, destinationPath, "repair", "managed entry drift will be repaired");
|
|
@@ -1365,7 +1595,7 @@ function inspectCompatibility(destinationPath, skill) {
|
|
|
1365
1595
|
}
|
|
1366
1596
|
const sourceSkillText = readFileSync4(skill.skillFilePath, "utf8");
|
|
1367
1597
|
if (inspection.type === "directory") {
|
|
1368
|
-
if (
|
|
1598
|
+
if (readdirSync4(destinationPath).length === 0) {
|
|
1369
1599
|
return "empty-directory";
|
|
1370
1600
|
}
|
|
1371
1601
|
const installedSkillPath = join5(destinationPath, "SKILL.md");
|
|
@@ -1385,6 +1615,13 @@ function inspectCompatibility(destinationPath, skill) {
|
|
|
1385
1615
|
}
|
|
1386
1616
|
return "none";
|
|
1387
1617
|
}
|
|
1618
|
+
function normalizeComparablePath2(path) {
|
|
1619
|
+
try {
|
|
1620
|
+
return realpathSync4(path);
|
|
1621
|
+
} catch {
|
|
1622
|
+
return resolve5(path);
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1388
1625
|
function makePlannedEntry(skill, harness, installName, destinationPath, action, message) {
|
|
1389
1626
|
return {
|
|
1390
1627
|
harnessId: harness.id,
|
|
@@ -1397,6 +1634,12 @@ function makePlannedEntry(skill, harness, installName, destinationPath, action,
|
|
|
1397
1634
|
message
|
|
1398
1635
|
};
|
|
1399
1636
|
}
|
|
1637
|
+
function shouldInstallOnHarness(skill, harnessId) {
|
|
1638
|
+
if (!skill.installHarnessIds || skill.installHarnessIds.length === 0) {
|
|
1639
|
+
return true;
|
|
1640
|
+
}
|
|
1641
|
+
return skill.installHarnessIds.includes(harnessId);
|
|
1642
|
+
}
|
|
1400
1643
|
function resolveInstallName(skill, harnessId, config) {
|
|
1401
1644
|
const override = config.aliases[skill.sourceKey];
|
|
1402
1645
|
if (override?.harnesses?.[harnessId]) {
|
|
@@ -1460,7 +1703,7 @@ function hasDrift(plan) {
|
|
|
1460
1703
|
|
|
1461
1704
|
// src/index.ts
|
|
1462
1705
|
var cli = cac("skill-sync");
|
|
1463
|
-
var version = "0.1.
|
|
1706
|
+
var version = "0.1.3";
|
|
1464
1707
|
function normalizeList(value) {
|
|
1465
1708
|
if (!value) {
|
|
1466
1709
|
return [];
|
|
@@ -1487,10 +1730,34 @@ function print(value, json) {
|
|
|
1487
1730
|
}
|
|
1488
1731
|
console.log(value);
|
|
1489
1732
|
}
|
|
1490
|
-
function
|
|
1733
|
+
function renderLandingHelp() {
|
|
1734
|
+
return [
|
|
1735
|
+
"skill-sync",
|
|
1736
|
+
"",
|
|
1737
|
+
"High-signal commands:",
|
|
1738
|
+
" skill-sync doctor Inspect sources, drift, and orphan installs",
|
|
1739
|
+
" skill-sync doctor --verbose Show the full per-entry plan",
|
|
1740
|
+
" skill-sync execute Apply symlink updates",
|
|
1741
|
+
" skill-sync sync Alias for execute",
|
|
1742
|
+
" skill-sync sources List discovered source skills",
|
|
1743
|
+
" skill-sync harnesses List detected harness roots",
|
|
1744
|
+
"",
|
|
1745
|
+
"Short alias:",
|
|
1746
|
+
" ss doctor",
|
|
1747
|
+
" ss execute",
|
|
1748
|
+
"",
|
|
1749
|
+
"Safety:",
|
|
1750
|
+
" skill-sync backup create",
|
|
1751
|
+
" skill-sync backup list",
|
|
1752
|
+
"",
|
|
1753
|
+
"Use --help for the full command reference."
|
|
1754
|
+
].join(`
|
|
1755
|
+
`);
|
|
1756
|
+
}
|
|
1757
|
+
function renderDetailedPlan(plan, options) {
|
|
1491
1758
|
const lines = [];
|
|
1492
1759
|
appendSourceDiagnostics(lines, plan.sourceDiagnostics);
|
|
1493
|
-
if (plan.orphanSkills && plan.orphanSkills.length > 0) {
|
|
1760
|
+
if (options?.includeOrphans !== false && plan.orphanSkills && plan.orphanSkills.length > 0) {
|
|
1494
1761
|
if (lines.length > 0) {
|
|
1495
1762
|
lines.push("");
|
|
1496
1763
|
}
|
|
@@ -1522,6 +1789,98 @@ function renderPlan(plan) {
|
|
|
1522
1789
|
return lines.join(`
|
|
1523
1790
|
`);
|
|
1524
1791
|
}
|
|
1792
|
+
function renderPlan(plan, options) {
|
|
1793
|
+
if (options.verbose || hasConflicts(plan)) {
|
|
1794
|
+
return renderDetailedPlan(plan, options);
|
|
1795
|
+
}
|
|
1796
|
+
const lines = [];
|
|
1797
|
+
appendSourceDiagnostics(lines, plan.sourceDiagnostics);
|
|
1798
|
+
if (options.includeOrphans !== false && plan.orphanSkills && plan.orphanSkills.length > 0) {
|
|
1799
|
+
if (lines.length > 0) {
|
|
1800
|
+
lines.push("");
|
|
1801
|
+
}
|
|
1802
|
+
const harnessCount = new Set(plan.orphanSkills.map((orphan) => orphan.harnessId)).size;
|
|
1803
|
+
lines.push(`Orphan installed skills: ${plan.orphanSkills.length} detected across ${harnessCount} harness(es)`);
|
|
1804
|
+
lines.push("Run `skill-sync doctor --verbose` to inspect orphan entries.");
|
|
1805
|
+
}
|
|
1806
|
+
const counts = countPlanActions(plan);
|
|
1807
|
+
if (lines.length > 0) {
|
|
1808
|
+
lines.push("");
|
|
1809
|
+
}
|
|
1810
|
+
lines.push(`Summary: ${plan.ok} ok, ${plan.changes} change(s), ${plan.conflicts} conflict(s)`);
|
|
1811
|
+
lines.push(`Actions: ${Object.entries(counts).map(([action, count]) => `${action}=${count}`).join(", ")}`);
|
|
1812
|
+
const harnessLines = summarizeHarnessPlans(plan);
|
|
1813
|
+
if (harnessLines.length > 0) {
|
|
1814
|
+
lines.push("");
|
|
1815
|
+
lines.push("Harness changes:");
|
|
1816
|
+
lines.push(...harnessLines);
|
|
1817
|
+
}
|
|
1818
|
+
return lines.join(`
|
|
1819
|
+
`);
|
|
1820
|
+
}
|
|
1821
|
+
function summarizeHarnessPlans(plan) {
|
|
1822
|
+
const lines = [];
|
|
1823
|
+
for (const harnessPlan of plan.harnesses) {
|
|
1824
|
+
const interestingEntries = harnessPlan.entries.filter((entry) => entry.action !== "ok");
|
|
1825
|
+
if (interestingEntries.length === 0) {
|
|
1826
|
+
continue;
|
|
1827
|
+
}
|
|
1828
|
+
const counts = {};
|
|
1829
|
+
for (const entry of interestingEntries) {
|
|
1830
|
+
counts[entry.action] = (counts[entry.action] || 0) + 1;
|
|
1831
|
+
}
|
|
1832
|
+
lines.push(`- ${harnessPlan.harness.id}: ${Object.entries(counts).map(([action, count]) => `${action}=${count}`).join(", ")}`);
|
|
1833
|
+
}
|
|
1834
|
+
return lines;
|
|
1835
|
+
}
|
|
1836
|
+
function renderDoctorReport(plan, state, skills, harnessCount, verbose) {
|
|
1837
|
+
if (verbose || hasConflicts(plan)) {
|
|
1838
|
+
return renderDetailedPlan(plan, { includeOrphans: true });
|
|
1839
|
+
}
|
|
1840
|
+
const totalExpectedInstalls = plan.harnesses.flatMap((harness) => harness.entries).filter((entry) => entry.action !== "remove-managed" && entry.action !== "prune-state").length;
|
|
1841
|
+
const trackedExpectedInstalls = plan.harnesses.flatMap((harness) => harness.entries).filter((entry) => entry.action !== "conflict" && Boolean(state.managedEntries[entry.destinationPath])).length;
|
|
1842
|
+
const okButUntracked = plan.harnesses.flatMap((harness) => harness.entries).filter((entry) => entry.action === "ok" && !state.managedEntries[entry.destinationPath]).length;
|
|
1843
|
+
const compatibleCopies = plan.harnesses.flatMap((harness) => harness.entries).filter((entry) => entry.message === "matching install will be replaced with a symlink to the authoritative source").length;
|
|
1844
|
+
const lines = [];
|
|
1845
|
+
appendSourceDiagnostics(lines, plan.sourceDiagnostics);
|
|
1846
|
+
if (lines.length > 0) {
|
|
1847
|
+
lines.push("");
|
|
1848
|
+
}
|
|
1849
|
+
lines.push("Doctor");
|
|
1850
|
+
lines.push(`Sources: ${skills.length} discovered skill source(s)`);
|
|
1851
|
+
const scopedSources = skills.filter((skill) => skill.installHarnessIds && skill.installHarnessIds.length > 0).length;
|
|
1852
|
+
if (scopedSources > 0) {
|
|
1853
|
+
lines.push(`Scope: ${skills.length - scopedSources} global, ${scopedSources} scoped`);
|
|
1854
|
+
}
|
|
1855
|
+
lines.push(`Harnesses: ${harnessCount} detected/enabled root(s)`);
|
|
1856
|
+
lines.push(`Expected installs: ${totalExpectedInstalls}`);
|
|
1857
|
+
lines.push(`State: ${trackedExpectedInstalls} tracked, ${okButUntracked} ok-but-untracked`);
|
|
1858
|
+
lines.push(`Sync: ${plan.changes} change(s), ${plan.conflicts} conflict(s), ${plan.ok} ok`);
|
|
1859
|
+
if (compatibleCopies > 0) {
|
|
1860
|
+
lines.push(`Copies: ${compatibleCopies} matching install(s) still need conversion from copied content to authoritative symlinks`);
|
|
1861
|
+
}
|
|
1862
|
+
if (plan.orphanSkills && plan.orphanSkills.length > 0) {
|
|
1863
|
+
const groupedOrphans = new Map;
|
|
1864
|
+
for (const orphan of plan.orphanSkills) {
|
|
1865
|
+
groupedOrphans.set(orphan.harnessId, (groupedOrphans.get(orphan.harnessId) || 0) + 1);
|
|
1866
|
+
}
|
|
1867
|
+
const topHarnesses = [...groupedOrphans.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 5).map(([harnessId, count]) => `${harnessId}=${count}`).join(", ");
|
|
1868
|
+
lines.push(`Orphans: ${plan.orphanSkills.length} installed skill(s) exist outside the discovered source set`);
|
|
1869
|
+
lines.push(`Top orphan roots: ${topHarnesses}`);
|
|
1870
|
+
lines.push("Diagnosis: project-root skills are syncing correctly. The remaining orphans are typically slug mismatches, backup artifacts, or installed entries that do not yet map to a canonical source.");
|
|
1871
|
+
lines.push("Run `skill-sync doctor --verbose` to inspect orphan entries.");
|
|
1872
|
+
} else {
|
|
1873
|
+
lines.push("Orphans: 0");
|
|
1874
|
+
}
|
|
1875
|
+
const harnessLines = summarizeHarnessPlans(plan);
|
|
1876
|
+
if (harnessLines.length > 0) {
|
|
1877
|
+
lines.push("");
|
|
1878
|
+
lines.push("Harness changes:");
|
|
1879
|
+
lines.push(...harnessLines);
|
|
1880
|
+
}
|
|
1881
|
+
return lines.join(`
|
|
1882
|
+
`);
|
|
1883
|
+
}
|
|
1525
1884
|
function appendSourceDiagnostics(lines, sourceDiagnostics) {
|
|
1526
1885
|
if (sourceDiagnostics.errors.length === 0 && sourceDiagnostics.warnings.length === 0) {
|
|
1527
1886
|
return;
|
|
@@ -1557,39 +1916,58 @@ function planSync(options) {
|
|
|
1557
1916
|
return withRuntime(options, (runtime) => {
|
|
1558
1917
|
const config = loadConfig(runtime);
|
|
1559
1918
|
config.projectsRoots = resolveProjectsOverride(config.projectsRoots, options);
|
|
1560
|
-
const
|
|
1561
|
-
const
|
|
1919
|
+
const allHarnesses = resolveHarnesses(runtime.homeDir, config).filter((harness) => harness.enabled);
|
|
1920
|
+
const harnesses = resolveSelectedHarnesses(allHarnesses, options);
|
|
1921
|
+
const { skills, sourceDiagnostics } = discoverSkillSet(config, allHarnesses);
|
|
1562
1922
|
const state = loadState(runtime);
|
|
1563
1923
|
const plan = buildSyncPlan(skills, harnesses, config, state, sourceDiagnostics);
|
|
1564
|
-
return { runtime, plan, harnesses,
|
|
1924
|
+
return { runtime, plan, harnesses, skills, state };
|
|
1565
1925
|
});
|
|
1566
1926
|
}
|
|
1567
|
-
function
|
|
1568
|
-
print(json ?
|
|
1927
|
+
function printDoctorResult(plan, options, state, skills, harnessCount) {
|
|
1928
|
+
print(options.json ? {
|
|
1929
|
+
...plan,
|
|
1930
|
+
summary: {
|
|
1931
|
+
sourcesDiscovered: skills.length,
|
|
1932
|
+
scopedSources: skills.filter((skill) => skill.installHarnessIds && skill.installHarnessIds.length > 0).length,
|
|
1933
|
+
harnessesDetected: harnessCount,
|
|
1934
|
+
expectedInstalls: plan.harnesses.flatMap((harness) => harness.entries).filter((entry) => entry.action !== "remove-managed" && entry.action !== "prune-state").length,
|
|
1935
|
+
changes: plan.changes,
|
|
1936
|
+
conflicts: plan.conflicts,
|
|
1937
|
+
ok: plan.ok,
|
|
1938
|
+
orphans: plan.orphanSkills?.length || 0
|
|
1939
|
+
}
|
|
1940
|
+
} : renderDoctorReport(plan, state, skills, harnessCount, options.verbose), Boolean(options.json));
|
|
1569
1941
|
process.exit(hasConflicts(plan) ? 3 : hasDrift(plan) ? 2 : 0);
|
|
1570
1942
|
}
|
|
1571
|
-
cli.command("
|
|
1572
|
-
const { plan } = planSync(options);
|
|
1573
|
-
|
|
1943
|
+
cli.command("doctor", "Inspect current sources, drift, and orphan installs").option("--json", "Output JSON").option("--dry-run", "Accepted for parity; check is always read-only").option("--verbose", "Show detailed plan output").option("--projects-root <path>", "Override configured projects root").option("--harness <id>", "Filter to one or more harness ids").option("--home <path>", "Override HOME for skill-sync state and harness resolution").action((options) => {
|
|
1944
|
+
const { plan, state, skills, harnesses } = planSync(options);
|
|
1945
|
+
printDoctorResult(plan, options, state, skills, harnesses.length);
|
|
1946
|
+
});
|
|
1947
|
+
cli.command("check", "Alias for doctor").option("--json", "Output JSON").option("--dry-run", "Accepted for parity; check is always read-only").option("--verbose", "Show detailed plan output").option("--projects-root <path>", "Override configured projects root").option("--harness <id>", "Filter to one or more harness ids").option("--home <path>", "Override HOME for skill-sync state and harness resolution").action((options) => {
|
|
1948
|
+
const { plan, state, skills, harnesses } = planSync(options);
|
|
1949
|
+
printDoctorResult(plan, options, state, skills, harnesses.length);
|
|
1574
1950
|
});
|
|
1575
|
-
|
|
1576
|
-
const { runtime, plan } = planSync(options);
|
|
1951
|
+
function runExecute(options) {
|
|
1952
|
+
const { runtime, plan, state } = planSync(options);
|
|
1577
1953
|
if (hasConflicts(plan)) {
|
|
1578
|
-
print(options.json ? plan : renderPlan(plan), Boolean(options.json));
|
|
1954
|
+
print(options.json ? plan : renderPlan(plan, { verbose: true, includeOrphans: true }), Boolean(options.json));
|
|
1579
1955
|
process.exit(3);
|
|
1580
1956
|
}
|
|
1581
|
-
const state = loadState(runtime);
|
|
1582
1957
|
const nextState = applySyncPlan(plan, state, Boolean(options.dryRun));
|
|
1583
1958
|
if (!options.dryRun) {
|
|
1584
1959
|
saveState(runtime, nextState);
|
|
1585
1960
|
}
|
|
1586
|
-
print(options.json ? plan : renderPlan(plan), Boolean(options.json));
|
|
1587
|
-
}
|
|
1961
|
+
print(options.json ? plan : renderPlan(plan, { verbose: options.verbose, includeOrphans: false }), Boolean(options.json));
|
|
1962
|
+
}
|
|
1963
|
+
cli.command("execute", "Apply the desired symlink state").option("--json", "Output JSON").option("--dry-run", "Show changes without mutating").option("--verbose", "Show detailed plan output").option("--projects-root <path>", "Override configured projects root").option("--harness <id>", "Filter to one or more harness ids").option("--home <path>", "Override HOME for skill-sync state and harness resolution").action(runExecute);
|
|
1964
|
+
cli.command("sync", "Alias for execute").option("--json", "Output JSON").option("--dry-run", "Show changes without mutating").option("--verbose", "Show detailed plan output").option("--projects-root <path>", "Override configured projects root").option("--harness <id>", "Filter to one or more harness ids").option("--home <path>", "Override HOME for skill-sync state and harness resolution").action(runExecute);
|
|
1588
1965
|
cli.command("sources", "List discovered source skills").option("--json", "Output JSON").option("--projects-root <path>", "Override configured projects root").option("--home <path>", "Override HOME for skill-sync state and harness resolution").action((options) => {
|
|
1589
1966
|
withRuntime(options, (runtime) => {
|
|
1590
1967
|
const config = loadConfig(runtime);
|
|
1591
1968
|
config.projectsRoots = resolveProjectsOverride(config.projectsRoots, options);
|
|
1592
|
-
const
|
|
1969
|
+
const harnesses = resolveHarnesses(runtime.homeDir, config).filter((harness) => harness.enabled);
|
|
1970
|
+
const { skills, sourceDiagnostics } = discoverSkillSet(config, harnesses);
|
|
1593
1971
|
if (options.json) {
|
|
1594
1972
|
print({ skills, sourceDiagnostics }, true);
|
|
1595
1973
|
return;
|
|
@@ -1742,22 +2120,16 @@ cli.help();
|
|
|
1742
2120
|
cli.version(version);
|
|
1743
2121
|
cli.option("--json", "Output JSON");
|
|
1744
2122
|
cli.option("--dry-run", "Show changes without mutating");
|
|
2123
|
+
cli.option("--verbose", "Show detailed plan output");
|
|
1745
2124
|
cli.option("--projects-root <path>", "Override configured projects root");
|
|
1746
2125
|
cli.option("--harness <id>", "Filter to one or more harness ids");
|
|
1747
2126
|
cli.option("--home <path>", "Override HOME for skill-sync state and harness resolution");
|
|
1748
2127
|
var rawArgs = process.argv.slice(2);
|
|
1749
2128
|
cli.parse();
|
|
1750
|
-
var shouldRunDefaultSync = !rawArgs.includes("--help") && !rawArgs.includes("-h") && !rawArgs.includes("--version") && !rawArgs.includes("-v") && !cli.matchedCommand;
|
|
2129
|
+
var shouldRunDefaultSync = rawArgs.length > 0 && !rawArgs.includes("--help") && !rawArgs.includes("-h") && !rawArgs.includes("--version") && !rawArgs.includes("-v") && !cli.matchedCommand;
|
|
1751
2130
|
if (shouldRunDefaultSync) {
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
process.exit(3);
|
|
1757
|
-
}
|
|
1758
|
-
const nextState = applySyncPlan(plan, loadState(runtime), Boolean(options.dryRun));
|
|
1759
|
-
if (!options.dryRun) {
|
|
1760
|
-
saveState(runtime, nextState);
|
|
1761
|
-
}
|
|
1762
|
-
console.log(renderPlan(plan));
|
|
2131
|
+
print(renderLandingHelp(), false);
|
|
2132
|
+
}
|
|
2133
|
+
if (rawArgs.length === 0) {
|
|
2134
|
+
print(renderLandingHelp(), false);
|
|
1763
2135
|
}
|
package/package.json
CHANGED