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
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile, symlink, cp, lstat, unlink, readlink, realpath, } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
3
|
+
import { mergeMarkdown } from "../merge/markdown.js";
|
|
4
|
+
import { deepMergeJson } from "../merge/json.js";
|
|
5
|
+
function label(action) {
|
|
6
|
+
if ("path" in action)
|
|
7
|
+
return action.path;
|
|
8
|
+
return `${action.from} -> ${action.to}`;
|
|
9
|
+
}
|
|
10
|
+
async function pathExists(p) {
|
|
11
|
+
try {
|
|
12
|
+
await lstat(p);
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// Every write DESTINATION must stay inside the target project directory.
|
|
20
|
+
// Resolves `relPath` against `root` and throws if it escapes lexically.
|
|
21
|
+
function resolveContained(root, relPath) {
|
|
22
|
+
const resolvedRoot = resolve(root);
|
|
23
|
+
const abs = resolve(resolvedRoot, relPath);
|
|
24
|
+
if (abs !== resolvedRoot && !abs.startsWith(resolvedRoot + sep)) {
|
|
25
|
+
throw new Error(`refusing to write outside the project root: "${relPath}" resolves to "${abs}", which is not inside "${resolvedRoot}"`);
|
|
26
|
+
}
|
|
27
|
+
return abs;
|
|
28
|
+
}
|
|
29
|
+
// Lexical containment is not enough: a symlinked ancestor (e.g. `.claude` -> a
|
|
30
|
+
// global config dir) can redirect writes outside the project. Resolve the
|
|
31
|
+
// destination's deepest EXISTING ancestor via realpath and require it to be
|
|
32
|
+
// inside realpath(root), so writes can never land outside the project tree.
|
|
33
|
+
async function assertRealpathContained(realRoot, abs) {
|
|
34
|
+
let dir = dirname(abs);
|
|
35
|
+
while (!(await pathExists(dir))) {
|
|
36
|
+
const parent = dirname(dir);
|
|
37
|
+
if (parent === dir)
|
|
38
|
+
break;
|
|
39
|
+
dir = parent;
|
|
40
|
+
}
|
|
41
|
+
const realDir = await realpath(dir);
|
|
42
|
+
if (realDir !== realRoot && !realDir.startsWith(realRoot + sep)) {
|
|
43
|
+
throw new Error(`refusing to write outside the project root via a symlinked ancestor: "${abs}" resolves under "${realDir}", which is not inside "${realRoot}"`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// The ancestor check above only covers the deepest EXISTING ancestor: if the
|
|
47
|
+
// destination itself already exists as a symlink (e.g. a cloned repo ships
|
|
48
|
+
// `.claude/settings.json -> ~/.claude/settings.json`), writes that go through
|
|
49
|
+
// `readFile`/`writeFile` on that path follow the link. Require the final path,
|
|
50
|
+
// if it is itself a symlink, to also realpath inside realRoot. Actions that
|
|
51
|
+
// replace the link itself (`symlink`) don't write through it and opt out.
|
|
52
|
+
async function assertFinalPathContained(realRoot, abs) {
|
|
53
|
+
let st;
|
|
54
|
+
try {
|
|
55
|
+
st = await lstat(abs);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return; // destination doesn't exist yet; nothing to follow
|
|
59
|
+
}
|
|
60
|
+
if (!st.isSymbolicLink())
|
|
61
|
+
return;
|
|
62
|
+
const real = await realpath(abs);
|
|
63
|
+
if (real !== realRoot && !real.startsWith(realRoot + sep)) {
|
|
64
|
+
throw new Error(`refusing to write outside the project root via a symlinked destination: "${abs}" resolves to "${real}", which is not inside "${realRoot}"`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async function containedDest(root, realRoot, relPath, opts = {}) {
|
|
68
|
+
const abs = resolveContained(root, relPath);
|
|
69
|
+
await assertRealpathContained(realRoot, abs);
|
|
70
|
+
if (opts.checkFinalPath !== false) {
|
|
71
|
+
await assertFinalPathContained(realRoot, abs);
|
|
72
|
+
}
|
|
73
|
+
return abs;
|
|
74
|
+
}
|
|
75
|
+
function isEnoent(err) {
|
|
76
|
+
return (typeof err === "object" &&
|
|
77
|
+
err !== null &&
|
|
78
|
+
err.code === "ENOENT");
|
|
79
|
+
}
|
|
80
|
+
async function applyAction(action, root, realRoot) {
|
|
81
|
+
switch (action.type) {
|
|
82
|
+
case "write-file": {
|
|
83
|
+
const abs = await containedDest(root, realRoot, action.path);
|
|
84
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
85
|
+
if (action.mode === "create" && (await pathExists(abs)))
|
|
86
|
+
return "skipped";
|
|
87
|
+
await writeFile(abs, action.contents);
|
|
88
|
+
return "applied";
|
|
89
|
+
}
|
|
90
|
+
case "merge-markdown": {
|
|
91
|
+
const abs = await containedDest(root, realRoot, action.path);
|
|
92
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
93
|
+
let existing = "";
|
|
94
|
+
try {
|
|
95
|
+
existing = await readFile(abs, "utf8");
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
if (!isEnoent(err))
|
|
99
|
+
throw err;
|
|
100
|
+
existing = "";
|
|
101
|
+
}
|
|
102
|
+
const merged = mergeMarkdown(existing, action.marker, action.section);
|
|
103
|
+
if (merged === existing)
|
|
104
|
+
return "skipped";
|
|
105
|
+
await writeFile(abs, merged);
|
|
106
|
+
return "applied";
|
|
107
|
+
}
|
|
108
|
+
case "merge-json": {
|
|
109
|
+
const abs = await containedDest(root, realRoot, action.path);
|
|
110
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
111
|
+
let existingText = null;
|
|
112
|
+
try {
|
|
113
|
+
existingText = await readFile(abs, "utf8");
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
if (!isEnoent(err))
|
|
117
|
+
throw err;
|
|
118
|
+
}
|
|
119
|
+
// JSON.parse errors on an existing (corrupt) file are intentionally
|
|
120
|
+
// NOT caught here: they must propagate to the fail-stop report rather
|
|
121
|
+
// than silently clobber the file with a fresh merge.
|
|
122
|
+
const base = existingText === null ? {} : JSON.parse(existingText);
|
|
123
|
+
const merged = deepMergeJson(base, action.patch);
|
|
124
|
+
if (existingText !== null &&
|
|
125
|
+
JSON.stringify(merged) === JSON.stringify(base)) {
|
|
126
|
+
return "skipped";
|
|
127
|
+
}
|
|
128
|
+
await writeFile(abs, JSON.stringify(merged, null, 2) + "\n");
|
|
129
|
+
return "applied";
|
|
130
|
+
}
|
|
131
|
+
case "symlink": {
|
|
132
|
+
const abs = await containedDest(root, realRoot, action.path, {
|
|
133
|
+
checkFinalPath: false,
|
|
134
|
+
});
|
|
135
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
136
|
+
if (await pathExists(abs)) {
|
|
137
|
+
try {
|
|
138
|
+
if ((await readlink(abs)) === action.target)
|
|
139
|
+
return "skipped";
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// not a symlink; fall through to replace
|
|
143
|
+
}
|
|
144
|
+
await unlink(abs);
|
|
145
|
+
}
|
|
146
|
+
await symlink(action.target, abs);
|
|
147
|
+
return "applied";
|
|
148
|
+
}
|
|
149
|
+
case "copy-dir": {
|
|
150
|
+
const absFrom = isAbsolute(action.from)
|
|
151
|
+
? action.from
|
|
152
|
+
: join(root, action.from);
|
|
153
|
+
const absTo = await containedDest(root, realRoot, action.to);
|
|
154
|
+
await mkdir(dirname(absTo), { recursive: true });
|
|
155
|
+
// Always reported "applied": comparing directory trees for idempotency
|
|
156
|
+
// isn't worth the complexity at MVP scale.
|
|
157
|
+
await cp(absFrom, absTo, { recursive: true });
|
|
158
|
+
return "applied";
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
export async function executePlan(actions, root) {
|
|
163
|
+
const report = { applied: [], skipped: [], failed: null };
|
|
164
|
+
// realpath the project root once; every write is checked against it.
|
|
165
|
+
const realRoot = await realpath(resolve(root));
|
|
166
|
+
for (const action of actions) {
|
|
167
|
+
try {
|
|
168
|
+
const result = await applyAction(action, root, realRoot);
|
|
169
|
+
if (result === "applied")
|
|
170
|
+
report.applied.push(label(action));
|
|
171
|
+
else
|
|
172
|
+
report.skipped.push(label(action));
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
report.failed = {
|
|
176
|
+
action,
|
|
177
|
+
error: err instanceof Error ? err.message : String(err),
|
|
178
|
+
};
|
|
179
|
+
return report;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return report;
|
|
183
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { renderTemplate } from "../merge/template.js";
|
|
2
|
+
import { AGENTS_SKELETON, PROFILES } from "./templates.js";
|
|
3
|
+
export function renderProfile(type, vars) {
|
|
4
|
+
const def = PROFILES[type];
|
|
5
|
+
const agentsMd = renderTemplate(AGENTS_SKELETON, {
|
|
6
|
+
projectName: vars.projectName,
|
|
7
|
+
typeLabel: def.typeLabel,
|
|
8
|
+
stackNotes: def.stackNotes,
|
|
9
|
+
});
|
|
10
|
+
return {
|
|
11
|
+
agentsMd,
|
|
12
|
+
gitignore: def.gitignore,
|
|
13
|
+
suggestedPackages: def.suggestedPackages,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export const AGENTS_SKELETON = `# {{projectName}}
|
|
2
|
+
|
|
3
|
+
> Canonical agent instructions for this project. CLAUDE.md is only a pointer — edit **this** file.
|
|
4
|
+
|
|
5
|
+
## Project Type
|
|
6
|
+
|
|
7
|
+
{{typeLabel}}
|
|
8
|
+
|
|
9
|
+
## Stack & Conventions
|
|
10
|
+
|
|
11
|
+
{{stackNotes}}
|
|
12
|
+
|
|
13
|
+
## Workflows
|
|
14
|
+
`;
|
|
15
|
+
const GITIGNORE_BASE = `node_modules/
|
|
16
|
+
dist/
|
|
17
|
+
build/
|
|
18
|
+
.env
|
|
19
|
+
.env.*
|
|
20
|
+
*.log
|
|
21
|
+
.DS_Store
|
|
22
|
+
|
|
23
|
+
# airdy: keep agent config in version control, ignore only local overrides
|
|
24
|
+
.claude/*
|
|
25
|
+
!.claude/settings.json
|
|
26
|
+
!.claude/hooks/
|
|
27
|
+
!.claude/scripts/
|
|
28
|
+
!.claude/skills
|
|
29
|
+
.claude/settings.local.json
|
|
30
|
+
`;
|
|
31
|
+
export const PROFILES = {
|
|
32
|
+
web: {
|
|
33
|
+
typeLabel: "Web frontend / full-stack application.",
|
|
34
|
+
stackNotes: "- Prefer the framework's official conventions.\n- Keep components small and typed.",
|
|
35
|
+
suggestedPackages: ["web"],
|
|
36
|
+
gitignore: GITIGNORE_BASE + "\n.next/\n.vite/\ncoverage/\n",
|
|
37
|
+
},
|
|
38
|
+
"mobile-app": {
|
|
39
|
+
typeLabel: "Mobile application.",
|
|
40
|
+
stackNotes: "- Follow platform HIG / Material guidelines.\n- Keep view state explicit.",
|
|
41
|
+
suggestedPackages: ["mobile"],
|
|
42
|
+
gitignore: GITIGNORE_BASE + "\nDerivedData/\n*.xcuserstate\n",
|
|
43
|
+
},
|
|
44
|
+
backend: {
|
|
45
|
+
typeLabel: "Backend service / API.",
|
|
46
|
+
stackNotes: "- Layer domain logic away from transport.\n- Validate all external input.",
|
|
47
|
+
suggestedPackages: ["backend"],
|
|
48
|
+
gitignore: GITIGNORE_BASE + "\ncoverage/\n*.pid\n",
|
|
49
|
+
},
|
|
50
|
+
desktop: {
|
|
51
|
+
typeLabel: "Desktop application.",
|
|
52
|
+
stackNotes: "- Keep platform-specific code isolated.",
|
|
53
|
+
suggestedPackages: ["desktop"],
|
|
54
|
+
gitignore: GITIGNORE_BASE + "\nout/\n",
|
|
55
|
+
},
|
|
56
|
+
"oss-fork": {
|
|
57
|
+
typeLabel: "Fork of an upstream open-source project.",
|
|
58
|
+
stackNotes: "- Preserve upstream conventions.\n- Keep local changes minimal and well-documented.",
|
|
59
|
+
suggestedPackages: [],
|
|
60
|
+
gitignore: GITIGNORE_BASE,
|
|
61
|
+
},
|
|
62
|
+
minimal: {
|
|
63
|
+
typeLabel: "Minimal / unspecified.",
|
|
64
|
+
stackNotes: "- No assumptions; the launched agent will establish conventions.",
|
|
65
|
+
suggestedPackages: [],
|
|
66
|
+
gitignore: GITIGNORE_BASE,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { select, isCancel } from "@clack/prompts";
|
|
2
|
+
export async function promptConflict(path, diff) {
|
|
3
|
+
console.log(`\n${path} already exists and was not created by airdy. Planned change:\n`);
|
|
4
|
+
console.log(diff);
|
|
5
|
+
const choice = await select({
|
|
6
|
+
message: `How should ${path} be handled?`,
|
|
7
|
+
options: [
|
|
8
|
+
{ value: "skip", label: "skip — keep the existing file" },
|
|
9
|
+
{
|
|
10
|
+
value: "overwrite",
|
|
11
|
+
label: "overwrite — replace with the planned content",
|
|
12
|
+
},
|
|
13
|
+
{ value: "merge", label: "merge — append the planned content" },
|
|
14
|
+
],
|
|
15
|
+
initialValue: "skip",
|
|
16
|
+
});
|
|
17
|
+
if (isCancel(choice))
|
|
18
|
+
return "skip";
|
|
19
|
+
return choice;
|
|
20
|
+
}
|
|
21
|
+
// symlink / copy-dir destinations have no text diff to merge — a plain
|
|
22
|
+
// keep-or-replace decision. Returns true to overwrite, false to skip.
|
|
23
|
+
export async function promptOverwritePath(path) {
|
|
24
|
+
console.log(`\n${path} already exists and was not created by airdy.`);
|
|
25
|
+
const choice = await select({
|
|
26
|
+
message: `How should ${path} be handled?`,
|
|
27
|
+
options: [
|
|
28
|
+
{ value: "skip", label: "skip — keep the existing path" },
|
|
29
|
+
{
|
|
30
|
+
value: "overwrite",
|
|
31
|
+
label: "overwrite — replace it with airdy's version",
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
initialValue: "skip",
|
|
35
|
+
});
|
|
36
|
+
if (isCancel(choice))
|
|
37
|
+
return false;
|
|
38
|
+
return choice === "overwrite";
|
|
39
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { intro, outro, select, multiselect, confirm, text, isCancel, cancel, } from "@clack/prompts";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { allAdapters } from "../adapters/index.js";
|
|
4
|
+
import { mergePackages } from "../config/user.js";
|
|
5
|
+
import { PROFILES } from "../profiles/templates.js";
|
|
6
|
+
const PROJECT_TYPES = [
|
|
7
|
+
{ value: "web", label: "Web frontend / full-stack" },
|
|
8
|
+
{ value: "mobile-app", label: "Mobile app" },
|
|
9
|
+
{ value: "backend", label: "Backend service / API" },
|
|
10
|
+
{ value: "desktop", label: "Desktop app" },
|
|
11
|
+
{ value: "oss-fork", label: "Open-source fork" },
|
|
12
|
+
{ value: "minimal", label: "Minimal / unspecified" },
|
|
13
|
+
];
|
|
14
|
+
const WORKFLOWS = [
|
|
15
|
+
{
|
|
16
|
+
value: "superpowers",
|
|
17
|
+
label: "superpowers",
|
|
18
|
+
hint: "brainstorm -> plan -> TDD -> review",
|
|
19
|
+
},
|
|
20
|
+
{ value: "verify-loop", label: "verify-loop", hint: "needs a preset source" },
|
|
21
|
+
{ value: "ship-gate", label: "ship-gate", hint: "needs a preset source" },
|
|
22
|
+
{ value: "none", label: "none" },
|
|
23
|
+
];
|
|
24
|
+
// Initial skill-package selection: a re-run restores the prior choice; a fresh
|
|
25
|
+
// run pre-selects the project type's suggested packages, filtered to the ones
|
|
26
|
+
// actually offered (per spec: project type pre-selects packages).
|
|
27
|
+
export function initialSkillPackages(prefill, projectType, offered) {
|
|
28
|
+
if (prefill.skillPackages)
|
|
29
|
+
return prefill.skillPackages;
|
|
30
|
+
const suggested = PROFILES[projectType]?.suggestedPackages ?? [];
|
|
31
|
+
return suggested.filter((name) => name in offered);
|
|
32
|
+
}
|
|
33
|
+
export function parseExtraSkillUrls(input) {
|
|
34
|
+
return input
|
|
35
|
+
.split(",")
|
|
36
|
+
.map((s) => s.trim())
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
.map((token) => {
|
|
39
|
+
const clean = token.replace(/^https?:\/\//, "").replace(/\.git$/, "");
|
|
40
|
+
const parts = clean.split("/");
|
|
41
|
+
const repo = parts.slice(0, 3).join("/");
|
|
42
|
+
const subdir = parts.length > 3 ? parts.slice(3).join("/") : undefined;
|
|
43
|
+
return { source: "git", repo, subdir };
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
async function promptGit(ctx, prefill) {
|
|
47
|
+
const kind = await select({
|
|
48
|
+
message: "Git setup?",
|
|
49
|
+
options: [
|
|
50
|
+
{ value: "init", label: "git init (branch main)" },
|
|
51
|
+
{ value: "clone", label: "git clone a URL" },
|
|
52
|
+
{ value: "gh-create", label: "gh repo create" },
|
|
53
|
+
{ value: "skip", label: "skip" },
|
|
54
|
+
],
|
|
55
|
+
initialValue: prefill.git?.kind ?? (ctx.isGitRepo ? "skip" : "init"),
|
|
56
|
+
});
|
|
57
|
+
if (isCancel(kind))
|
|
58
|
+
return null;
|
|
59
|
+
if (kind === "init") {
|
|
60
|
+
const branch = await text({
|
|
61
|
+
message: "Branch name?",
|
|
62
|
+
defaultValue: "main",
|
|
63
|
+
placeholder: "main",
|
|
64
|
+
});
|
|
65
|
+
if (isCancel(branch))
|
|
66
|
+
return null;
|
|
67
|
+
return { kind: "init", branch: branch || "main" };
|
|
68
|
+
}
|
|
69
|
+
if (kind === "clone") {
|
|
70
|
+
const url = await text({
|
|
71
|
+
message: "Repository URL?",
|
|
72
|
+
placeholder: "https://github.com/owner/repo.git",
|
|
73
|
+
});
|
|
74
|
+
if (isCancel(url))
|
|
75
|
+
return null;
|
|
76
|
+
return { kind: "clone", url: url };
|
|
77
|
+
}
|
|
78
|
+
if (kind === "gh-create") {
|
|
79
|
+
const name = await text({
|
|
80
|
+
message: "New repo name?",
|
|
81
|
+
defaultValue: basename(ctx.cwd),
|
|
82
|
+
});
|
|
83
|
+
if (isCancel(name))
|
|
84
|
+
return null;
|
|
85
|
+
const vis = await select({
|
|
86
|
+
message: "Visibility?",
|
|
87
|
+
options: [
|
|
88
|
+
{ value: "private", label: "private" },
|
|
89
|
+
{ value: "public", label: "public" },
|
|
90
|
+
],
|
|
91
|
+
initialValue: "private",
|
|
92
|
+
});
|
|
93
|
+
if (isCancel(vis))
|
|
94
|
+
return null;
|
|
95
|
+
return {
|
|
96
|
+
kind: "gh-create",
|
|
97
|
+
name: name,
|
|
98
|
+
visibility: vis,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return { kind: "skip" };
|
|
102
|
+
}
|
|
103
|
+
export async function runWizard(ctx, userConfig, prefill) {
|
|
104
|
+
intro("airdy — project setup");
|
|
105
|
+
const agents = await multiselect({
|
|
106
|
+
message: "Which coding agent(s) to configure?",
|
|
107
|
+
options: allAdapters.map((a) => ({ value: a.id, label: a.id })),
|
|
108
|
+
initialValues: prefill.agents ?? ["claude-code"],
|
|
109
|
+
required: true,
|
|
110
|
+
});
|
|
111
|
+
if (isCancel(agents)) {
|
|
112
|
+
cancel("Cancelled");
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const projectType = await select({
|
|
116
|
+
message: "Project type?",
|
|
117
|
+
options: PROJECT_TYPES,
|
|
118
|
+
initialValue: prefill.projectType ?? "minimal",
|
|
119
|
+
});
|
|
120
|
+
if (isCancel(projectType)) {
|
|
121
|
+
cancel("Cancelled");
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
const workflows = await multiselect({
|
|
125
|
+
message: "Development workflows?",
|
|
126
|
+
options: WORKFLOWS,
|
|
127
|
+
initialValues: prefill.workflows ?? ["none"],
|
|
128
|
+
required: true,
|
|
129
|
+
});
|
|
130
|
+
if (isCancel(workflows)) {
|
|
131
|
+
cancel("Cancelled");
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
const packages = mergePackages(userConfig);
|
|
135
|
+
const skillPackages = await multiselect({
|
|
136
|
+
message: "Skill packages? (space to toggle, enter to skip)",
|
|
137
|
+
options: Object.entries(packages).map(([name, p]) => ({
|
|
138
|
+
value: name,
|
|
139
|
+
label: name,
|
|
140
|
+
hint: p.description,
|
|
141
|
+
})),
|
|
142
|
+
initialValues: initialSkillPackages(prefill, projectType, packages),
|
|
143
|
+
required: false,
|
|
144
|
+
});
|
|
145
|
+
if (isCancel(skillPackages)) {
|
|
146
|
+
cancel("Cancelled");
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
const extraUrls = await text({
|
|
150
|
+
message: "Extra skills by git URL (comma-separated, optional; git installs land post-MVP)",
|
|
151
|
+
placeholder: "github.com/owner/repo/skills/foo",
|
|
152
|
+
defaultValue: "",
|
|
153
|
+
});
|
|
154
|
+
if (isCancel(extraUrls)) {
|
|
155
|
+
cancel("Cancelled");
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
const git = await promptGit(ctx, prefill);
|
|
159
|
+
if (!git) {
|
|
160
|
+
cancel("Cancelled");
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
const agentList = agents;
|
|
164
|
+
let launchAgent = agentList[0] ?? null;
|
|
165
|
+
let autoLaunch = true;
|
|
166
|
+
const PRINT_ONLY = "__print__";
|
|
167
|
+
if (agentList.length > 1) {
|
|
168
|
+
const launchOptions = [
|
|
169
|
+
...agentList.map((id) => ({
|
|
170
|
+
value: id,
|
|
171
|
+
label: `launch ${id}`,
|
|
172
|
+
})),
|
|
173
|
+
{ value: PRINT_ONLY, label: "print the command and exit" },
|
|
174
|
+
];
|
|
175
|
+
const chosen = await select({
|
|
176
|
+
message: "Launch an agent now?",
|
|
177
|
+
options: launchOptions,
|
|
178
|
+
initialValue: (prefill.launchAgent ?? agentList[0]),
|
|
179
|
+
});
|
|
180
|
+
if (isCancel(chosen)) {
|
|
181
|
+
cancel("Cancelled");
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
if (chosen === PRINT_ONLY) {
|
|
185
|
+
autoLaunch = false; // keep launchAgent as the first agent for the printout
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
launchAgent = chosen;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
else if (launchAgent) {
|
|
192
|
+
const go = await confirm({ message: `Launch ${launchAgent} now?` });
|
|
193
|
+
if (isCancel(go)) {
|
|
194
|
+
cancel("Cancelled");
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
autoLaunch = go;
|
|
198
|
+
}
|
|
199
|
+
outro("Choices captured.");
|
|
200
|
+
return {
|
|
201
|
+
agents: agentList,
|
|
202
|
+
launchAgent,
|
|
203
|
+
projectType: projectType,
|
|
204
|
+
workflows: workflows,
|
|
205
|
+
skillPackages: skillPackages,
|
|
206
|
+
extraSkills: parseExtraSkillUrls(extraUrls),
|
|
207
|
+
git,
|
|
208
|
+
linkMode: prefill.linkMode ?? userConfig.preferences.linkMode ?? "link",
|
|
209
|
+
autoLaunch,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
export async function confirmPlan() {
|
|
213
|
+
const ok = await confirm({ message: "Apply these changes?" });
|
|
214
|
+
if (isCancel(ok))
|
|
215
|
+
return false;
|
|
216
|
+
return ok;
|
|
217
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join, basename } from "node:path";
|
|
3
|
+
function expandHome(p) {
|
|
4
|
+
return p.startsWith("~") ? join(homedir(), p.slice(1)) : p;
|
|
5
|
+
}
|
|
6
|
+
function identity(ref) {
|
|
7
|
+
return [ref.source, ref.path, ref.repo, ref.subdir, ref.id]
|
|
8
|
+
.filter(Boolean)
|
|
9
|
+
.join("|");
|
|
10
|
+
}
|
|
11
|
+
export function resolveSkillRefs(packageNames, packages, extraSkills) {
|
|
12
|
+
const all = [];
|
|
13
|
+
for (const name of packageNames) {
|
|
14
|
+
const pkg = packages[name];
|
|
15
|
+
if (pkg)
|
|
16
|
+
all.push(...pkg.skills);
|
|
17
|
+
}
|
|
18
|
+
all.push(...extraSkills);
|
|
19
|
+
const seen = new Set();
|
|
20
|
+
const out = [];
|
|
21
|
+
for (const ref of all) {
|
|
22
|
+
const key = identity(ref);
|
|
23
|
+
if (seen.has(key))
|
|
24
|
+
continue;
|
|
25
|
+
seen.add(key);
|
|
26
|
+
out.push(ref);
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
export function skillName(ref) {
|
|
31
|
+
if (ref.source === "local" && ref.path)
|
|
32
|
+
return basename(expandHome(ref.path));
|
|
33
|
+
if (ref.source === "git" && ref.subdir)
|
|
34
|
+
return basename(ref.subdir);
|
|
35
|
+
if (ref.source === "git" && ref.repo)
|
|
36
|
+
return basename(ref.repo);
|
|
37
|
+
if (ref.source === "marketplace" && ref.id)
|
|
38
|
+
return ref.id.split("/").pop();
|
|
39
|
+
throw new Error(`Cannot derive a skill name from ref: ${JSON.stringify(ref)}`);
|
|
40
|
+
}
|
|
41
|
+
export async function skillRefToActions(ref) {
|
|
42
|
+
const name = skillName(ref);
|
|
43
|
+
const dest = join(".agents", "skills", name);
|
|
44
|
+
if (ref.source === "local") {
|
|
45
|
+
return [{ type: "copy-dir", from: expandHome(ref.path), to: dest }];
|
|
46
|
+
}
|
|
47
|
+
if (ref.source === "git") {
|
|
48
|
+
console.warn(`airdy: git-source skill installs (${name} from ${ref.repo}) are coming post-MVP; skipped.`);
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
console.warn(`airdy: marketplace skills are not yet supported (${ref.id}); skipped. Use a git URL instead.`);
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
const STATE_VERSION = 1;
|
|
4
|
+
export async function readState(root) {
|
|
5
|
+
try {
|
|
6
|
+
const raw = await readFile(join(root, "agentsetup.json"), "utf8");
|
|
7
|
+
const parsed = JSON.parse(raw);
|
|
8
|
+
// Old state files predate ownedPaths; default it so they still load.
|
|
9
|
+
return { ...parsed, ownedPaths: parsed.ownedPaths ?? [] };
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function stateAction(answers, ownedPaths) {
|
|
16
|
+
const state = {
|
|
17
|
+
version: STATE_VERSION,
|
|
18
|
+
agents: answers.agents,
|
|
19
|
+
launchAgent: answers.launchAgent,
|
|
20
|
+
projectType: answers.projectType,
|
|
21
|
+
workflows: answers.workflows,
|
|
22
|
+
skillPackages: answers.skillPackages,
|
|
23
|
+
linkMode: answers.linkMode,
|
|
24
|
+
ownedPaths,
|
|
25
|
+
};
|
|
26
|
+
return {
|
|
27
|
+
type: "write-file",
|
|
28
|
+
path: "agentsetup.json",
|
|
29
|
+
mode: "overwrite",
|
|
30
|
+
contents: JSON.stringify(state, null, 2) + "\n",
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function answersFromState(state) {
|
|
34
|
+
return {
|
|
35
|
+
agents: state.agents,
|
|
36
|
+
launchAgent: state.launchAgent,
|
|
37
|
+
projectType: state.projectType,
|
|
38
|
+
workflows: state.workflows,
|
|
39
|
+
skillPackages: state.skillPackages,
|
|
40
|
+
linkMode: state.linkMode,
|
|
41
|
+
};
|
|
42
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|