nestjs-boost 0.0.0-stage → 1.2.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 +68 -0
- package/LICENSE +21 -0
- package/NOTICE.md +6 -0
- package/README.md +168 -2
- package/bin/nestjs-boost.mjs +148 -0
- package/guidelines/core.md +64 -0
- package/lib/agents.mjs +137 -0
- package/lib/installer.mjs +450 -0
- package/lib/prompt.mjs +131 -0
- package/lib/setup.mjs +60 -0
- package/manifest.json +676 -0
- package/package.json +67 -6
- package/scripts/postinstall.mjs +49 -0
- package/skills/nestjs-ai/SKILL.md +145 -0
- package/skills/nestjs-ai/references/ai-reference.md +143 -0
- package/skills/nestjs-architecture/SKILL.md +83 -0
- package/skills/nestjs-architecture/references/dynamic-modules.md +92 -0
- package/skills/nestjs-architecture/references/scopes-lifecycle.md +98 -0
- package/skills/nestjs-architecture/references/troubleshooting.md +77 -0
- package/skills/nestjs-authentication/SKILL.md +115 -0
- package/skills/nestjs-authentication/references/flows.md +180 -0
- package/skills/nestjs-authorization/SKILL.md +104 -0
- package/skills/nestjs-caching/SKILL.md +109 -0
- package/skills/nestjs-cli-tooling/SKILL.md +94 -0
- package/skills/nestjs-cli-tooling/references/nest-cli-json.md +91 -0
- package/skills/nestjs-configuration/SKILL.md +109 -0
- package/skills/nestjs-cqrs/SKILL.md +124 -0
- package/skills/nestjs-database-drizzle/SKILL.md +117 -0
- package/skills/nestjs-database-mikro-orm/SKILL.md +109 -0
- package/skills/nestjs-database-mongoose/SKILL.md +124 -0
- package/skills/nestjs-database-prisma/SKILL.md +126 -0
- package/skills/nestjs-database-sequelize/SKILL.md +88 -0
- package/skills/nestjs-database-typeorm/SKILL.md +110 -0
- package/skills/nestjs-deployment/SKILL.md +133 -0
- package/skills/nestjs-deployment/references/serverless-and-systemd.md +133 -0
- package/skills/nestjs-events/SKILL.md +78 -0
- package/skills/nestjs-file-storage/SKILL.md +111 -0
- package/skills/nestjs-file-storage/references/storage-reference.md +82 -0
- package/skills/nestjs-graphql/SKILL.md +119 -0
- package/skills/nestjs-graphql/references/federation.md +110 -0
- package/skills/nestjs-graphql/references/schema-features.md +133 -0
- package/skills/nestjs-health-checks/SKILL.md +114 -0
- package/skills/nestjs-http-client/SKILL.md +118 -0
- package/skills/nestjs-http-client/references/http-client-reference.md +116 -0
- package/skills/nestjs-http-platform/SKILL.md +104 -0
- package/skills/nestjs-http-platform/references/adapters.md +88 -0
- package/skills/nestjs-http-web-features/SKILL.md +113 -0
- package/skills/nestjs-http-web-features/references/uploads-streaming.md +108 -0
- package/skills/nestjs-i18n/SKILL.md +113 -0
- package/skills/nestjs-idempotency/SKILL.md +112 -0
- package/skills/nestjs-locks/SKILL.md +108 -0
- package/skills/nestjs-logging/SKILL.md +123 -0
- package/skills/nestjs-mail/SKILL.md +125 -0
- package/skills/nestjs-mail/references/mail-reference.md +90 -0
- package/skills/nestjs-mcp-server/SKILL.md +152 -0
- package/skills/nestjs-mcp-server/references/mcp-reference.md +146 -0
- package/skills/nestjs-microservices/SKILL.md +108 -0
- package/skills/nestjs-microservices/references/transports.md +117 -0
- package/skills/nestjs-observability/SKILL.md +108 -0
- package/skills/nestjs-observability/references/apm-and-otel.md +106 -0
- package/skills/nestjs-openapi/SKILL.md +90 -0
- package/skills/nestjs-openapi/references/swagger-reference.md +180 -0
- package/skills/nestjs-outbox/SKILL.md +119 -0
- package/skills/nestjs-outbox/references/outbox-reference.md +74 -0
- package/skills/nestjs-passport-auth/SKILL.md +125 -0
- package/skills/nestjs-queues/SKILL.md +118 -0
- package/skills/nestjs-queues/references/bull-legacy.md +75 -0
- package/skills/nestjs-request-pipeline/SKILL.md +146 -0
- package/skills/nestjs-request-pipeline/references/filters-interceptors.md +114 -0
- package/skills/nestjs-request-pipeline/references/pipes-decorators.md +80 -0
- package/skills/nestjs-resilience/SKILL.md +116 -0
- package/skills/nestjs-resilience/references/resilience-reference.md +74 -0
- package/skills/nestjs-scheduling/SKILL.md +100 -0
- package/skills/nestjs-security-hardening/SKILL.md +99 -0
- package/skills/nestjs-security-hardening/references/throttler-advanced.md +78 -0
- package/skills/nestjs-standalone-apps/SKILL.md +78 -0
- package/skills/nestjs-testing/SKILL.md +118 -0
- package/skills/nestjs-testing/references/advanced-testing.md +62 -0
- package/skills/nestjs-upgrading/SKILL.md +82 -0
- package/skills/nestjs-upgrading/references/esm-switch.md +114 -0
- package/skills/nestjs-validation-serialization/SKILL.md +124 -0
- package/skills/nestjs-validation-serialization/references/validation-options.md +84 -0
- package/skills/nestjs-webhooks/SKILL.md +116 -0
- package/skills/nestjs-webhooks/references/webhooks-reference.md +132 -0
- package/skills/nestjs-websockets/SKILL.md +108 -0
- package/skills/nestjs-workflows/SKILL.md +123 -0
- package/skills/nestjs-workflows/references/workflows-reference.md +85 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
// nestjs-boost core: detect a NestJS project, choose the matching Agent Skills, and sync them
|
|
2
|
+
// (plus a guidelines block) into the folders each coding agent reads.
|
|
3
|
+
//
|
|
4
|
+
// Safety rules, enforced here so the CLI and the postinstall hook share them:
|
|
5
|
+
// - Writes only inside the project root, only to skill folders, guideline files and the lock file.
|
|
6
|
+
// - Edits guideline files only between the marker comments; everything else is kept.
|
|
7
|
+
// - Never overwrites or deletes a skill folder the user changed (tracked by content hash in
|
|
8
|
+
// the lock file) unless `force` is set. Customize through `.ai/skills/<name>/` instead.
|
|
9
|
+
// - Never follows symbolic links: a link anywhere on a path it writes or deletes is refused, and
|
|
10
|
+
// links inside skill folders, `.ai/skills` and `.ai/guidelines` are skipped.
|
|
11
|
+
// - Trusts nothing in the lock file: only skill-folder and guideline paths it could have written
|
|
12
|
+
// are honoured, so a tampered lock file can't point a delete at other project files.
|
|
13
|
+
// - No network access, no child processes, no dependencies.
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { AGENTS, AGENT_IDS, ALL_INSTRUCTION_FILES, ALL_SKILL_DIRS, DEFAULT_AGENTS, instructionFiles, planSkillDirs } from './agents.mjs';
|
|
19
|
+
|
|
20
|
+
export { AGENTS, AGENT_IDS, AGENT_LIST, DEFAULT_AGENTS, planSkillDirs } from './agents.mjs';
|
|
21
|
+
export const PACK_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
export const MANIFEST = JSON.parse(readFileSync(join(PACK_DIR, 'manifest.json'), 'utf8'));
|
|
23
|
+
export const PACK_VERSION = JSON.parse(readFileSync(join(PACK_DIR, 'package.json'), 'utf8')).version;
|
|
24
|
+
const START = '<!-- nestjs-boost:start -->';
|
|
25
|
+
const END = '<!-- nestjs-boost:end -->';
|
|
26
|
+
export const LOCK = '.ai/nestjs-boost.lock.json';
|
|
27
|
+
const CONFIG_FILE = '.ai/nestjs-boost.json';
|
|
28
|
+
|
|
29
|
+
export class BoostError extends Error {}
|
|
30
|
+
const fail = (msg) => { throw new BoostError(msg); };
|
|
31
|
+
const readJson = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
|
|
32
|
+
const toList = (v) => (Array.isArray(v) ? v : v ? String(v).split(',') : []).map((s) => String(s).trim()).filter(Boolean);
|
|
33
|
+
|
|
34
|
+
const NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
35
|
+
// Lock-file allowlist, derived from the agent registry: `<some agent's skills dir>/<skill name>`.
|
|
36
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
37
|
+
const SKILL_PATH_RE = new RegExp(`^(${ALL_SKILL_DIRS.map(escapeRe).join('|')})/[a-z0-9]+(-[a-z0-9]+)*$`);
|
|
38
|
+
const GUIDELINE_FILES = new Set(ALL_INSTRUCTION_FILES);
|
|
39
|
+
const lstat = (p) => { try { return lstatSync(p); } catch { return null; } };
|
|
40
|
+
|
|
41
|
+
// Returns the first symbolic link (or Windows junction) between root (exclusive) and full, if any.
|
|
42
|
+
function linkOnPath(root, full) {
|
|
43
|
+
let cur = root;
|
|
44
|
+
for (const part of relative(root, full).split(sep)) {
|
|
45
|
+
cur = join(cur, part);
|
|
46
|
+
const st = lstat(cur);
|
|
47
|
+
if (!st) return null; // the rest doesn't exist yet
|
|
48
|
+
if (st.isSymbolicLink()) return cur;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Resolve a path strictly below the project root, refusing symbolic links on the way, so
|
|
54
|
+
// writes and deletes can't be redirected outside the project.
|
|
55
|
+
function inside(root, p) {
|
|
56
|
+
const full = resolve(root, p);
|
|
57
|
+
if (!full.startsWith(root + sep)) fail(`refusing to touch a path outside the project: ${p}`);
|
|
58
|
+
const link = linkOnPath(root, full);
|
|
59
|
+
if (link) fail(`refusing to follow a symbolic link: ${relative(root, link).split(sep).join('/')}`);
|
|
60
|
+
return full;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// A real (non-symlinked) directory below root, or null.
|
|
64
|
+
function safeDir(root, rel) {
|
|
65
|
+
const full = resolve(root, rel);
|
|
66
|
+
if (!full.startsWith(root + sep) || linkOnPath(root, full)) return null;
|
|
67
|
+
return lstat(full)?.isDirectory() ? full : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Stable content hash of a directory (relative paths + bytes), used to spot user edits.
|
|
71
|
+
// Symbolic links and special files are skipped (and never copied), so a link can't pull outside
|
|
72
|
+
// content in or make the walk loop. Anything that isn't a directory gets a sentinel hash, so it is
|
|
73
|
+
// treated as user content and left alone.
|
|
74
|
+
export function hashDir(dir) {
|
|
75
|
+
const st = lstat(dir);
|
|
76
|
+
if (!st) return null;
|
|
77
|
+
if (!st.isDirectory()) return 'not-a-directory';
|
|
78
|
+
const h = createHash('sha256');
|
|
79
|
+
const walk = (d) => {
|
|
80
|
+
for (const ent of readdirSync(d, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
|
|
81
|
+
const full = join(d, ent.name);
|
|
82
|
+
if (ent.isDirectory()) walk(full);
|
|
83
|
+
else if (ent.isFile()) h.update(relative(dir, full).split(sep).join('/')).update('\0').update(readFileSync(full)).update('\0');
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
walk(dir);
|
|
87
|
+
return h.digest('hex').slice(0, 16);
|
|
88
|
+
}
|
|
89
|
+
const noLinks = (src) => !lstatSync(src).isSymbolicLink();
|
|
90
|
+
|
|
91
|
+
// Read the lock file, keeping only entries nestjs-boost itself could have written.
|
|
92
|
+
export function readLock(lockPath) {
|
|
93
|
+
const raw = readJson(lockPath);
|
|
94
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
95
|
+
const ignored = [];
|
|
96
|
+
const files = {};
|
|
97
|
+
for (const [rel, hash] of Object.entries(raw.files && typeof raw.files === 'object' ? raw.files : {})) {
|
|
98
|
+
if (SKILL_PATH_RE.test(rel) && typeof hash === 'string') files[rel] = hash;
|
|
99
|
+
else ignored.push(rel);
|
|
100
|
+
}
|
|
101
|
+
const guidelineList = (v) => (Array.isArray(v) ? v : []).filter((f) => {
|
|
102
|
+
const ok = GUIDELINE_FILES.has(f);
|
|
103
|
+
if (!ok) ignored.push(String(f));
|
|
104
|
+
return ok;
|
|
105
|
+
});
|
|
106
|
+
return {
|
|
107
|
+
...raw,
|
|
108
|
+
agents: Array.isArray(raw.agents) ? raw.agents.map(String) : undefined,
|
|
109
|
+
files,
|
|
110
|
+
guidelines: guidelineList(raw.guidelines),
|
|
111
|
+
createdGuidelines: guidelineList(raw.createdGuidelines),
|
|
112
|
+
ignored: [...new Set(ignored)],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const ignoredWarning = (lock) => (lock?.ignored.length
|
|
116
|
+
? [`lock file lists paths nestjs-boost never writes; ignored: ${lock.ignored.join(', ')}`] : []);
|
|
117
|
+
|
|
118
|
+
// ---------- detection ----------
|
|
119
|
+
export function isNestProject(root) {
|
|
120
|
+
const pkg = readJson(join(root, 'package.json'));
|
|
121
|
+
return Boolean(pkg && { ...pkg.devDependencies, ...pkg.dependencies }['@nestjs/core']);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function detect(root) {
|
|
125
|
+
const rootPkg = readJson(join(root, 'package.json'));
|
|
126
|
+
if (!rootPkg) fail(`no package.json in ${root}`);
|
|
127
|
+
const pkgs = [rootPkg];
|
|
128
|
+
for (const dir of ['apps', 'libs', 'packages']) {
|
|
129
|
+
const base = join(root, dir);
|
|
130
|
+
if (!existsSync(base) || !statSync(base).isDirectory()) continue;
|
|
131
|
+
for (const name of readdirSync(base)) {
|
|
132
|
+
const p = readJson(join(base, name, 'package.json'));
|
|
133
|
+
if (p) pkgs.push(p);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const deps = {};
|
|
137
|
+
for (const p of pkgs) Object.assign(deps, p.devDependencies, p.dependencies);
|
|
138
|
+
if (!deps['@nestjs/core']) fail('no @nestjs/core in package.json; run this in a NestJS project');
|
|
139
|
+
|
|
140
|
+
const installedCore = readJson(join(root, 'node_modules/@nestjs/core/package.json'))?.version;
|
|
141
|
+
const coreVersion = installedCore ?? String(deps['@nestjs/core']).replace(/^[^\d]*/, '');
|
|
142
|
+
const nestMajor = Number(coreVersion.split('.')[0]) || null;
|
|
143
|
+
|
|
144
|
+
const has = (name) => Boolean(deps[name]);
|
|
145
|
+
const adapter = has('@nestjs/platform-node') ? 'Node adapter (@nestjs/platform-node)'
|
|
146
|
+
: has('@nestjs/platform-fastify') ? 'Fastify (@nestjs/platform-fastify)'
|
|
147
|
+
: has('@nestjs/platform-express') ? 'Express (@nestjs/platform-express)' : 'none (no HTTP platform package)';
|
|
148
|
+
const esm = rootPkg.type === 'module';
|
|
149
|
+
const testRunner = has('vitest') ? 'Vitest' : has('jest') ? 'Jest' : 'unknown';
|
|
150
|
+
const linter = has('oxlint') ? 'oxlint' : has('eslint') ? 'ESLint' : has('@biomejs/biome') ? 'Biome' : 'none';
|
|
151
|
+
const pm = existsSync(join(root, 'pnpm-lock.yaml')) ? 'pnpm'
|
|
152
|
+
: existsSync(join(root, 'yarn.lock')) ? 'yarn'
|
|
153
|
+
: existsSync(join(root, 'bun.lockb')) || existsSync(join(root, 'bun.lock')) ? 'bun' : 'npm';
|
|
154
|
+
const schemaLib = ['zod', 'valibot', 'arktype'].find(has);
|
|
155
|
+
const validation = [schemaLib && `Standard Schema (${schemaLib}) with StandardSchemaValidationPipe`, has('class-validator') && 'class-validator with ValidationPipe']
|
|
156
|
+
.filter(Boolean).join(' + ') || 'none installed yet (default: zod + StandardSchemaValidationPipe)';
|
|
157
|
+
const nestCli = readJson(join(root, 'nest-cli.json')) ?? {};
|
|
158
|
+
|
|
159
|
+
const scripts = rootPkg.scripts ?? {};
|
|
160
|
+
const run = (s) => (pm === 'npm' ? (s === 'test' ? 'npm test' : `npm run ${s}`) : `${pm} ${s}`);
|
|
161
|
+
const cmd = (s, fallback) => (scripts[s] ? run(s) : fallback);
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
root, deps, nestMajor, coreVersion, adapter, esm, testRunner, linter, pm, validation,
|
|
165
|
+
config: rootPkg['nestjs-boost'] ?? null,
|
|
166
|
+
monorepo: nestCli.monorepo === true,
|
|
167
|
+
files: (names) => names.filter((n) => existsSync(join(root, n))),
|
|
168
|
+
commands: {
|
|
169
|
+
build: cmd('build', 'npx nest build'),
|
|
170
|
+
lint: cmd('lint', '# no lint script'),
|
|
171
|
+
test: cmd('test', '# no test script'),
|
|
172
|
+
e2e: cmd('test:e2e', '# no e2e script'),
|
|
173
|
+
start: cmd('start:prod', 'node dist/main'),
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Agents whose files are already in the project (registry order); empty if none.
|
|
179
|
+
export function findAgents(root) {
|
|
180
|
+
return AGENT_IDS.filter((id) => AGENTS[id].markers.some((m) => existsSync(join(root, m))));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function detectAgents(root) {
|
|
184
|
+
const found = findAgents(root);
|
|
185
|
+
return found.length ? found : DEFAULT_AGENTS;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Validate and normalise an agent list: known ids only, registry order, no duplicates.
|
|
189
|
+
export function normalizeAgents(list) {
|
|
190
|
+
const ids = toList(list);
|
|
191
|
+
const unknown = ids.filter((a) => !AGENTS[a]);
|
|
192
|
+
if (unknown.length) fail(`unknown agent(s): ${unknown.join(', ')} (known: ${AGENT_IDS.join(', ')}; see \`nestjs-boost agents\`)`);
|
|
193
|
+
return AGENT_IDS.filter((id) => ids.includes(id));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Options precedence: explicit (CLI) > package.json "nestjs-boost" > .ai/nestjs-boost.json > lock > detection.
|
|
197
|
+
// `agentSource` says which one decided the agents, so the CLI knows whether to ask.
|
|
198
|
+
export function resolveOptions(root, facts, explicit = {}, lock = null) {
|
|
199
|
+
const file = readJson(join(root, CONFIG_FILE)) ?? {};
|
|
200
|
+
const pkg = facts.config ?? {};
|
|
201
|
+
const given = (v) => v !== undefined && !(Array.isArray(v) && !v.length) && v !== '';
|
|
202
|
+
const pick = (key) => [explicit[key], pkg[key], file[key]].find(given);
|
|
203
|
+
const agentSources = [['cli', explicit.agents], ['package.json', pkg.agents], [CONFIG_FILE, file.agents], ['lock', lock?.agents]];
|
|
204
|
+
const [agentSource, agentList] = agentSources.find(([, v]) => toList(v).length) ?? [findAgents(root).length ? 'detected' : 'default', detectAgents(root)];
|
|
205
|
+
const agents = normalizeAgents(agentList);
|
|
206
|
+
return {
|
|
207
|
+
agents,
|
|
208
|
+
agentSource,
|
|
209
|
+
all: Boolean(pick('all')),
|
|
210
|
+
include: toList(pick('include')),
|
|
211
|
+
exclude: toList(pick('exclude')),
|
|
212
|
+
guidelines: pick('guidelines') !== false,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------- skill selection ----------
|
|
217
|
+
export function selectSkills(facts, { all = false, include = [], exclude = [] } = {}) {
|
|
218
|
+
const known = new Set(MANIFEST.skills.map((s) => s.name));
|
|
219
|
+
const unknown = [...include, ...exclude].filter((n) => !known.has(n));
|
|
220
|
+
if (unknown.length) fail(`unknown skill(s): ${unknown.join(', ')} (see \`nestjs-boost list --all\`)`);
|
|
221
|
+
const selected = [];
|
|
222
|
+
for (const s of MANIFEST.skills) {
|
|
223
|
+
const w = s.installWhen ?? {};
|
|
224
|
+
let reason = null;
|
|
225
|
+
if (w.always) reason = 'foundation';
|
|
226
|
+
else if (w.anyDependency?.some((d) => facts.deps[d])) reason = `dependency: ${w.anyDependency.filter((d) => facts.deps[d]).join(', ')}`;
|
|
227
|
+
else if (w.anyFile && facts.files(w.anyFile).length) reason = `file: ${facts.files(w.anyFile).join(', ')}`;
|
|
228
|
+
else if (w.nestjsMajorBelow && facts.nestMajor && facts.nestMajor < w.nestjsMajorBelow) reason = `NestJS ${facts.nestMajor} < ${w.nestjsMajorBelow}`;
|
|
229
|
+
if (!reason && (all || include.includes(s.name))) reason = all ? 'all' : 'include';
|
|
230
|
+
if (reason && exclude.includes(s.name)) reason = null;
|
|
231
|
+
if (reason && existsSync(join(PACK_DIR, 'skills', s.name, 'SKILL.md'))) selected.push({ name: s.name, reason });
|
|
232
|
+
}
|
|
233
|
+
return selected;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---------- guidelines ----------
|
|
237
|
+
function skillSummary(skillDir) {
|
|
238
|
+
const md = readFileSync(join(skillDir, 'SKILL.md'), 'utf8');
|
|
239
|
+
const desc = md.match(/^description:\s*(.+)$/m)?.[1] ?? '';
|
|
240
|
+
const use = desc.match(/Use when (.+?)(?:\. Not for |\.?$)/)?.[1];
|
|
241
|
+
return use ? `use when ${use}` : desc;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function renderGuidelines(facts, skills, root, customSkills = []) {
|
|
245
|
+
let tpl = readFileSync(join(PACK_DIR, 'guidelines/core.md'), 'utf8');
|
|
246
|
+
const runnerApi = facts.testRunner === 'Vitest' ? '`vi.fn()`, `vi.spyOn()`' : facts.testRunner === 'Jest' ? '`jest.fn()`, `jest.spyOn()`' : 'the configured runner';
|
|
247
|
+
const values = {
|
|
248
|
+
nestjsVersion: facts.coreVersion,
|
|
249
|
+
docsCrawled: MANIFEST.docs.crawled,
|
|
250
|
+
httpAdapter: facts.adapter,
|
|
251
|
+
moduleFormat: facts.esm ? 'ESM (`"type": "module"`)' : 'CommonJS',
|
|
252
|
+
testRunner: facts.testRunner,
|
|
253
|
+
linter: facts.linter,
|
|
254
|
+
packageManager: facts.pm,
|
|
255
|
+
validation: facts.validation,
|
|
256
|
+
esmNote: facts.esm ? 'This is an **ESM** project: relative imports use the `.js` extension (`import { X } from \'./x.service.js\'`), and `main.ts` may use top-level `await`.' : '',
|
|
257
|
+
buildCommand: facts.commands.build,
|
|
258
|
+
lintCommand: facts.commands.lint,
|
|
259
|
+
testCommand: facts.commands.test,
|
|
260
|
+
e2eCommand: facts.commands.e2e,
|
|
261
|
+
startCommand: facts.commands.start,
|
|
262
|
+
testRunnerApi: runnerApi,
|
|
263
|
+
skillMap: [
|
|
264
|
+
...skills.map((s) => `- \`${s.name}\`: ${skillSummary(join(PACK_DIR, 'skills', s.name))}`),
|
|
265
|
+
...customSkills.map((n) => `- \`${n}\` (project): ${skillSummary(join(root, '.ai/skills', n))}`),
|
|
266
|
+
].join('\n'),
|
|
267
|
+
};
|
|
268
|
+
tpl = tpl.replace(/\{\{(\w+)\}\}/g, (_, k) => values[k] ?? '');
|
|
269
|
+
const extraDir = safeDir(root, '.ai/guidelines');
|
|
270
|
+
if (extraDir) {
|
|
271
|
+
const extras = readdirSync(extraDir, { withFileTypes: true })
|
|
272
|
+
.filter((d) => d.isFile() && d.name.endsWith('.md')).map((d) => d.name).sort();
|
|
273
|
+
for (const f of extras) tpl += `\n\n${readFileSync(join(extraDir, f), 'utf8').trim()}\n`;
|
|
274
|
+
}
|
|
275
|
+
return stripMarkers(tpl).replace(/\n{3,}/g, '\n\n').trim();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Project-supplied text (custom guidelines, skill descriptions) must not contain the block
|
|
279
|
+
// markers, or it would move the block boundaries and later syncs could overwrite user text.
|
|
280
|
+
const stripMarkers = (text) => text.split(START).join('').split(END).join('');
|
|
281
|
+
|
|
282
|
+
// Replace only the marked block; keep everything the user wrote outside it.
|
|
283
|
+
export function upsertBlock(existing, body) {
|
|
284
|
+
const block = `${START}\n${body}\n${END}`;
|
|
285
|
+
if (!existing) return `${block}\n`;
|
|
286
|
+
const s = existing.indexOf(START);
|
|
287
|
+
const e = existing.indexOf(END);
|
|
288
|
+
if (s !== -1 && e > s) return existing.slice(0, s) + block + existing.slice(e + END.length);
|
|
289
|
+
return `${existing.trimEnd()}\n\n${block}\n`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function removeBlock(existing) {
|
|
293
|
+
const s = existing.indexOf(START);
|
|
294
|
+
const e = existing.indexOf(END);
|
|
295
|
+
if (s === -1 || e < s) return existing;
|
|
296
|
+
return `${existing.slice(0, s).trimEnd()}\n\n${existing.slice(e + END.length).trimStart()}`.trim() + '\n';
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---------- sync ----------
|
|
300
|
+
// Returns a report; writes nothing when dryRun is set.
|
|
301
|
+
export function sync(root, explicit = {}, { dryRun = false, force = false, prune = true } = {}) {
|
|
302
|
+
root = resolve(root);
|
|
303
|
+
const lockPath = inside(root, LOCK);
|
|
304
|
+
const lock = readLock(lockPath);
|
|
305
|
+
const facts = detect(root);
|
|
306
|
+
const opts = resolveOptions(root, facts, explicit, lock);
|
|
307
|
+
const skills = selectSkills(facts, opts);
|
|
308
|
+
const warnings = ignoredWarning(lock);
|
|
309
|
+
const overridesDir = safeDir(root, '.ai/skills');
|
|
310
|
+
const overrides = [];
|
|
311
|
+
for (const d of overridesDir ? readdirSync(overridesDir, { withFileTypes: true }) : []) {
|
|
312
|
+
const skillFile = lstat(join(overridesDir, d.name, 'SKILL.md'));
|
|
313
|
+
if (!d.isDirectory() || !skillFile?.isFile()) {
|
|
314
|
+
if (d.isSymbolicLink()) warnings.push(`.ai/skills/${d.name}: symbolic link; skipped`);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (!NAME_RE.test(d.name)) { warnings.push(`.ai/skills/${d.name}: skill names must be lowercase-with-hyphens; skipped`); continue; }
|
|
318
|
+
overrides.push(d.name);
|
|
319
|
+
}
|
|
320
|
+
const custom = overrides.filter((n) => !skills.some((s) => s.name === n));
|
|
321
|
+
const prevFiles = lock?.files ?? {};
|
|
322
|
+
const files = {};
|
|
323
|
+
const changes = [];
|
|
324
|
+
|
|
325
|
+
const wanted = new Map();
|
|
326
|
+
const skillDirs = planSkillDirs(opts.agents);
|
|
327
|
+
for (const { dir } of skillDirs) {
|
|
328
|
+
for (const s of skills) wanted.set(`${dir}/${s.name}`, overrides.includes(s.name) ? join(overridesDir, s.name) : join(PACK_DIR, 'skills', s.name));
|
|
329
|
+
for (const n of custom) wanted.set(`${dir}/${n}`, join(overridesDir, n));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
for (const [rel, src] of wanted) {
|
|
333
|
+
const dest = inside(root, rel);
|
|
334
|
+
const srcHash = hashDir(src);
|
|
335
|
+
const destHash = hashDir(dest);
|
|
336
|
+
const tracked = prevFiles[rel];
|
|
337
|
+
if (destHash === srcHash) { files[rel] = srcHash; continue; }
|
|
338
|
+
if (destHash && !force && (tracked === undefined || destHash !== tracked)) {
|
|
339
|
+
warnings.push(`${rel}: ${tracked === undefined ? 'exists and was not installed by nestjs-boost' : 'was edited'}; left as is (customize via .ai/skills/${rel.split('/').pop()}/, or use --force)`);
|
|
340
|
+
if (tracked !== undefined) files[rel] = tracked;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
changes.push({ action: destHash ? 'update' : 'add', path: rel });
|
|
344
|
+
if (!dryRun) {
|
|
345
|
+
rmSync(dest, { recursive: true, force: true });
|
|
346
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
347
|
+
cpSync(src, dest, { recursive: true, filter: noLinks });
|
|
348
|
+
}
|
|
349
|
+
files[rel] = srcHash;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Skills installed earlier whose condition no longer holds.
|
|
353
|
+
for (const [rel, hash] of Object.entries(prevFiles)) {
|
|
354
|
+
if (wanted.has(rel)) continue;
|
|
355
|
+
const dest = inside(root, rel);
|
|
356
|
+
const current = hashDir(dest);
|
|
357
|
+
if (!current) continue;
|
|
358
|
+
if (!prune || (current !== hash && !force)) {
|
|
359
|
+
if (prune) warnings.push(`${rel}: no longer needed but was edited; left as is (delete it yourself, or use --force)`);
|
|
360
|
+
files[rel] = hash;
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
changes.push({ action: 'remove', path: rel });
|
|
364
|
+
if (!dryRun) rmSync(dest, { recursive: true, force: true });
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Guidelines block in each agent's instructions file.
|
|
368
|
+
const guidelineFiles = opts.guidelines ? instructionFiles(opts.agents) : [];
|
|
369
|
+
const created = new Set(lock?.createdGuidelines ?? []);
|
|
370
|
+
if (guidelineFiles.length) {
|
|
371
|
+
const body = renderGuidelines(facts, skills, root, custom);
|
|
372
|
+
for (const rel of guidelineFiles) {
|
|
373
|
+
const dest = inside(root, rel);
|
|
374
|
+
const before = existsSync(dest) ? readFileSync(dest, 'utf8') : '';
|
|
375
|
+
const after = upsertBlock(before, body);
|
|
376
|
+
if (!before) created.add(rel);
|
|
377
|
+
if (before === after) continue;
|
|
378
|
+
changes.push({ action: before ? 'update' : 'add', path: `${rel} (guidelines block)` });
|
|
379
|
+
if (!dryRun) { mkdirSync(dirname(dest), { recursive: true }); writeFileSync(dest, after); }
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// Instructions files of agents no longer selected (or guidelines turned off): take the block out.
|
|
383
|
+
if (prune) {
|
|
384
|
+
for (const rel of lock?.guidelines ?? []) {
|
|
385
|
+
if (!guidelineFiles.includes(rel)) removeGuideline(root, rel, lock.createdGuidelines.includes(rel), changes, dryRun);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (!dryRun) removeEmptyDirs(root, changes);
|
|
389
|
+
|
|
390
|
+
const nextLock = {
|
|
391
|
+
pack: MANIFEST.pack, version: PACK_VERSION, agents: opts.agents,
|
|
392
|
+
skills: skills.map((s) => s.name), guidelines: guidelineFiles,
|
|
393
|
+
createdGuidelines: [...created].filter((f) => guidelineFiles.includes(f)).sort(), files,
|
|
394
|
+
};
|
|
395
|
+
const lockText = JSON.stringify(nextLock, null, 2) + '\n';
|
|
396
|
+
if (!dryRun && (!existsSync(lockPath) || readFileSync(lockPath, 'utf8') !== lockText)) {
|
|
397
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
398
|
+
writeFileSync(lockPath, lockText);
|
|
399
|
+
}
|
|
400
|
+
return { facts, opts, foundAgents: findAgents(root), skills, custom, skillDirs, guidelineFiles, changes, warnings, dryRun };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Take the nestjs-boost block out of an instructions file; delete the file if nestjs-boost created
|
|
404
|
+
// it and nothing else is left.
|
|
405
|
+
function removeGuideline(root, rel, createdByUs, changes, dryRun) {
|
|
406
|
+
const dest = inside(root, rel);
|
|
407
|
+
if (!lstat(dest)?.isFile()) return;
|
|
408
|
+
const before = readFileSync(dest, 'utf8');
|
|
409
|
+
const after = removeBlock(before);
|
|
410
|
+
if (before === after) return;
|
|
411
|
+
const drop = after.trim() === '' && createdByUs;
|
|
412
|
+
changes.push({ action: drop ? 'remove' : 'update', path: `${rel}${drop ? '' : ' (guidelines block removed)'}` });
|
|
413
|
+
if (!dryRun) drop ? rmSync(dest) : writeFileSync(dest, after);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// After removing skill folders, also remove the skills folder and its agent folder (e.g. `.cursor/`)
|
|
417
|
+
// if that left them empty. Only folders above a removed path are considered.
|
|
418
|
+
function removeEmptyDirs(root, changes, extra = []) {
|
|
419
|
+
const removed = changes.filter((c) => c.action === 'remove').map((c) => c.path);
|
|
420
|
+
const candidates = [...new Set([...removed.flatMap((p) => [dirname(p), dirname(dirname(p))]), ...extra])]
|
|
421
|
+
.filter((d) => d !== '.' && (ALL_SKILL_DIRS.some((s) => s === d || dirname(s) === d) || extra.includes(d)));
|
|
422
|
+
for (const rel of candidates) {
|
|
423
|
+
const d = safeDir(root, rel);
|
|
424
|
+
try { if (d && !readdirSync(d).length) rmSync(d, { recursive: true }); } catch { /* absent or busy */ }
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Remove everything nestjs-boost installed (edited skills are kept unless force).
|
|
429
|
+
export function uninstall(root, { dryRun = false, force = false } = {}) {
|
|
430
|
+
root = resolve(root);
|
|
431
|
+
const lockPath = inside(root, LOCK);
|
|
432
|
+
const lock = readLock(lockPath);
|
|
433
|
+
const changes = [];
|
|
434
|
+
if (!lock) return { changes, warnings: ['nothing to remove: no .ai/nestjs-boost.lock.json'], dryRun };
|
|
435
|
+
const warnings = ignoredWarning(lock);
|
|
436
|
+
for (const [rel, hash] of Object.entries(lock.files)) {
|
|
437
|
+
const dest = inside(root, rel);
|
|
438
|
+
const current = hashDir(dest);
|
|
439
|
+
if (!current) continue;
|
|
440
|
+
if (current !== hash && !force) { warnings.push(`${rel}: edited; kept (use --force to remove)`); continue; }
|
|
441
|
+
changes.push({ action: 'remove', path: rel });
|
|
442
|
+
if (!dryRun) rmSync(dest, { recursive: true, force: true });
|
|
443
|
+
}
|
|
444
|
+
for (const rel of lock.guidelines) removeGuideline(root, rel, lock.createdGuidelines.includes(rel), changes, dryRun);
|
|
445
|
+
if (!dryRun) {
|
|
446
|
+
rmSync(lockPath, { force: true });
|
|
447
|
+
removeEmptyDirs(root, changes, ['.ai']);
|
|
448
|
+
}
|
|
449
|
+
return { changes, warnings, dryRun };
|
|
450
|
+
}
|
package/lib/prompt.mjs
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Minimal interactive prompts (multi-select and yes/no) on top of node:readline, so the package
|
|
2
|
+
// keeps zero dependencies. Every prompt takes an `io` object ({ input, output, env }) so tests can
|
|
3
|
+
// drive it with fake streams. Callers must check canPrompt() first; without a real terminal they
|
|
4
|
+
// should fall back to flags, config or detection instead of asking.
|
|
5
|
+
import { emitKeypressEvents } from 'node:readline';
|
|
6
|
+
|
|
7
|
+
export class PromptCancelled extends Error {
|
|
8
|
+
constructor() { super('cancelled'); }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const defaultIo = () => ({ input: process.stdin, output: process.stdout, env: process.env });
|
|
12
|
+
|
|
13
|
+
export function canPrompt(io = defaultIo()) {
|
|
14
|
+
return Boolean(io.input?.isTTY && io.output?.isTTY && typeof io.input.setRawMode === 'function');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Unicode symbols where the terminal is known to render them; ASCII elsewhere (old Windows consoles).
|
|
18
|
+
function theme(io) {
|
|
19
|
+
const { env, output } = io;
|
|
20
|
+
const unicode = process.platform !== 'win32' || Boolean(env.WT_SESSION || env.TERM_PROGRAM || env.ConEmuANSI === 'ON');
|
|
21
|
+
const color = !env.NO_COLOR && (typeof output.hasColors === 'function' ? output.hasColors() : Boolean(output.isTTY));
|
|
22
|
+
const paint = (code) => (s) => (color ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
23
|
+
return {
|
|
24
|
+
dim: paint('2'), bold: paint('1'), cyan: paint('36'), green: paint('32'), red: paint('31'),
|
|
25
|
+
pointer: unicode ? '❯' : '>', on: unicode ? '◉' : '[x]', off: unicode ? '◯' : '[ ]',
|
|
26
|
+
ask: unicode ? '?' : '?', done: unicode ? '✔' : 'v',
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Runs one prompt: raw-mode key handling plus in-place redraw. `render` returns the lines to show;
|
|
31
|
+
// `onKey` returns { done: value } to finish, or nothing to keep going.
|
|
32
|
+
function session(io, render, onKey) {
|
|
33
|
+
const { input, output } = io;
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
let drawn = 0;
|
|
36
|
+
const width = () => Math.max(20, (output.columns || 80) - 1);
|
|
37
|
+
// Lines are cut to the terminal width so a wrapped line can't break the redraw.
|
|
38
|
+
const fit = (line) => {
|
|
39
|
+
let visible = 0;
|
|
40
|
+
let out = '';
|
|
41
|
+
for (const part of line.split(/(\x1b\[[0-9;]*m)/)) {
|
|
42
|
+
if (part.startsWith('\x1b[')) { out += part; continue; }
|
|
43
|
+
const room = width() - visible;
|
|
44
|
+
if (room <= 0) break;
|
|
45
|
+
out += part.length > room ? `${part.slice(0, Math.max(0, room - 1))}…` : part;
|
|
46
|
+
visible += Math.min(part.length, room);
|
|
47
|
+
}
|
|
48
|
+
return `${out}\x1b[0m`;
|
|
49
|
+
};
|
|
50
|
+
const draw = (lines) => {
|
|
51
|
+
if (drawn) output.write(`\x1b[${drawn}A\x1b[0J`);
|
|
52
|
+
output.write(`${lines.map(fit).join('\n')}\n`);
|
|
53
|
+
drawn = lines.length;
|
|
54
|
+
};
|
|
55
|
+
const finish = (fn, finalLines) => {
|
|
56
|
+
input.removeListener('keypress', handler);
|
|
57
|
+
if (input.isTTY) input.setRawMode(false);
|
|
58
|
+
input.pause();
|
|
59
|
+
if (finalLines) draw(finalLines);
|
|
60
|
+
output.write('\x1b[?25h');
|
|
61
|
+
fn();
|
|
62
|
+
};
|
|
63
|
+
const handler = (str, key = {}) => {
|
|
64
|
+
if ((key.ctrl && key.name === 'c') || key.name === 'escape') {
|
|
65
|
+
return finish(() => reject(new PromptCancelled()));
|
|
66
|
+
}
|
|
67
|
+
const result = onKey(str, key);
|
|
68
|
+
if (result && 'done' in result) return finish(() => resolve(result.done), result.final);
|
|
69
|
+
draw(render());
|
|
70
|
+
};
|
|
71
|
+
emitKeypressEvents(input);
|
|
72
|
+
if (input.isTTY) input.setRawMode(true);
|
|
73
|
+
input.on('keypress', handler);
|
|
74
|
+
input.resume();
|
|
75
|
+
output.write('\x1b[?25l');
|
|
76
|
+
draw(render());
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// choices: [{ value, label, hint?, tag?, checked? }]. Resolves to the selected values in list order.
|
|
81
|
+
export function multiSelect({ message, choices, min = 1 }, io = defaultIo()) {
|
|
82
|
+
const t = theme(io);
|
|
83
|
+
let cursor = 0;
|
|
84
|
+
let error = '';
|
|
85
|
+
const selected = new Set(choices.filter((c) => c.checked).map((c) => c.value));
|
|
86
|
+
const labelWidth = Math.max(...choices.map((c) => c.label.length));
|
|
87
|
+
const tagWidth = Math.max(0, ...choices.map((c) => (c.tag ?? '').length));
|
|
88
|
+
const render = () => [
|
|
89
|
+
`${t.cyan(t.ask)} ${t.bold(message)}`,
|
|
90
|
+
t.dim(' ↑/↓ move · space select · a all/none · enter confirm · esc cancel'),
|
|
91
|
+
...choices.map((c, i) => {
|
|
92
|
+
const here = i === cursor;
|
|
93
|
+
const box = selected.has(c.value) ? t.green(t.on) : t.dim(t.off);
|
|
94
|
+
const label = here ? t.cyan(c.label.padEnd(labelWidth)) : c.label.padEnd(labelWidth);
|
|
95
|
+
const tag = tagWidth ? ` ${t.green((c.tag ?? '').padEnd(tagWidth))}` : ''; // own column, keeps hints aligned
|
|
96
|
+
return `${here ? t.cyan(t.pointer) : ' '} ${box} ${label}${tag} ${t.dim(c.hint ?? '')}`;
|
|
97
|
+
}),
|
|
98
|
+
...(error ? [t.red(` ${error}`)] : []),
|
|
99
|
+
];
|
|
100
|
+
return session(io, render, (str, key) => {
|
|
101
|
+
error = '';
|
|
102
|
+
if (key.name === 'up' || str === 'k') cursor = (cursor - 1 + choices.length) % choices.length;
|
|
103
|
+
else if (key.name === 'down' || str === 'j') cursor = (cursor + 1) % choices.length;
|
|
104
|
+
else if (key.name === 'space') {
|
|
105
|
+
const v = choices[cursor].value;
|
|
106
|
+
selected.has(v) ? selected.delete(v) : selected.add(v);
|
|
107
|
+
} else if (str === 'a') {
|
|
108
|
+
const all = selected.size === choices.length;
|
|
109
|
+
choices.forEach((c) => (all ? selected.delete(c.value) : selected.add(c.value)));
|
|
110
|
+
} else if (key.name === 'return' || key.name === 'enter') {
|
|
111
|
+
if (selected.size < min) { error = `Select at least ${min} (press space).`; return undefined; }
|
|
112
|
+
const values = choices.filter((c) => selected.has(c.value)).map((c) => c.value);
|
|
113
|
+
const names = choices.filter((c) => selected.has(c.value)).map((c) => c.label).join(', ');
|
|
114
|
+
return { done: values, final: [`${t.green(t.done)} ${t.bold(message)} ${t.cyan(names)}`] };
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function confirm({ message, initial = true }, io = defaultIo()) {
|
|
121
|
+
const t = theme(io);
|
|
122
|
+
const render = () => [`${t.cyan(t.ask)} ${t.bold(message)} ${t.dim(initial ? '(Y/n)' : '(y/N)')}`];
|
|
123
|
+
return session(io, render, (str, key) => {
|
|
124
|
+
let answer;
|
|
125
|
+
if (str === 'y' || str === 'Y') answer = true;
|
|
126
|
+
else if (str === 'n' || str === 'N') answer = false;
|
|
127
|
+
else if (key.name === 'return' || key.name === 'enter') answer = initial;
|
|
128
|
+
if (answer === undefined) return undefined;
|
|
129
|
+
return { done: answer, final: [`${t.green(t.done)} ${t.bold(message)} ${t.cyan(answer ? 'Yes' : 'No')}`] };
|
|
130
|
+
});
|
|
131
|
+
}
|
package/lib/setup.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Interactive `nestjs-boost install`: pick agents, review the plan, confirm, install.
|
|
2
|
+
// All writing goes through installer.sync(); this module only asks questions and reports.
|
|
3
|
+
import { AGENTS, AGENT_LIST, sync } from './installer.mjs';
|
|
4
|
+
import { confirm, multiSelect } from './prompt.mjs';
|
|
5
|
+
|
|
6
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
7
|
+
|
|
8
|
+
// Picker entries: every registry agent, pre-checking the current choice (lock, detection or default).
|
|
9
|
+
export function agentChoices(current, found) {
|
|
10
|
+
return AGENT_LIST.map((a) => ({
|
|
11
|
+
value: a.id,
|
|
12
|
+
label: a.name,
|
|
13
|
+
tag: found.includes(a.id) ? '(detected)' : '',
|
|
14
|
+
hint: `${a.skills.dir}/ · ${a.instructions}${a.hint ? ` · ${a.hint}` : ''}`,
|
|
15
|
+
checked: current.includes(a.id),
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// A short, human description of what an install will do, built from a dry-run report.
|
|
20
|
+
export function describePlan(report) {
|
|
21
|
+
const { skills, custom, skillDirs, guidelineFiles, changes, opts } = report;
|
|
22
|
+
const lines = [];
|
|
23
|
+
const foundation = skills.filter((s) => s.reason === 'foundation').length;
|
|
24
|
+
const extra = [`${foundation} foundation`, `${skills.length - foundation} for your packages`, ...(custom.length ? [`${custom.length} project`] : [])];
|
|
25
|
+
lines.push(`Skills: ${plural(skills.length + custom.length, 'skill')} (${extra.join(', ')})`);
|
|
26
|
+
for (const { dir, agents } of skillDirs) {
|
|
27
|
+
lines.push(` ${`${dir}/`.padEnd(20)} read by ${agents.map((id) => AGENTS[id].name).join(', ')}`);
|
|
28
|
+
}
|
|
29
|
+
if (guidelineFiles.length) lines.push(`Guidelines block: ${guidelineFiles.join(', ')}`);
|
|
30
|
+
const count = (action) => changes.filter((c) => c.action === action).length;
|
|
31
|
+
const summary = ['add', 'update', 'remove'].map((a) => (count(a) ? `${count(a)} ${a === 'add' ? 'new' : a === 'update' ? 'updated' : 'removed'}` : '')).filter(Boolean);
|
|
32
|
+
lines.push(summary.length ? `Changes: ${summary.join(', ')}` : 'Changes: none, already up to date');
|
|
33
|
+
if (opts.agentSource === 'package.json' || opts.agentSource === '.ai/nestjs-boost.json') {
|
|
34
|
+
lines.push(`(agents come from ${opts.agentSource}; edit it there to change them)`);
|
|
35
|
+
}
|
|
36
|
+
return lines;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Returns the final sync report, or null when the user declines.
|
|
40
|
+
export async function interactiveInstall(root, explicit, run, io, log = console.log) {
|
|
41
|
+
// Dry run first: fails early outside a NestJS project and tells us the current agents.
|
|
42
|
+
const current = sync(root, explicit, { ...run, dryRun: true });
|
|
43
|
+
const pinned = ['package.json', '.ai/nestjs-boost.json'].includes(current.opts.agentSource);
|
|
44
|
+
let agents = current.opts.agents;
|
|
45
|
+
if (!pinned) {
|
|
46
|
+
agents = await multiSelect({
|
|
47
|
+
message: 'Which AI coding agents do you use?',
|
|
48
|
+
choices: agentChoices(current.opts.agents, current.foundAgents),
|
|
49
|
+
}, io);
|
|
50
|
+
}
|
|
51
|
+
const choice = { ...explicit, agents };
|
|
52
|
+
const plan = sync(root, choice, { ...run, dryRun: true });
|
|
53
|
+
log('');
|
|
54
|
+
for (const line of describePlan(plan)) log(line);
|
|
55
|
+
for (const w of plan.warnings) log(` ! ${w}`);
|
|
56
|
+
if (!plan.changes.length) return plan;
|
|
57
|
+
log('');
|
|
58
|
+
if (!(await confirm({ message: 'Install now?' }, io))) return null;
|
|
59
|
+
return sync(root, choice, run);
|
|
60
|
+
}
|