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/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,3 @@
1
+ export function renderTemplate(tpl, vars) {
2
+ return tpl.replace(/\{\{(\w+)\}\}/g, (_m, key) => vars[key] ?? "");
3
+ }
@@ -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
+ }