airdy 0.1.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/LICENSE +21 -0
- package/README.md +84 -0
- package/bin/airdy.js +8 -0
- package/dist/adapters/claude-code.js +41 -0
- package/dist/adapters/codex.js +11 -0
- package/dist/adapters/index.js +9 -0
- package/dist/adapters/types.js +1 -0
- package/dist/cli/args.js +45 -0
- package/dist/config/user.js +49 -0
- package/dist/context.js +69 -0
- package/dist/git/steps.js +32 -0
- package/dist/main.js +312 -0
- package/dist/merge/json.js +33 -0
- package/dist/merge/markdown.js +16 -0
- package/dist/merge/template.js +3 -0
- package/dist/plan/builder.js +82 -0
- package/dist/plan/conflicts.js +113 -0
- package/dist/plan/executor.js +183 -0
- package/dist/profiles/index.js +15 -0
- package/dist/profiles/templates.js +68 -0
- package/dist/prompts/conflict.js +39 -0
- package/dist/prompts/wizard.js +217 -0
- package/dist/skills/package.js +53 -0
- package/dist/state/agentsetup.js +42 -0
- package/dist/types.js +1 -0
- package/dist/version.js +6 -0
- package/dist/workflows/module.js +88 -0
- package/package.json +50 -0
package/dist/main.js
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { basename, join } from "node:path";
|
|
2
|
+
import { readdir, readFile, lstat, readlink } from "node:fs/promises";
|
|
3
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
4
|
+
import { parseArgs } from "./cli/args.js";
|
|
5
|
+
import { getVersion } from "./version.js";
|
|
6
|
+
import { detectContext, checkPreflight } from "./context.js";
|
|
7
|
+
import { loadUserConfig, mergePackages, loadAnswersFromConfig, } from "./config/user.js";
|
|
8
|
+
import { readState, answersFromState, stateAction, } from "./state/agentsetup.js";
|
|
9
|
+
import { buildPlan, describePlan } from "./plan/builder.js";
|
|
10
|
+
import { executePlan } from "./plan/executor.js";
|
|
11
|
+
import { resolveConflict, resolveLinkConflict, previewAction, applyChoice, unifiedDiff, } from "./plan/conflicts.js";
|
|
12
|
+
import { promptConflict, promptOverwritePath } from "./prompts/conflict.js";
|
|
13
|
+
import { preGit, postGit } from "./git/steps.js";
|
|
14
|
+
import { getAdapter } from "./adapters/index.js";
|
|
15
|
+
import { loadModule } from "./workflows/module.js";
|
|
16
|
+
import { runWizard, confirmPlan } from "./prompts/wizard.js";
|
|
17
|
+
async function loadPresetModules(sources) {
|
|
18
|
+
const modules = {};
|
|
19
|
+
for (const src of sources) {
|
|
20
|
+
let entries;
|
|
21
|
+
try {
|
|
22
|
+
entries = await readdir(src);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
continue; // preset source dir missing; skip
|
|
26
|
+
}
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
try {
|
|
29
|
+
const mod = await loadModule(join(src, entry));
|
|
30
|
+
modules[mod.name] = mod;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// not a module dir (no module.json); ignore
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return modules;
|
|
38
|
+
}
|
|
39
|
+
function isEnoent(err) {
|
|
40
|
+
return (typeof err === "object" &&
|
|
41
|
+
err !== null &&
|
|
42
|
+
err.code === "ENOENT");
|
|
43
|
+
}
|
|
44
|
+
// Only a genuinely absent file counts as "no conflict". Any other read error
|
|
45
|
+
// (permission denied, a directory in the file's place) must surface, not be
|
|
46
|
+
// silently treated as absent — otherwise the conflict layer would let a write
|
|
47
|
+
// proceed against a file it could not actually read.
|
|
48
|
+
async function readIfExists(path) {
|
|
49
|
+
try {
|
|
50
|
+
return await readFile(path, "utf8");
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
if (isEnoent(err))
|
|
54
|
+
return null;
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Only a genuinely absent destination counts as "no conflict" here too (see
|
|
59
|
+
// readIfExists above): any other lstat error (permission denied, etc.) must
|
|
60
|
+
// propagate rather than be silently treated as absent.
|
|
61
|
+
async function probeLink(abs) {
|
|
62
|
+
let st;
|
|
63
|
+
try {
|
|
64
|
+
st = await lstat(abs);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
if (isEnoent(err))
|
|
68
|
+
return { exists: false, currentLink: null };
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
let currentLink = null;
|
|
72
|
+
if (st.isSymbolicLink()) {
|
|
73
|
+
try {
|
|
74
|
+
currentLink = await readlink(abs);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
currentLink = null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { exists: true, currentLink };
|
|
81
|
+
}
|
|
82
|
+
export async function resolvePlanConflicts(plan, root, ownedPaths, interactive) {
|
|
83
|
+
const owned = new Set(ownedPaths);
|
|
84
|
+
const finalPlan = [];
|
|
85
|
+
const conflictSkipped = [];
|
|
86
|
+
for (const action of plan) {
|
|
87
|
+
if (action.type === "write-file") {
|
|
88
|
+
const existing = await readIfExists(join(root, action.path));
|
|
89
|
+
if (resolveConflict(action, existing, owned) === "proceed") {
|
|
90
|
+
finalPlan.push(action);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (!interactive) {
|
|
94
|
+
// --yes default: skip existing non-airdy files, report them
|
|
95
|
+
conflictSkipped.push(action.path);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const diff = unifiedDiff(existing ?? "", previewAction(action, existing ?? ""), action.path);
|
|
99
|
+
const choice = await promptConflict(action.path, diff);
|
|
100
|
+
const resolved = applyChoice(action, choice, existing ?? "");
|
|
101
|
+
if (resolved)
|
|
102
|
+
finalPlan.push(resolved);
|
|
103
|
+
else
|
|
104
|
+
conflictSkipped.push(action.path);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (action.type === "symlink" || action.type === "copy-dir") {
|
|
108
|
+
const dest = action.type === "symlink" ? action.path : action.to;
|
|
109
|
+
const desired = action.type === "symlink" ? action.target : null;
|
|
110
|
+
const probe = await probeLink(join(root, dest));
|
|
111
|
+
if (resolveLinkConflict(dest, desired, probe, owned) === "proceed") {
|
|
112
|
+
finalPlan.push(action);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (!interactive) {
|
|
116
|
+
conflictSkipped.push(dest);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const overwrite = await promptOverwritePath(dest);
|
|
120
|
+
if (overwrite)
|
|
121
|
+
finalPlan.push(action);
|
|
122
|
+
else
|
|
123
|
+
conflictSkipped.push(dest);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
// merge-markdown / merge-json: additive per marker/key and idempotent
|
|
127
|
+
finalPlan.push(action);
|
|
128
|
+
}
|
|
129
|
+
return { finalPlan, conflictSkipped };
|
|
130
|
+
}
|
|
131
|
+
// The executor's applied/skipped entries are labels; map an action back to the
|
|
132
|
+
// project-relative destination path it actually writes.
|
|
133
|
+
function actionLabel(a) {
|
|
134
|
+
return "path" in a ? a.path : `${a.from} -> ${a.to}`;
|
|
135
|
+
}
|
|
136
|
+
function actionDest(a) {
|
|
137
|
+
return a.type === "copy-dir" ? a.to : a.path;
|
|
138
|
+
}
|
|
139
|
+
// Only actions that wholly create/replace their destination confer ownership;
|
|
140
|
+
// merge-markdown/merge-json only merge into a path that may pre-exist as a
|
|
141
|
+
// user file, so an applied merge must never mark its target airdy-owned.
|
|
142
|
+
function isAuthoringAction(action) {
|
|
143
|
+
return (action.type === "write-file" ||
|
|
144
|
+
action.type === "symlink" ||
|
|
145
|
+
action.type === "copy-dir");
|
|
146
|
+
}
|
|
147
|
+
// Union of prior ownedPaths with the destinations actually authored this run.
|
|
148
|
+
function nextOwnedPaths(prior, finalPlan, report) {
|
|
149
|
+
const applied = new Set(report.applied);
|
|
150
|
+
const owned = new Set(prior);
|
|
151
|
+
for (const action of finalPlan) {
|
|
152
|
+
if (!isAuthoringAction(action))
|
|
153
|
+
continue;
|
|
154
|
+
if (applied.has(actionLabel(action)))
|
|
155
|
+
owned.add(actionDest(action));
|
|
156
|
+
}
|
|
157
|
+
return [...owned].sort();
|
|
158
|
+
}
|
|
159
|
+
export async function launchOrPrint(launchAgent, ctx, cwd, opts) {
|
|
160
|
+
const adapter = getAdapter(launchAgent);
|
|
161
|
+
if (adapter.trustNote)
|
|
162
|
+
console.log(`\nNote: ${adapter.trustNote}`);
|
|
163
|
+
const { cmd, args } = adapter.launchCommand(ctx);
|
|
164
|
+
const full = [cmd, ...args].join(" ");
|
|
165
|
+
// print-only path (dry-run, non-interactive, or --yes): never spawn
|
|
166
|
+
if (!opts.interactive) {
|
|
167
|
+
console.log(`\nTo start ${launchAgent}: ${full}`);
|
|
168
|
+
return 0;
|
|
169
|
+
}
|
|
170
|
+
// guard: if the launch agent's CLI is not installed, print the command with a
|
|
171
|
+
// note instead of spawning a process that would immediately fail (consistent
|
|
172
|
+
// with checkPreflight's warning).
|
|
173
|
+
const cliKey = adapter.cliName;
|
|
174
|
+
const cliAvailable = ctx.cli?.[cliKey] ?? false;
|
|
175
|
+
if (!cliAvailable) {
|
|
176
|
+
console.log(`\nTo start ${launchAgent}: ${full}`);
|
|
177
|
+
console.log(`(${cmd} CLI not found on PATH — install it, then run the command above)`);
|
|
178
|
+
return 0;
|
|
179
|
+
}
|
|
180
|
+
console.log(`\nLaunching ${launchAgent}...`);
|
|
181
|
+
return await new Promise((resolve) => {
|
|
182
|
+
const child = opts.spawn(cmd, args, { cwd, stdio: "inherit" });
|
|
183
|
+
child.on("error", (err) => {
|
|
184
|
+
console.error(`could not launch ${cmd}: ${err.message}`);
|
|
185
|
+
resolve(1);
|
|
186
|
+
});
|
|
187
|
+
child.on("exit", (code) => resolve(code ?? 0));
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
const HELP = `airdy — project setup wizard for coding agents
|
|
191
|
+
|
|
192
|
+
Usage: airdy [options]
|
|
193
|
+
|
|
194
|
+
Options:
|
|
195
|
+
-y, --yes non-interactive; requires --config
|
|
196
|
+
--config <file> answers JSON for non-interactive mode
|
|
197
|
+
--dry-run print the plan without writing
|
|
198
|
+
--copy copy skills into .claude/skills instead of symlinking
|
|
199
|
+
-v, --version print version
|
|
200
|
+
-h, --help print this help`;
|
|
201
|
+
export async function main(argv, cwd, deps = {}) {
|
|
202
|
+
const args = parseArgs(argv);
|
|
203
|
+
if (args.version) {
|
|
204
|
+
console.log(getVersion());
|
|
205
|
+
return 0;
|
|
206
|
+
}
|
|
207
|
+
if (args.help) {
|
|
208
|
+
console.log(HELP);
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
|
211
|
+
// shared interactivity predicate: conflict prompting and launch both use it
|
|
212
|
+
// (non-TTY without --yes must never open a clack prompt)
|
|
213
|
+
const interactive = !args.yes && (deps.isTTY ?? process.stdout.isTTY === true);
|
|
214
|
+
const spawnFn = deps.spawn ?? ((cmd, cmdArgs, opts) => nodeSpawn(cmd, cmdArgs, opts));
|
|
215
|
+
const ctx = await detectContext(cwd);
|
|
216
|
+
const userConfig = await loadUserConfig(deps.home);
|
|
217
|
+
const packages = mergePackages(userConfig);
|
|
218
|
+
const state = await readState(cwd);
|
|
219
|
+
const ownedPaths = state?.ownedPaths ?? [];
|
|
220
|
+
let answers;
|
|
221
|
+
if (args.yes) {
|
|
222
|
+
if (!args.configPath) {
|
|
223
|
+
console.error("--yes requires --config <file>");
|
|
224
|
+
return 1;
|
|
225
|
+
}
|
|
226
|
+
answers = await loadAnswersFromConfig(args.configPath, cwd, args.copy);
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
const prefill = state ? answersFromState(state) : {};
|
|
230
|
+
const result = await runWizard(ctx, userConfig, prefill);
|
|
231
|
+
if (!result) {
|
|
232
|
+
console.log("Cancelled.");
|
|
233
|
+
return 1;
|
|
234
|
+
}
|
|
235
|
+
answers = result;
|
|
236
|
+
}
|
|
237
|
+
for (const w of checkPreflight(answers, ctx))
|
|
238
|
+
console.warn(`! ${w}`);
|
|
239
|
+
const modules = await loadPresetModules(userConfig.presetSources);
|
|
240
|
+
const plan = await buildPlan(answers, ctx, {
|
|
241
|
+
projectName: basename(cwd),
|
|
242
|
+
packages,
|
|
243
|
+
modules,
|
|
244
|
+
ownedPaths,
|
|
245
|
+
});
|
|
246
|
+
console.log("\nPlanned changes:");
|
|
247
|
+
console.log(describePlan(plan));
|
|
248
|
+
if (args.dryRun) {
|
|
249
|
+
console.log("\n(dry run — nothing written)");
|
|
250
|
+
if (answers.launchAgent) {
|
|
251
|
+
// print-only path: never spawn under --dry-run
|
|
252
|
+
await launchOrPrint(answers.launchAgent, ctx, cwd, {
|
|
253
|
+
interactive: false,
|
|
254
|
+
spawn: spawnFn,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return 0;
|
|
258
|
+
}
|
|
259
|
+
if (!args.yes) {
|
|
260
|
+
const ok = await confirmPlan();
|
|
261
|
+
if (!ok) {
|
|
262
|
+
console.log("Aborted.");
|
|
263
|
+
return 1;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// three-way conflict resolution driven by per-path provenance (ownedPaths)
|
|
267
|
+
const { finalPlan, conflictSkipped } = await resolvePlanConflicts(plan, cwd, ownedPaths, interactive);
|
|
268
|
+
if (conflictSkipped.length > 0) {
|
|
269
|
+
console.log(`Skipped (existing files not created by airdy): ${conflictSkipped.join(", ")}`);
|
|
270
|
+
}
|
|
271
|
+
if (ctx.cli.git && answers.git.kind !== "skip") {
|
|
272
|
+
try {
|
|
273
|
+
await preGit(answers.git, cwd);
|
|
274
|
+
}
|
|
275
|
+
catch (err) {
|
|
276
|
+
console.warn(`! git step failed: ${err.message}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const report = await executePlan(finalPlan, cwd);
|
|
280
|
+
// Persist provenance: union prior ownedPaths with the destinations actually
|
|
281
|
+
// written this run (even on a partial/failed run, so a re-run recognises them).
|
|
282
|
+
const newOwned = nextOwnedPaths(ownedPaths, finalPlan, report);
|
|
283
|
+
const stateReport = await executePlan([stateAction(answers, newOwned)], cwd);
|
|
284
|
+
if (stateReport.failed) {
|
|
285
|
+
// Ownership loss is safe-direction (a re-run may just re-prompt for
|
|
286
|
+
// already-answered settings), so warn instead of failing the run.
|
|
287
|
+
console.warn(`! failed to persist ownership state: ${stateReport.failed.error} (re-run may re-prompt)`);
|
|
288
|
+
}
|
|
289
|
+
if (report.failed) {
|
|
290
|
+
console.error(`\nStopped: ${report.failed.error}`);
|
|
291
|
+
console.error(`Applied ${report.applied.length}; the rest were not applied. Re-run to continue.`);
|
|
292
|
+
return 1;
|
|
293
|
+
}
|
|
294
|
+
console.log(`\nDone. ${report.applied.length} written, ${report.skipped.length} unchanged.`);
|
|
295
|
+
if (answers.git.kind === "gh-create" && ctx.cli.gh) {
|
|
296
|
+
try {
|
|
297
|
+
await postGit(answers.git, cwd);
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
console.warn(`! gh repo create failed: ${err.message}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (answers.launchAgent) {
|
|
304
|
+
// spawn only when interactive AND the user did not choose print-only
|
|
305
|
+
const wantSpawn = interactive && (answers.autoLaunch ?? true);
|
|
306
|
+
return await launchOrPrint(answers.launchAgent, ctx, cwd, {
|
|
307
|
+
interactive: wantSpawn,
|
|
308
|
+
spawn: spawnFn,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
return 0;
|
|
312
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
function isPlainObject(v) {
|
|
2
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
3
|
+
}
|
|
4
|
+
export function deepMergeJson(base, patch) {
|
|
5
|
+
if (Array.isArray(base) && Array.isArray(patch)) {
|
|
6
|
+
const out = [...base];
|
|
7
|
+
for (const item of patch) {
|
|
8
|
+
const isPrimitive = item === null || typeof item !== "object";
|
|
9
|
+
if (isPrimitive) {
|
|
10
|
+
if (out.includes(item))
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
// structural equality via JSON.stringify; key-order sensitive, but
|
|
15
|
+
// same-source patches (e.g. re-running the same merge) are byte-identical
|
|
16
|
+
const serialized = JSON.stringify(item);
|
|
17
|
+
if (out.some((existing) => JSON.stringify(existing) === serialized))
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
out.push(item);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
if (isPlainObject(base) && isPlainObject(patch)) {
|
|
25
|
+
const out = { ...base };
|
|
26
|
+
for (const key of Object.keys(patch)) {
|
|
27
|
+
out[key] =
|
|
28
|
+
key in base ? deepMergeJson(base[key], patch[key]) : patch[key];
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
return patch;
|
|
33
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function mergeMarkdown(existing, marker, section) {
|
|
2
|
+
const start = `<!-- ${marker}:start -->`;
|
|
3
|
+
const end = `<!-- ${marker}:end -->`;
|
|
4
|
+
const block = `${start}\n${section}\n${end}`;
|
|
5
|
+
const pattern = new RegExp(`${escapeRegExp(start)}[\\s\\S]*?${escapeRegExp(end)}`);
|
|
6
|
+
if (pattern.test(existing)) {
|
|
7
|
+
return existing.replace(pattern, block);
|
|
8
|
+
}
|
|
9
|
+
const prefix = existing.length === 0 || existing.endsWith("\n")
|
|
10
|
+
? existing
|
|
11
|
+
: existing + "\n";
|
|
12
|
+
return `${prefix}${existing.length === 0 ? "" : "\n"}${block}\n`;
|
|
13
|
+
}
|
|
14
|
+
function escapeRegExp(s) {
|
|
15
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
16
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { sep } from "node:path";
|
|
2
|
+
import { renderProfile } from "../profiles/index.js";
|
|
3
|
+
import { getAdapter } from "../adapters/index.js";
|
|
4
|
+
import { builtinSuperpowersActions, moduleToActions, } from "../workflows/module.js";
|
|
5
|
+
import { resolveSkillRefs, skillRefToActions } from "../skills/package.js";
|
|
6
|
+
import { stateAction } from "../state/agentsetup.js";
|
|
7
|
+
function targetPath(a) {
|
|
8
|
+
if ("path" in a)
|
|
9
|
+
return a.path;
|
|
10
|
+
if ("to" in a)
|
|
11
|
+
return a.to;
|
|
12
|
+
return "";
|
|
13
|
+
}
|
|
14
|
+
function targetsClaudeDir(a) {
|
|
15
|
+
const p = targetPath(a);
|
|
16
|
+
return (p === ".claude" || p.startsWith(".claude/") || p.startsWith(".claude" + sep));
|
|
17
|
+
}
|
|
18
|
+
export async function buildPlan(answers, ctx, deps) {
|
|
19
|
+
const actions = [];
|
|
20
|
+
// 1. project profile
|
|
21
|
+
const profile = renderProfile(answers.projectType, {
|
|
22
|
+
projectName: deps.projectName,
|
|
23
|
+
});
|
|
24
|
+
actions.push({
|
|
25
|
+
type: "write-file",
|
|
26
|
+
path: "AGENTS.md",
|
|
27
|
+
mode: "create",
|
|
28
|
+
contents: profile.agentsMd,
|
|
29
|
+
});
|
|
30
|
+
if (answers.git.kind === "init" || answers.git.kind === "gh-create") {
|
|
31
|
+
actions.push({
|
|
32
|
+
type: "write-file",
|
|
33
|
+
path: ".gitignore",
|
|
34
|
+
mode: "create",
|
|
35
|
+
contents: profile.gitignore,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
// 2. workflows
|
|
39
|
+
for (const wf of answers.workflows) {
|
|
40
|
+
if (wf === "none")
|
|
41
|
+
continue;
|
|
42
|
+
if (wf === "superpowers") {
|
|
43
|
+
actions.push(...builtinSuperpowersActions());
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const mod = deps.modules[wf];
|
|
47
|
+
if (mod)
|
|
48
|
+
actions.push(...(await moduleToActions(mod)));
|
|
49
|
+
}
|
|
50
|
+
// 3. skills -> .agents/skills (must precede the claude-code .claude/skills link)
|
|
51
|
+
const refs = resolveSkillRefs(answers.skillPackages, deps.packages, answers.extraSkills);
|
|
52
|
+
for (const ref of refs) {
|
|
53
|
+
actions.push(...(await skillRefToActions(ref)));
|
|
54
|
+
}
|
|
55
|
+
// 4. adapters
|
|
56
|
+
for (const id of answers.agents) {
|
|
57
|
+
actions.push(...getAdapter(id).contribute(answers, ctx));
|
|
58
|
+
}
|
|
59
|
+
// 5. state file
|
|
60
|
+
actions.push(stateAction(answers, deps.ownedPaths));
|
|
61
|
+
// gate: only claude-code owns .claude/*
|
|
62
|
+
const hasClaude = answers.agents.includes("claude-code");
|
|
63
|
+
return hasClaude ? actions : actions.filter((a) => !targetsClaudeDir(a));
|
|
64
|
+
}
|
|
65
|
+
export function describePlan(actions) {
|
|
66
|
+
return actions
|
|
67
|
+
.map((a) => {
|
|
68
|
+
switch (a.type) {
|
|
69
|
+
case "write-file":
|
|
70
|
+
return ` write ${a.path} (${a.mode})`;
|
|
71
|
+
case "merge-markdown":
|
|
72
|
+
return ` merge ${a.path} [${a.marker}]`;
|
|
73
|
+
case "merge-json":
|
|
74
|
+
return ` merge ${a.path}`;
|
|
75
|
+
case "symlink":
|
|
76
|
+
return ` symlink ${a.path} -> ${a.target}`;
|
|
77
|
+
case "copy-dir":
|
|
78
|
+
return ` copy ${a.from} -> ${a.to}`;
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
.join("\n");
|
|
82
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { mergeMarkdown } from "../merge/markdown.js";
|
|
2
|
+
import { deepMergeJson } from "../merge/json.js";
|
|
3
|
+
const AIRDY_OWNED_FILES = new Set(["agentsetup.json"]);
|
|
4
|
+
// Ownership is per-path: a path airdy actually wrote in a prior run (recorded in
|
|
5
|
+
// agentsetup.json's ownedPaths) plus airdy's own state file. A blanket "state
|
|
6
|
+
// exists -> overwrite anything" rule silently clobbers files the user chose to
|
|
7
|
+
// keep, so it is gone.
|
|
8
|
+
export function isAirdyOwned(path, owned) {
|
|
9
|
+
return AIRDY_OWNED_FILES.has(path) || owned.has(path);
|
|
10
|
+
}
|
|
11
|
+
export function resolveConflict(action, existing, owned) {
|
|
12
|
+
// merge-* are additive per key/marker; symlink/copy-dir go through
|
|
13
|
+
// resolveLinkConflict which needs filesystem probes.
|
|
14
|
+
if (action.type !== "write-file")
|
|
15
|
+
return "proceed";
|
|
16
|
+
if (existing === null)
|
|
17
|
+
return "proceed";
|
|
18
|
+
if (isAirdyOwned(action.path, owned))
|
|
19
|
+
return "proceed";
|
|
20
|
+
return "needs-user";
|
|
21
|
+
}
|
|
22
|
+
// symlink / copy-dir destinations: a pre-existing, non-airdy path at the
|
|
23
|
+
// destination is a conflict on the FIRST run. An already-correct symlink or an
|
|
24
|
+
// airdy-owned path proceeds (idempotent re-run).
|
|
25
|
+
export function resolveLinkConflict(dest, desiredLink, // symlink target; null for copy-dir
|
|
26
|
+
probe, owned) {
|
|
27
|
+
if (!probe.exists)
|
|
28
|
+
return "proceed";
|
|
29
|
+
if (isAirdyOwned(dest, owned))
|
|
30
|
+
return "proceed";
|
|
31
|
+
if (desiredLink !== null && probe.currentLink === desiredLink)
|
|
32
|
+
return "proceed";
|
|
33
|
+
return "needs-user";
|
|
34
|
+
}
|
|
35
|
+
export function previewAction(action, existing) {
|
|
36
|
+
switch (action.type) {
|
|
37
|
+
case "write-file":
|
|
38
|
+
return action.contents;
|
|
39
|
+
case "merge-markdown":
|
|
40
|
+
return mergeMarkdown(existing, action.marker, action.section);
|
|
41
|
+
case "merge-json": {
|
|
42
|
+
let base = {};
|
|
43
|
+
try {
|
|
44
|
+
base = JSON.parse(existing);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
base = {};
|
|
48
|
+
}
|
|
49
|
+
return JSON.stringify(deepMergeJson(base, action.patch), null, 2) + "\n";
|
|
50
|
+
}
|
|
51
|
+
default:
|
|
52
|
+
return existing;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function applyChoice(action, choice, existing) {
|
|
56
|
+
if (choice === "skip")
|
|
57
|
+
return null;
|
|
58
|
+
if (action.type !== "write-file")
|
|
59
|
+
return action;
|
|
60
|
+
if (choice === "overwrite")
|
|
61
|
+
return { ...action, mode: "overwrite" };
|
|
62
|
+
// merge: append the planned contents after the existing ones
|
|
63
|
+
const sep = existing.endsWith("\n") ? "\n" : "\n\n";
|
|
64
|
+
return {
|
|
65
|
+
type: "write-file",
|
|
66
|
+
path: action.path,
|
|
67
|
+
mode: "overwrite",
|
|
68
|
+
contents: existing + sep + action.contents,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export function unifiedDiff(oldText, newText, path) {
|
|
72
|
+
const a = oldText.split("\n");
|
|
73
|
+
const b = newText.split("\n");
|
|
74
|
+
const m = a.length;
|
|
75
|
+
const n = b.length;
|
|
76
|
+
// LCS length table (small files only: config/docs, not source trees)
|
|
77
|
+
const lcs = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
78
|
+
for (let i = m - 1; i >= 0; i--) {
|
|
79
|
+
for (let j = n - 1; j >= 0; j--) {
|
|
80
|
+
lcs[i][j] =
|
|
81
|
+
a[i] === b[j]
|
|
82
|
+
? lcs[i + 1][j + 1] + 1
|
|
83
|
+
: Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const lines = [`--- ${path} (existing)`, `+++ ${path} (planned)`];
|
|
87
|
+
let i = 0;
|
|
88
|
+
let j = 0;
|
|
89
|
+
while (i < m && j < n) {
|
|
90
|
+
if (a[i] === b[j]) {
|
|
91
|
+
lines.push(` ${a[i]}`);
|
|
92
|
+
i++;
|
|
93
|
+
j++;
|
|
94
|
+
}
|
|
95
|
+
else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
|
96
|
+
lines.push(`- ${a[i]}`);
|
|
97
|
+
i++;
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
lines.push(`+ ${b[j]}`);
|
|
101
|
+
j++;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
while (i < m) {
|
|
105
|
+
lines.push(`- ${a[i]}`);
|
|
106
|
+
i++;
|
|
107
|
+
}
|
|
108
|
+
while (j < n) {
|
|
109
|
+
lines.push(`+ ${b[j]}`);
|
|
110
|
+
j++;
|
|
111
|
+
}
|
|
112
|
+
return lines.join("\n");
|
|
113
|
+
}
|