@daldindev/agentic-skills 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +18 -2
- package/package.json +1 -1
- package/src/cli.mjs +155 -10
- package/src/diff.mjs +219 -0
- package/src/install.mjs +147 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,19 @@ The format is based on Keep a Changelog and this project follows Semantic Versio
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.0] - 2026-09-05
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `update` and `sync` now keep both changes when a local edit and an upstream change never wrote in the same place, instead of freezing the file for either. A file is only ever combined when the version it was installed from can be read and hashes to exactly what the manifest recorded, and only when no two changes touch; otherwise it stays frozen, exactly as before. Nothing local is ever dropped to make a merge possible, and no file is left half-combined.
|
|
14
|
+
- `--no-merge` turns that off, leaving every edited file frozen the way earlier versions did.
|
|
15
|
+
- `agentic-skills diff`: for every file a local edit froze, compares three versions - the commit you installed from, your copy, and current upstream - so every difference has a known author. Without a path it lists the frozen files and says whether each is blocked by a real overlap or only by the per-file rule; with a path it prints all three versions of every changed region. Nothing is written, and nothing is merged.
|
|
16
|
+
- `--base-archive` supplies the version you installed from as a local tarball or URL, instead of downloading the commit recorded in the manifest. Used by `update`, `sync`, and `diff`.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- The manifest no longer advances the recorded hash of a file that was skipped. Nothing was written to that file, so the version it descends from has not changed; recording the newer one made it look as though the user had deleted an upstream change they had merely not taken yet.
|
|
21
|
+
|
|
9
22
|
## [0.2.0] - 2026-09-05
|
|
10
23
|
|
|
11
24
|
### Added
|
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ npx agentic-skills init
|
|
|
31
31
|
Requirements:
|
|
32
32
|
|
|
33
33
|
- Node.js `>=20.11`
|
|
34
|
-
- Network access to `codeload.github.com`, where GitHub serves source archives, when you run `init`, `update`, or `
|
|
34
|
+
- Network access to `codeload.github.com`, where GitHub serves source archives, when you run `init`, `update`, `sync`, or `diff`
|
|
35
35
|
|
|
36
36
|
## Quick Start
|
|
37
37
|
|
|
@@ -57,6 +57,7 @@ Point your assistant at `.agents/ARCHITECTURE.md`. It lists every component with
|
|
|
57
57
|
| `agentic-skills update` | Download ag-kit again and update installed files, keeping the ones you edited |
|
|
58
58
|
| `agentic-skills sync` | Install if missing, update if present. One command that works in both states, for unattended runs |
|
|
59
59
|
| `agentic-skills status` | Show what is installed, from which upstream commit, and which files were changed locally |
|
|
60
|
+
| `agentic-skills diff` | Show why your edits froze a file, and what they are holding back |
|
|
60
61
|
|
|
61
62
|
| Option | Purpose |
|
|
62
63
|
| --- | --- |
|
|
@@ -64,7 +65,9 @@ Point your assistant at `.agents/ARCHITECTURE.md`. It lists every component with
|
|
|
64
65
|
| `-d, --dir <name>` | Install directory inside the project, default `.agents` |
|
|
65
66
|
| `-r, --ref <git-ref>` | Upstream branch, tag, or commit to install, default `main` |
|
|
66
67
|
| `--archive <source>` | Install from a local ag-kit `.tar.gz` or a URL instead of GitHub |
|
|
68
|
+
| `--base-archive <source>` | The version you installed from, instead of downloading the commit in the manifest |
|
|
67
69
|
| `-f, --force` | Overwrite files that were modified locally |
|
|
70
|
+
| `--no-merge` | Never combine a local edit with an upstream change; leave every edited file frozen |
|
|
68
71
|
| `--dry-run` | Download and print the plan without writing anything |
|
|
69
72
|
| `--json` | Print machine-readable output |
|
|
70
73
|
|
|
@@ -83,10 +86,23 @@ For each file it compares what is installed, what upstream now has, and the hash
|
|
|
83
86
|
| Situation | Result |
|
|
84
87
|
| --- | --- |
|
|
85
88
|
| You never touched the file | Updated, or deleted when upstream dropped it |
|
|
86
|
-
| You edited
|
|
89
|
+
| You edited it somewhere upstream did not | Both changes kept |
|
|
90
|
+
| You edited it where upstream also changed it | Skipped and listed, unless `--force` |
|
|
87
91
|
| You created the file yourself | Never touched |
|
|
88
92
|
| Upstream added a file | Installed |
|
|
89
93
|
|
|
94
|
+
Both are only kept when the two changes can be told apart and never touch. Anywhere they do touch, or where the version you installed from cannot be read, the file stays frozen instead. **Nothing you wrote is ever dropped to make that work**, and a file is never left half-combined: it is reconciled whole or left exactly as it was. `--no-merge` turns the attempt off entirely.
|
|
95
|
+
|
|
96
|
+
`diff` shows what a skipped file is holding back, and whose text is whose. It reads three versions — the commit you installed from, your copy, and current upstream — so every difference has a known author:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
npx @daldindev/agentic-skills diff
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
That lists the frozen files and what each one is stuck on. Pass a path to see the three versions of every changed region in one file.
|
|
103
|
+
|
|
104
|
+
Nothing is written and nothing is merged, so the decision stays yours: fold the upstream change in by hand, or take upstream and lose your edit with `--force`. Until you do one or the other, that file sits out every update.
|
|
105
|
+
|
|
90
106
|
To stay on a known upstream state instead of `main`, pass a tag or commit. The same flag reproduces an earlier install exactly:
|
|
91
107
|
|
|
92
108
|
```bash
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { parseArgs } from "node:util";
|
|
6
6
|
import { PACKAGE_ROOT, readJson } from "./fs-utils.mjs";
|
|
7
|
-
import { DEFAULT_INSTALL_DIR, init, status, sync, update } from "./install.mjs";
|
|
7
|
+
import { DEFAULT_INSTALL_DIR, init, inspect, status, sync, update } from "./install.mjs";
|
|
8
8
|
import { UPSTREAM, extractPort, loadArchive, materialize } from "./upstream.mjs";
|
|
9
9
|
|
|
10
10
|
const HELP = `agentic-skills - install the ag-kit agent roles, skills, and workflows into any project
|
|
@@ -14,13 +14,19 @@ Usage:
|
|
|
14
14
|
agentic-skills update [options] Download ag-kit again and update installed files, preserving local edits
|
|
15
15
|
agentic-skills sync [options] Install if missing, update if present; safe to run unattended
|
|
16
16
|
agentic-skills status [options] Show what is installed and which files were changed locally
|
|
17
|
+
agentic-skills diff [path] Show why your edits froze a file, and what they are holding back
|
|
17
18
|
|
|
18
19
|
Options:
|
|
19
20
|
-p, --path <dir> Project directory (default: current directory)
|
|
20
21
|
-d, --dir <name> Install directory inside the project (default: ${DEFAULT_INSTALL_DIR})
|
|
21
22
|
-r, --ref <git-ref> Upstream branch, tag, or commit to install (default: ${UPSTREAM.ref})
|
|
22
23
|
--archive <source> Install from a local ag-kit .tar.gz or a URL instead of GitHub
|
|
24
|
+
--base-archive <s> The version you installed from, as a local .tar.gz or URL, instead of
|
|
25
|
+
downloading the commit in the manifest. Used to tell your changes
|
|
26
|
+
from upstream's, by update, sync, and diff
|
|
23
27
|
-f, --force Overwrite locally modified files
|
|
28
|
+
--no-merge Never combine a local edit with an upstream change; leave every
|
|
29
|
+
edited file frozen, whether or not the two touch
|
|
24
30
|
--dry-run Download and show the plan without writing anything
|
|
25
31
|
--json Print machine-readable output
|
|
26
32
|
-h, --help Show this help
|
|
@@ -40,7 +46,9 @@ const { values, positionals } = parseArgs({
|
|
|
40
46
|
dir: { type: "string", short: "d" },
|
|
41
47
|
ref: { type: "string", short: "r", default: UPSTREAM.ref },
|
|
42
48
|
archive: { type: "string" },
|
|
49
|
+
"base-archive": { type: "string" },
|
|
43
50
|
force: { type: "boolean", short: "f", default: false },
|
|
51
|
+
"no-merge": { type: "boolean", default: false },
|
|
44
52
|
"dry-run": { type: "boolean", default: false },
|
|
45
53
|
json: { type: "boolean", default: false },
|
|
46
54
|
help: { type: "boolean", short: "h", default: false },
|
|
@@ -59,10 +67,10 @@ const list = (label, items, format = (item) => item) => {
|
|
|
59
67
|
for (const item of items) console.log(` ${format(item)}`);
|
|
60
68
|
};
|
|
61
69
|
|
|
62
|
-
/** Download
|
|
63
|
-
const
|
|
64
|
-
log(
|
|
65
|
-
const buffer = await loadArchive({ ref
|
|
70
|
+
/** Download one upstream archive and lay the ported tree out in a temp dir. */
|
|
71
|
+
const fetchTree = async ({ ref = null, archive = null }) => {
|
|
72
|
+
log(archive ? `Reading ${archive}` : `Downloading ${UPSTREAM.repository} at ${ref}`);
|
|
73
|
+
const buffer = await loadArchive({ ref, archive });
|
|
66
74
|
const port = extractPort(buffer);
|
|
67
75
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "agentic-skills-"));
|
|
68
76
|
try {
|
|
@@ -76,7 +84,7 @@ const fetchIncoming = async () => {
|
|
|
76
84
|
dir,
|
|
77
85
|
upstream: {
|
|
78
86
|
repository: UPSTREAM.repository,
|
|
79
|
-
ref:
|
|
87
|
+
ref: archive ? null : ref,
|
|
80
88
|
commit: port.commit,
|
|
81
89
|
version: port.version,
|
|
82
90
|
},
|
|
@@ -84,6 +92,31 @@ const fetchIncoming = async () => {
|
|
|
84
92
|
};
|
|
85
93
|
};
|
|
86
94
|
|
|
95
|
+
/**
|
|
96
|
+
* The version the install came from, so both sides of a local edit can be told
|
|
97
|
+
* apart. Called only once something is actually frozen, and never fatal: without
|
|
98
|
+
* it the run just stays as conservative as it was before.
|
|
99
|
+
*
|
|
100
|
+
* `--archive` means this run was asked not to reach for the network, so the base
|
|
101
|
+
* has to come from `--base-archive` or not at all.
|
|
102
|
+
*/
|
|
103
|
+
let loadedBase = null;
|
|
104
|
+
const loadBase = async (manifest) => {
|
|
105
|
+
const archive = values["base-archive"];
|
|
106
|
+
if (!archive && values.archive) return null;
|
|
107
|
+
|
|
108
|
+
const commit = manifest?.upstream?.commit;
|
|
109
|
+
if (!archive && !commit) return null;
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
loadedBase = await fetchTree(archive ? { archive } : { ref: commit });
|
|
113
|
+
return loadedBase;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.error(`warning: could not read the version you installed from: ${error.message}`);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
87
120
|
const describeUpstream = (upstream) => {
|
|
88
121
|
if (!upstream) return "unknown upstream";
|
|
89
122
|
const commit = upstream.commit ? ` (${upstream.commit.slice(0, 7)})` : "";
|
|
@@ -96,10 +129,12 @@ const printPlan = (result) => {
|
|
|
96
129
|
console.log(`${prefix}${result.mode}: ${result.installDir}`);
|
|
97
130
|
console.log(`${prefix}source: ${describeUpstream(result.upstream)}`);
|
|
98
131
|
console.log(
|
|
99
|
-
`${prefix}added ${plan.add.length}, updated ${plan.update.length},
|
|
100
|
-
`
|
|
132
|
+
`${prefix}added ${plan.add.length}, updated ${plan.update.length}, merged ${plan.merge.length}, ` +
|
|
133
|
+
`removed ${plan.remove.length}, skipped ${plan.skip.length}, kept ${plan.keep.length}, ` +
|
|
134
|
+
`unchanged ${plan.unchanged.length}`,
|
|
101
135
|
);
|
|
102
|
-
list("
|
|
136
|
+
list("Merged (your change and upstream's did not touch)", plan.merge, (item) => item.file);
|
|
137
|
+
list("Skipped (left frozen; use --force to overwrite)", plan.skip, (item) => `${item.file}: ${item.reason}`);
|
|
103
138
|
list("Kept (removed upstream but modified locally)", plan.keep, (item) => `${item.file}: ${item.reason}`);
|
|
104
139
|
if (result.hadManifest === false && plan.skip.length) {
|
|
105
140
|
console.log("\nNo manifest was found, so files that differ from upstream were skipped. Re-run with --force to overwrite them.");
|
|
@@ -109,6 +144,86 @@ const printPlan = (result) => {
|
|
|
109
144
|
}
|
|
110
145
|
};
|
|
111
146
|
|
|
147
|
+
const SIDE = { base: "base ", yours: "yours", upstream: "upstr" };
|
|
148
|
+
const REGION_LINES = 20;
|
|
149
|
+
const plural = (count, word) => `${count} ${count === 1 ? word : `${word}s`}`;
|
|
150
|
+
|
|
151
|
+
const authorPhrase = (authors) => {
|
|
152
|
+
if (authors.length === 2) return "both sides wrote here";
|
|
153
|
+
return authors[0] === "yours" ? "only you wrote here" : "only upstream wrote here";
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/** One side of a region. A bare `|` is a blank line; no `|` means the side has nothing here. */
|
|
157
|
+
const printSide = (side, lines) => {
|
|
158
|
+
if (!lines.length) {
|
|
159
|
+
console.log(` ${SIDE[side]} nothing here`);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
for (const line of lines.slice(0, REGION_LINES)) console.log(` ${SIDE[side]} | ${line}`);
|
|
163
|
+
const rest = lines.length - REGION_LINES;
|
|
164
|
+
if (rest > 0) console.log(` ${SIDE[side]} ~ ${plural(rest, "more line")} ~`);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const printDiffFile = (report, entry) => {
|
|
168
|
+
console.log(entry.file);
|
|
169
|
+
console.log(` installed ${describeUpstream(report.base)} -> upstream ${describeUpstream(report.upstream)}`);
|
|
170
|
+
console.log(` ${plural(entry.regions.length, "region")} changed, ${entry.collisions} written by both sides`);
|
|
171
|
+
|
|
172
|
+
entry.regions.forEach((region, index) => {
|
|
173
|
+
console.log(`\n [${index + 1}] ${region.where.padEnd(38)} ${authorPhrase(region.authors)}`);
|
|
174
|
+
printSide("base", region.base);
|
|
175
|
+
printSide("yours", region.yours);
|
|
176
|
+
printSide("upstream", region.upstream);
|
|
177
|
+
if (region.authors.length === 2) {
|
|
178
|
+
console.log("\n note: no untouched line separates your lines from upstream's here,");
|
|
179
|
+
console.log(" so neither side can be applied without deciding about the other.");
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
console.log("\ndiff only reads - nothing was written.");
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const printDiffSummary = (report) => {
|
|
187
|
+
console.log(`diff: ${report.installDir}`);
|
|
188
|
+
console.log(`source: ${describeUpstream(report.upstream)}`);
|
|
189
|
+
if (report.comparedAgainstBase) console.log(`base: ${describeUpstream(report.base)}`);
|
|
190
|
+
|
|
191
|
+
const entries = [...report.frozen, ...report.removedUpstream];
|
|
192
|
+
if (!entries.length) {
|
|
193
|
+
console.log("\nNothing is frozen: every installed file either matches upstream, tracks it cleanly, or is your own.");
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const width = Math.max(...entries.map((entry) => entry.file.length));
|
|
198
|
+
if (!report.comparedAgainstBase) {
|
|
199
|
+
console.log(`\n${plural(entries.length, "frozen file")}, and no way to tell your changes from upstream's:`);
|
|
200
|
+
console.log(report.hadManifest
|
|
201
|
+
? "the version this install came from could not be read. Pass --base-archive to supply it."
|
|
202
|
+
: "this install has no manifest saying which version it came from.");
|
|
203
|
+
for (const entry of entries) console.log(` ${entry.file}`);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const blocked = report.frozen.filter((entry) => entry.collisions > 0).length;
|
|
208
|
+
const byRule = report.frozen.length - blocked;
|
|
209
|
+
const parts = [];
|
|
210
|
+
if (blocked) parts.push(`${blocked} blocked by an overlap`);
|
|
211
|
+
if (byRule) parts.push(`${byRule} frozen by the per-file rule alone`);
|
|
212
|
+
if (report.removedUpstream.length) parts.push(`${report.removedUpstream.length} removed upstream`);
|
|
213
|
+
|
|
214
|
+
console.log(`\n${plural(entries.length, "frozen file")}. ${parts.join(", ")}.`);
|
|
215
|
+
console.log("diff only reads - nothing was written.\n");
|
|
216
|
+
|
|
217
|
+
for (const entry of report.frozen) {
|
|
218
|
+
const both = entry.collisions ? `${entry.collisions} written by both sides` : "none written by both sides";
|
|
219
|
+
console.log(` ${entry.file.padEnd(width)} ${plural(entry.regions.length, "region")} changed, ${both}`);
|
|
220
|
+
}
|
|
221
|
+
for (const entry of report.removedUpstream) {
|
|
222
|
+
console.log(` ${entry.file.padEnd(width)} removed upstream, kept because you edited it`);
|
|
223
|
+
}
|
|
224
|
+
console.log('\nRun "agentic-skills diff <path>" to see the three versions of one file.');
|
|
225
|
+
};
|
|
226
|
+
|
|
112
227
|
const printStatus = (info) => {
|
|
113
228
|
console.log(`package: ${info.package.packageName} ${info.package.packageVersion}`);
|
|
114
229
|
console.log(`target: ${info.installDir}`);
|
|
@@ -135,7 +250,8 @@ try {
|
|
|
135
250
|
} else if (values.help || !command || command === "help") {
|
|
136
251
|
process.stdout.write(HELP);
|
|
137
252
|
} else if (command === "init" || command === "update" || command === "sync") {
|
|
138
|
-
const
|
|
253
|
+
const merging = !values.force && !values["no-merge"];
|
|
254
|
+
const incoming = await fetchTree({ ref: values.ref, archive: values.archive });
|
|
139
255
|
try {
|
|
140
256
|
const options = {
|
|
141
257
|
path: values.path,
|
|
@@ -143,6 +259,7 @@ try {
|
|
|
143
259
|
force: values.force,
|
|
144
260
|
dryRun: values["dry-run"],
|
|
145
261
|
incomingDir: incoming.dir,
|
|
262
|
+
loadBase: merging ? loadBase : null,
|
|
146
263
|
upstream: incoming.upstream,
|
|
147
264
|
};
|
|
148
265
|
const run = { init, update, sync }[command];
|
|
@@ -152,6 +269,34 @@ try {
|
|
|
152
269
|
if (result.plan.skip.length || result.plan.keep.length) process.exitCode = 2;
|
|
153
270
|
} finally {
|
|
154
271
|
await incoming.cleanup();
|
|
272
|
+
if (loadedBase) await loadedBase.cleanup();
|
|
273
|
+
}
|
|
274
|
+
} else if (command === "diff") {
|
|
275
|
+
const target = { path: values.path, dir: values.dir };
|
|
276
|
+
const incoming = await fetchTree({ ref: values.ref, archive: values.archive });
|
|
277
|
+
try {
|
|
278
|
+
const report = await inspect({
|
|
279
|
+
...target,
|
|
280
|
+
incomingDir: incoming.dir,
|
|
281
|
+
loadBase,
|
|
282
|
+
upstream: incoming.upstream,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const wanted = positionals[1]?.replaceAll("\\", "/");
|
|
286
|
+
if (values.json) console.log(JSON.stringify(report, null, 2));
|
|
287
|
+
else if (!wanted) printDiffSummary(report);
|
|
288
|
+
else {
|
|
289
|
+
const entry = report.frozen.find((item) => item.file === wanted);
|
|
290
|
+
if (entry?.regions) printDiffFile(report, entry);
|
|
291
|
+
else if (entry) console.error(`${wanted} is frozen, but the version you installed from could not be read.`);
|
|
292
|
+
else {
|
|
293
|
+
console.error(`${wanted} is not frozen. Run "agentic-skills diff" to list the files that are.`);
|
|
294
|
+
process.exitCode = 1;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
} finally {
|
|
298
|
+
await incoming.cleanup();
|
|
299
|
+
if (loadedBase) await loadedBase.cleanup();
|
|
155
300
|
}
|
|
156
301
|
} else if (command === "status") {
|
|
157
302
|
const info = await status({ path: values.path, dir: values.dir });
|
package/src/diff.mjs
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Three-way comparison of one file, used to explain why a local edit froze it.
|
|
3
|
+
*
|
|
4
|
+
* Everything is measured against the version the user installed from, so every
|
|
5
|
+
* difference has a known author: the installed tree changed it, upstream
|
|
6
|
+
* changed it, or both wrote in the same place. A two-way comparison cannot
|
|
7
|
+
* tell those apart, which is the whole reason this exists.
|
|
8
|
+
*
|
|
9
|
+
* Nothing here merges anything. It only describes.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Above this many table cells the alignment degrades to "replaced wholesale". */
|
|
13
|
+
const MAX_CELLS = 4_000_000;
|
|
14
|
+
|
|
15
|
+
export const toLines = (text) => {
|
|
16
|
+
const lines = text.split(/\r?\n/);
|
|
17
|
+
if (lines.length && lines[lines.length - 1] === "") lines.pop();
|
|
18
|
+
return lines;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const mark = (type) => (line) => ({ type, line });
|
|
22
|
+
|
|
23
|
+
/** Longest common subsequence alignment of two line arrays. */
|
|
24
|
+
function align(a, b) {
|
|
25
|
+
if (!a.length) return b.map(mark("add"));
|
|
26
|
+
if (!b.length) return a.map(mark("remove"));
|
|
27
|
+
if (a.length * b.length > MAX_CELLS) return [...a.map(mark("remove")), ...b.map(mark("add"))];
|
|
28
|
+
|
|
29
|
+
const width = b.length + 1;
|
|
30
|
+
const table = new Uint32Array((a.length + 1) * width);
|
|
31
|
+
for (let i = a.length - 1; i >= 0; i--) {
|
|
32
|
+
for (let j = b.length - 1; j >= 0; j--) {
|
|
33
|
+
table[i * width + j] = a[i] === b[j]
|
|
34
|
+
? table[(i + 1) * width + j + 1] + 1
|
|
35
|
+
: Math.max(table[(i + 1) * width + j], table[i * width + j + 1]);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ops = [];
|
|
40
|
+
let i = 0;
|
|
41
|
+
let j = 0;
|
|
42
|
+
while (i < a.length && j < b.length) {
|
|
43
|
+
if (a[i] === b[j]) {
|
|
44
|
+
ops.push({ type: "context", line: a[i] });
|
|
45
|
+
i++;
|
|
46
|
+
j++;
|
|
47
|
+
} else if (table[(i + 1) * width + j] >= table[i * width + j + 1]) {
|
|
48
|
+
ops.push({ type: "remove", line: a[i++] });
|
|
49
|
+
} else {
|
|
50
|
+
ops.push({ type: "add", line: b[j++] });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
while (i < a.length) ops.push({ type: "remove", line: a[i++] });
|
|
54
|
+
while (j < b.length) ops.push({ type: "add", line: b[j++] });
|
|
55
|
+
return ops;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Compare two texts line by line. Identical head and tail are trimmed first,
|
|
60
|
+
* so an edit in one section does not pay for the rest of the file.
|
|
61
|
+
*/
|
|
62
|
+
export function diffLines(before, after) {
|
|
63
|
+
const a = toLines(before);
|
|
64
|
+
const b = toLines(after);
|
|
65
|
+
|
|
66
|
+
let start = 0;
|
|
67
|
+
while (start < a.length && start < b.length && a[start] === b[start]) start++;
|
|
68
|
+
|
|
69
|
+
let endA = a.length;
|
|
70
|
+
let endB = b.length;
|
|
71
|
+
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
|
|
72
|
+
endA--;
|
|
73
|
+
endB--;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return [
|
|
77
|
+
...a.slice(0, start).map(mark("context")),
|
|
78
|
+
...align(a.slice(start, endA), b.slice(start, endB)),
|
|
79
|
+
...a.slice(endA).map(mark("context")),
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Rewrite the comparison as an edit script against the base: a list of
|
|
85
|
+
* `[start, end)` base ranges and the lines that replace each one. An insertion
|
|
86
|
+
* is an empty range, which is what lets two insertions at the same point be
|
|
87
|
+
* recognised as the same place.
|
|
88
|
+
*/
|
|
89
|
+
function editsAgainstBase(baseLines, otherLines) {
|
|
90
|
+
// Trim the identical head and tail first, so a one-line change in a long file
|
|
91
|
+
// never pays for the whole file, and never trips the size cap in `align`.
|
|
92
|
+
let start = 0;
|
|
93
|
+
while (start < baseLines.length && start < otherLines.length && baseLines[start] === otherLines[start]) start++;
|
|
94
|
+
|
|
95
|
+
let endBase = baseLines.length;
|
|
96
|
+
let endOther = otherLines.length;
|
|
97
|
+
while (endBase > start && endOther > start && baseLines[endBase - 1] === otherLines[endOther - 1]) {
|
|
98
|
+
endBase--;
|
|
99
|
+
endOther--;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const ops = align(baseLines.slice(start, endBase), otherLines.slice(start, endOther));
|
|
103
|
+
const edits = [];
|
|
104
|
+
let index = start;
|
|
105
|
+
let current = null;
|
|
106
|
+
|
|
107
|
+
for (const op of ops) {
|
|
108
|
+
if (op.type === "context") {
|
|
109
|
+
if (current) edits.push(current);
|
|
110
|
+
current = null;
|
|
111
|
+
index++;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
current ||= { start: index, end: index, lines: [] };
|
|
115
|
+
if (op.type === "remove") current.end = ++index;
|
|
116
|
+
else current.lines.push(op.line);
|
|
117
|
+
}
|
|
118
|
+
if (current) edits.push(current);
|
|
119
|
+
|
|
120
|
+
return edits;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Replay one side's edits over a slice of the base. */
|
|
124
|
+
function applyEdits(baseLines, start, end, edits) {
|
|
125
|
+
const out = [];
|
|
126
|
+
let cursor = start;
|
|
127
|
+
for (const edit of edits) {
|
|
128
|
+
out.push(...baseLines.slice(cursor, edit.start));
|
|
129
|
+
out.push(...edit.lines);
|
|
130
|
+
cursor = edit.end;
|
|
131
|
+
}
|
|
132
|
+
out.push(...baseLines.slice(cursor, end));
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Every place the two sides diverged from the base, with all three versions of
|
|
138
|
+
* that place and who wrote there.
|
|
139
|
+
*
|
|
140
|
+
* Ranges that touch are one region, the way a merge tool treats them: two
|
|
141
|
+
* changes with no untouched line between them cannot be applied independently.
|
|
142
|
+
*/
|
|
143
|
+
export function changedRegions({ base, yours, upstream }) {
|
|
144
|
+
const baseLines = toLines(base);
|
|
145
|
+
const sides = {
|
|
146
|
+
yours: editsAgainstBase(baseLines, toLines(yours)),
|
|
147
|
+
upstream: editsAgainstBase(baseLines, toLines(upstream)),
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const all = Object.entries(sides)
|
|
151
|
+
.flatMap(([side, edits]) => edits.map((edit) => ({ ...edit, side })))
|
|
152
|
+
.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
153
|
+
|
|
154
|
+
const grouped = [];
|
|
155
|
+
for (const edit of all) {
|
|
156
|
+
const last = grouped[grouped.length - 1];
|
|
157
|
+
if (last && edit.start <= last.end) {
|
|
158
|
+
last.end = Math.max(last.end, edit.end);
|
|
159
|
+
last.parts.push(edit);
|
|
160
|
+
} else {
|
|
161
|
+
grouped.push({ start: edit.start, end: edit.end, parts: [edit] });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return grouped.map((region) => {
|
|
166
|
+
const authors = [...new Set(region.parts.map((part) => part.side))].sort();
|
|
167
|
+
const partsFor = (side) => region.parts.filter((part) => part.side === side);
|
|
168
|
+
return {
|
|
169
|
+
baseStart: region.start,
|
|
170
|
+
baseEnd: region.end,
|
|
171
|
+
authors,
|
|
172
|
+
base: baseLines.slice(region.start, region.end),
|
|
173
|
+
yours: applyEdits(baseLines, region.start, region.end, partsFor("yours")),
|
|
174
|
+
upstream: applyEdits(baseLines, region.start, region.end, partsFor("upstream")),
|
|
175
|
+
};
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Combine both sides when, and only when, they never wrote in the same place.
|
|
181
|
+
*
|
|
182
|
+
* A region with one author is unambiguous: taking that author's text is the
|
|
183
|
+
* same operation `update` performs on a file nobody edited, with the scope
|
|
184
|
+
* corrected from the file to the region. A region with two authors has no
|
|
185
|
+
* answer that is not a guess, so the whole file is refused rather than
|
|
186
|
+
* half-merged. Nothing the user wrote is ever dropped by this function.
|
|
187
|
+
*/
|
|
188
|
+
export function mergeClean({ base, yours, upstream }) {
|
|
189
|
+
const regions = changedRegions({ base, yours, upstream });
|
|
190
|
+
const conflicts = regions.filter((region) => region.authors.length === 2);
|
|
191
|
+
if (conflicts.length) return { merged: null, conflicts: conflicts.length };
|
|
192
|
+
|
|
193
|
+
const baseLines = toLines(base);
|
|
194
|
+
const out = [];
|
|
195
|
+
let cursor = 0;
|
|
196
|
+
for (const region of regions) {
|
|
197
|
+
out.push(...baseLines.slice(cursor, region.baseStart));
|
|
198
|
+
out.push(...(region.authors[0] === "yours" ? region.yours : region.upstream));
|
|
199
|
+
cursor = region.baseEnd;
|
|
200
|
+
}
|
|
201
|
+
out.push(...baseLines.slice(cursor));
|
|
202
|
+
|
|
203
|
+
// The result replaces the reader's file, so it keeps the reader's line endings
|
|
204
|
+
// and their choice about a final newline. A merge should change what it says,
|
|
205
|
+
// not how every line in the file is terminated.
|
|
206
|
+
const eol = yours.includes("\r\n") ? "\r\n" : "\n";
|
|
207
|
+
const trailing = /\r?\n$/.test(yours) ? eol : "";
|
|
208
|
+
return { merged: out.length ? out.join(eol) + trailing : "", conflicts: 0 };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The nearest Markdown heading above a region, to say where in the file it sits. */
|
|
212
|
+
export function locate(base, region) {
|
|
213
|
+
const baseLines = toLines(base);
|
|
214
|
+
if (region.baseStart >= baseLines.length) return "end of file";
|
|
215
|
+
for (let i = region.baseStart; i >= 0; i--) {
|
|
216
|
+
if (baseLines[i].startsWith("#")) return `line ${region.baseStart + 1}, in "${baseLines[i].trim()}"`;
|
|
217
|
+
}
|
|
218
|
+
return `line ${region.baseStart + 1}`;
|
|
219
|
+
}
|
package/src/install.mjs
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { changedRegions, locate, mergeClean } from "./diff.mjs";
|
|
3
4
|
import {
|
|
4
5
|
PACKAGE_ROOT,
|
|
5
6
|
copyFile,
|
|
6
7
|
exists,
|
|
8
|
+
hashFile,
|
|
7
9
|
readJson,
|
|
8
10
|
removeEmptyParents,
|
|
9
11
|
snapshotTree,
|
|
@@ -54,6 +56,29 @@ async function writeManifest(installDir, files, { previous = null, upstream = nu
|
|
|
54
56
|
return manifest;
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
/**
|
|
60
|
+
* What each installed file descends from after this run.
|
|
61
|
+
*
|
|
62
|
+
* Only files this run actually wrote move to the incoming hash. A file left
|
|
63
|
+
* frozen keeps the hash it had, because its ancestor is still the version it was
|
|
64
|
+
* last written from; recording the new one would make a later merge read
|
|
65
|
+
* upstream's change as the user having deleted it.
|
|
66
|
+
*/
|
|
67
|
+
function nextManifestFiles({ previous, plan }) {
|
|
68
|
+
const kept = new Set(plan.keep.map((item) => item.file));
|
|
69
|
+
const files = {};
|
|
70
|
+
|
|
71
|
+
// Carry an entry forward only while the file is still something to track:
|
|
72
|
+
// upstream still ships it, or it is on disk because an edit saved it.
|
|
73
|
+
for (const [file, hash] of Object.entries(previous)) {
|
|
74
|
+
if (plan.incoming[file] !== undefined || kept.has(file)) files[file] = hash;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const file of [...plan.add, ...plan.update, ...plan.unchanged]) files[file] = plan.incoming[file];
|
|
78
|
+
for (const { file } of plan.merge ?? []) files[file] = plan.incoming[file];
|
|
79
|
+
return files;
|
|
80
|
+
}
|
|
81
|
+
|
|
57
82
|
/**
|
|
58
83
|
* Three-way comparison between the installed tree, the incoming tree fetched
|
|
59
84
|
* from upstream, and the hashes recorded at the last install or update.
|
|
@@ -103,10 +128,53 @@ export async function planUpdate({ installDir, incomingDir, manifest = null, for
|
|
|
103
128
|
return plan;
|
|
104
129
|
}
|
|
105
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Try to combine both sides for the files the plan would otherwise skip.
|
|
133
|
+
*
|
|
134
|
+
* A file is only ever a merge candidate when the downloaded base hashes to the
|
|
135
|
+
* exact value the manifest recorded for it. That proves the text being used as
|
|
136
|
+
* the common ancestor is the text that was installed; without that proof the
|
|
137
|
+
* file is left frozen, because a wrong ancestor is how a merge loses work.
|
|
138
|
+
*/
|
|
139
|
+
export async function resolveMerges({ installDir, incomingDir, baseDir, plan, manifest }) {
|
|
140
|
+
if (!baseDir || !manifest) return { merge: [], skip: plan.skip };
|
|
141
|
+
|
|
142
|
+
const merge = [];
|
|
143
|
+
const skip = [];
|
|
144
|
+
|
|
145
|
+
for (const item of plan.skip) {
|
|
146
|
+
const recorded = manifest.files?.[item.file];
|
|
147
|
+
const basePath = path.join(baseDir, item.file);
|
|
148
|
+
const provable = recorded && (await exists(basePath)) && (await hashFile(basePath)) === recorded;
|
|
149
|
+
if (!provable) {
|
|
150
|
+
skip.push({ ...item, reason: `${item.reason}; no proven common ancestor to merge from` });
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const [base, yours, incoming] = await Promise.all([
|
|
155
|
+
fs.readFile(basePath, "utf8"),
|
|
156
|
+
fs.readFile(path.join(installDir, item.file), "utf8"),
|
|
157
|
+
fs.readFile(path.join(incomingDir, item.file), "utf8"),
|
|
158
|
+
]);
|
|
159
|
+
|
|
160
|
+
const { merged, conflicts } = mergeClean({ base, yours, upstream: incoming });
|
|
161
|
+
if (merged === null) {
|
|
162
|
+
skip.push({ ...item, reason: `your change and upstream's overlap in ${conflicts === 1 ? "one place" : `${conflicts} places`}` });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
merge.push({ file: item.file, text: merged });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { merge, skip };
|
|
169
|
+
}
|
|
170
|
+
|
|
106
171
|
export async function applyPlan({ installDir, incomingDir, plan }) {
|
|
107
172
|
for (const file of [...plan.add, ...plan.update]) {
|
|
108
173
|
await copyFile(path.join(incomingDir, file), path.join(installDir, file));
|
|
109
174
|
}
|
|
175
|
+
for (const { file, text } of plan.merge ?? []) {
|
|
176
|
+
await fs.writeFile(path.join(installDir, file), text);
|
|
177
|
+
}
|
|
110
178
|
for (const file of plan.remove) {
|
|
111
179
|
const target = path.join(installDir, file);
|
|
112
180
|
await fs.rm(target, { force: true });
|
|
@@ -114,6 +182,17 @@ export async function applyPlan({ installDir, incomingDir, plan }) {
|
|
|
114
182
|
}
|
|
115
183
|
}
|
|
116
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Where the version this install came from can be read, if anywhere. `loadBase`
|
|
187
|
+
* is only called once the plan actually has something frozen, so an install with
|
|
188
|
+
* no local edits never pays for a second download.
|
|
189
|
+
*/
|
|
190
|
+
async function baseFor(options, plan, manifest) {
|
|
191
|
+
if (options.baseDir) return { dir: options.baseDir, upstream: options.base || null };
|
|
192
|
+
if (!plan.skip.length || !options.loadBase) return null;
|
|
193
|
+
return (await options.loadBase(manifest)) || null;
|
|
194
|
+
}
|
|
195
|
+
|
|
117
196
|
async function hasContent(dir) {
|
|
118
197
|
if (!(await exists(dir))) return false;
|
|
119
198
|
const entries = await fs.readdir(dir);
|
|
@@ -131,9 +210,22 @@ async function reconcile(mode, options) {
|
|
|
131
210
|
const created = !(await hasContent(installDir));
|
|
132
211
|
const manifest = await loadManifest(installDir);
|
|
133
212
|
const plan = await planUpdate({ installDir, incomingDir, manifest, force: Boolean(options.force) });
|
|
213
|
+
|
|
214
|
+
const base = await baseFor(options, plan, manifest);
|
|
215
|
+
const resolved = await resolveMerges({
|
|
216
|
+
installDir,
|
|
217
|
+
incomingDir,
|
|
218
|
+
baseDir: base?.dir || null,
|
|
219
|
+
plan,
|
|
220
|
+
manifest,
|
|
221
|
+
});
|
|
222
|
+
plan.merge = resolved.merge;
|
|
223
|
+
plan.skip = resolved.skip;
|
|
224
|
+
|
|
134
225
|
if (!options.dryRun) {
|
|
135
226
|
await applyPlan({ installDir, incomingDir, plan });
|
|
136
|
-
|
|
227
|
+
const files = nextManifestFiles({ previous: manifest?.files || {}, plan });
|
|
228
|
+
await writeManifest(installDir, files, { previous: manifest, upstream });
|
|
137
229
|
}
|
|
138
230
|
return {
|
|
139
231
|
mode,
|
|
@@ -180,6 +272,60 @@ export async function sync(options = {}) {
|
|
|
180
272
|
return reconcile("sync", options);
|
|
181
273
|
}
|
|
182
274
|
|
|
275
|
+
/**
|
|
276
|
+
* Report what upstream changed in the files a local edit froze.
|
|
277
|
+
*
|
|
278
|
+
* `status` says which files stopped receiving updates; this says what they
|
|
279
|
+
* stopped receiving. Nothing is written, and nothing is merged: the installer
|
|
280
|
+
* never reconciles an edited file on its own, so this is the input a person
|
|
281
|
+
* needs to do it by hand.
|
|
282
|
+
*/
|
|
283
|
+
export async function inspect(options = {}) {
|
|
284
|
+
const { installDir } = resolveTarget(options);
|
|
285
|
+
const { incomingDir, upstream = null } = options;
|
|
286
|
+
|
|
287
|
+
if (!(await exists(installDir))) {
|
|
288
|
+
throw new Error(`${installDir} does not exist. Run "agentic-skills init" first.`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const manifest = await loadManifest(installDir);
|
|
292
|
+
const plan = await planUpdate({ installDir, incomingDir, manifest, force: false });
|
|
293
|
+
const base = await baseFor(options, plan, manifest);
|
|
294
|
+
const baseDir = base?.dir || null;
|
|
295
|
+
const read = async (dir, file) => (dir ? fs.readFile(path.join(dir, file), "utf8").catch(() => null) : null);
|
|
296
|
+
|
|
297
|
+
const frozen = [];
|
|
298
|
+
for (const { file, reason } of plan.skip) {
|
|
299
|
+
const yours = await read(installDir, file);
|
|
300
|
+
const incoming = await read(incomingDir, file);
|
|
301
|
+
// A file upstream added after this install has no base text; treat it as empty
|
|
302
|
+
// so both sides read as having written the whole thing, which is the truth.
|
|
303
|
+
const baseText = (await read(baseDir, file)) ?? "";
|
|
304
|
+
|
|
305
|
+
const regions = baseDir
|
|
306
|
+
? changedRegions({ base: baseText, yours, upstream: incoming })
|
|
307
|
+
.map((region) => ({ ...region, where: locate(baseText, region) }))
|
|
308
|
+
: null;
|
|
309
|
+
|
|
310
|
+
frozen.push({
|
|
311
|
+
file,
|
|
312
|
+
reason,
|
|
313
|
+
regions,
|
|
314
|
+
collisions: regions ? regions.filter((region) => region.authors.length === 2).length : null,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
installDir,
|
|
320
|
+
upstream,
|
|
321
|
+
base: base?.upstream || null,
|
|
322
|
+
comparedAgainstBase: Boolean(baseDir),
|
|
323
|
+
hadManifest: Boolean(manifest),
|
|
324
|
+
frozen,
|
|
325
|
+
removedUpstream: plan.keep,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
183
329
|
/** Local report only; nothing is fetched. */
|
|
184
330
|
export async function status(options = {}) {
|
|
185
331
|
const { installDir } = resolveTarget(options);
|