@lariocpt/flawless-react 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/flawless-a11y/SKILL.md +113 -0
- package/.agents/skills/flawless-api/SKILL.md +159 -0
- package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
- package/.agents/skills/flawless-e2e/SKILL.md +155 -0
- package/.agents/skills/flawless-expo/SKILL.md +283 -0
- package/.agents/skills/flawless-findings/SKILL.md +116 -0
- package/.agents/skills/flawless-load/SKILL.md +112 -0
- package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
- package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
- package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
- package/.agents/skills/flawless-react/SKILL.md +403 -0
- package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
- package/.agents/skills/flawless-regen/SKILL.md +135 -0
- package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
- package/LICENSE +21 -0
- package/README.md +608 -2
- package/bin/flawless-react +4 -0
- package/package.json +67 -4
- package/scripts/postinstall.d.mts +46 -0
- package/scripts/postinstall.mjs +127 -0
- package/src/analyse/authShims.ts +220 -0
- package/src/analyse/bootability.ts +257 -0
- package/src/analyse/childBridge.ts +302 -0
- package/src/analyse/childrenIntoSlot.ts +66 -0
- package/src/analyse/compoundFamily.ts +100 -0
- package/src/analyse/dataExports.ts +200 -0
- package/src/analyse/extractChildAttrs.ts +112 -0
- package/src/analyse/extractDataDeps.ts +857 -0
- package/src/analyse/extractDisclosures.ts +186 -0
- package/src/analyse/extractFetchCalls.ts +81 -0
- package/src/analyse/extractFlows.ts +239 -0
- package/src/analyse/extractGuards.ts +270 -0
- package/src/analyse/extractHooks.ts +237 -0
- package/src/analyse/extractInteractions.ts +1421 -0
- package/src/analyse/extractLogging.ts +45 -0
- package/src/analyse/extractNativeA11y.ts +138 -0
- package/src/analyse/extractPredicates.ts +784 -0
- package/src/analyse/extractPropUsage.ts +162 -0
- package/src/analyse/extractProps.ts +1212 -0
- package/src/analyse/extractRenderMode.ts +165 -0
- package/src/analyse/extractRootTag.ts +117 -0
- package/src/analyse/extractState.ts +361 -0
- package/src/analyse/extractStaticText.ts +212 -0
- package/src/analyse/extractVariants.ts +186 -0
- package/src/analyse/harvestApiEndpoints.ts +284 -0
- package/src/analyse/hostProps.ts +185 -0
- package/src/analyse/hostTag.ts +72 -0
- package/src/analyse/htmlContentModel.ts +94 -0
- package/src/analyse/index.ts +744 -0
- package/src/analyse/mountPathCalls.ts +442 -0
- package/src/analyse/nativeContentModel.ts +129 -0
- package/src/analyse/nativeDialect.ts +83 -0
- package/src/analyse/nativeProps.ts +339 -0
- package/src/analyse/ownReturns.ts +31 -0
- package/src/analyse/parseFile.ts +919 -0
- package/src/analyse/pos.ts +8 -0
- package/src/analyse/providerWraps.ts +1977 -0
- package/src/analyse/pureModule.ts +88 -0
- package/src/analyse/reexportTable.ts +148 -0
- package/src/analyse/refs.ts +195 -0
- package/src/analyse/renderPath.ts +441 -0
- package/src/analyse/resolveDepReturns.ts +370 -0
- package/src/analyse/resolveIndexKeys.ts +98 -0
- package/src/analyse/resolveModule.ts +431 -0
- package/src/analyse/resolveTagRootTags.ts +103 -0
- package/src/analyse/serverActionReturns.ts +213 -0
- package/src/analyse/sourceCache.ts +263 -0
- package/src/analyse/spreadSiblings.ts +98 -0
- package/src/analyse/walkChildren.ts +98 -0
- package/src/analyse/zodSchema.ts +424 -0
- package/src/bin.ts +23 -0
- package/src/changed.ts +221 -0
- package/src/cli.ts +560 -0
- package/src/cliFlags.ts +281 -0
- package/src/core.ts +1229 -0
- package/src/docLine.ts +122 -0
- package/src/emitPaths.ts +51 -0
- package/src/expo/detect.ts +184 -0
- package/src/expo/refusal.ts +62 -0
- package/src/expo/routes.ts +145 -0
- package/src/index.ts +95 -0
- package/src/mcp/analysisTools.ts +720 -0
- package/src/mcp/infoTools.ts +135 -0
- package/src/mcp/protocol.ts +130 -0
- package/src/mcp/run.ts +269 -0
- package/src/mcp/runTools.ts +362 -0
- package/src/mcp/server.ts +279 -0
- package/src/mcp/spec.ts +86 -0
- package/src/mcp/support.ts +268 -0
- package/src/mcp/tools.ts +75 -0
- package/src/nextjs/apiRoutes.ts +116 -0
- package/src/nextjs/detect.ts +397 -0
- package/src/nextjs/emit.ts +106 -0
- package/src/nextjs/liveSpec.ts +79 -0
- package/src/nextjs/mswHandlers.ts +55 -0
- package/src/nextjs/providerWrapper.ts +603 -0
- package/src/nextjs/routes.ts +86 -0
- package/src/nextjs/swagger.ts +32 -0
- package/src/packageJson.ts +414 -0
- package/src/platform.ts +18 -0
- package/src/prompt.ts +132 -0
- package/src/reactCompiler.ts +165 -0
- package/src/reporters/findings.ts +156 -0
- package/src/reporters/report.ts +463 -0
- package/src/runner/runTests.ts +402 -0
- package/src/scaffold/adoptStyle.ts +181 -0
- package/src/scaffold/index.ts +253 -0
- package/src/scaffold/openapi.ts +183 -0
- package/src/scaffold/templates.ts +2379 -0
- package/src/scaffold-tests/index.ts +463 -0
- package/src/scaffold-tests/mockValues.ts +208 -0
- package/src/scaffold-tests/mocks.ts +794 -0
- package/src/scaffold-tests/nativeIndex.ts +52 -0
- package/src/scaffold-tests/nativeTemplates.ts +1528 -0
- package/src/scaffold-tests/postDispatch.ts +152 -0
- package/src/scaffold-tests/registry.ts +443 -0
- package/src/scaffold-tests/selectors.ts +493 -0
- package/src/scaffold-tests/selectorsNative.ts +273 -0
- package/src/scaffold-tests/stamp.ts +79 -0
- package/src/scaffold-tests/templates.ts +3578 -0
- package/src/skills/cli.ts +248 -0
- package/src/skills/compat.ts +166 -0
- package/src/skills/data.ts +192 -0
- package/src/skills/md.d.ts +7 -0
- package/src/skills/menu.ts +55 -0
- package/src/skills/spec.ts +227 -0
- package/src/suiteScripts.ts +110 -0
- package/src/tasks/a11yNative.ts +281 -0
- package/src/tasks/a11yScan.ts +67 -0
- package/src/tasks/biomeRunner.ts +301 -0
- package/src/tasks/biomeSweep.ts +166 -0
- package/src/tasks/e2eExpo.ts +436 -0
- package/src/tasks/e2eRoutes.ts +756 -0
- package/src/tasks/eslintReactRules.ts +67 -0
- package/src/tasks/index.ts +24 -0
- package/src/tasks/loadExpo.ts +273 -0
- package/src/tasks/loadPipes.ts +750 -0
- package/src/tasks/perfHarness.ts +145 -0
- package/src/tasks/staticPerfAnalysis.ts +510 -0
- package/src/tasks/types.ts +1 -0
- package/src/tasks/unitAndBrowser.ts +462 -0
- package/src/types.ts +1419 -0
- package/src/version.ts +12 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// `flawless-react skills list | install` — the runner.
|
|
2
|
+
//
|
|
3
|
+
// The order of operations is the contract, and it is the one thing here that is
|
|
4
|
+
// not obvious from any single function:
|
|
5
|
+
//
|
|
6
|
+
// 1. write `.agents/skills/<name>/SKILL.md` — the promise
|
|
7
|
+
// 2. link `.claude/skills/<name>` at it — the convenience
|
|
8
|
+
// 3. report — including what was refused
|
|
9
|
+
//
|
|
10
|
+
// The canonical write comes first and the exit code follows it alone. A compat
|
|
11
|
+
// entry that could not be made costs Claude Code the discovery of one skill and
|
|
12
|
+
// is reported; it does not turn a completed install into a failed one, and it
|
|
13
|
+
// never deletes anything that might be somebody else's.
|
|
14
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
15
|
+
import type { ClackPrompts } from "../prompt.ts";
|
|
16
|
+
import { loadClack } from "../prompt.ts";
|
|
17
|
+
import { type CompatOutcome, ensureCompatEntry, summarizeCompat } from "./compat.ts";
|
|
18
|
+
import {
|
|
19
|
+
EMBEDDED_SKILLS,
|
|
20
|
+
type EmbeddedSkill,
|
|
21
|
+
hasFlawlessSkills,
|
|
22
|
+
SKILLS_SUBDIR,
|
|
23
|
+
type SkillStatus,
|
|
24
|
+
skillDir,
|
|
25
|
+
skillPath,
|
|
26
|
+
skillStatuses,
|
|
27
|
+
skillsBaseDir,
|
|
28
|
+
} from "./data.ts";
|
|
29
|
+
import { defaultSelection, pickSkills } from "./menu.ts";
|
|
30
|
+
import { parseSkillsCommand, renderSkillsHelp, type SkillsCommand } from "./spec.ts";
|
|
31
|
+
|
|
32
|
+
/** Everything the runner writes to, so a test can read the same text the user does. */
|
|
33
|
+
export interface SkillsIO {
|
|
34
|
+
log: (line?: string) => void;
|
|
35
|
+
warn: (line: string) => void;
|
|
36
|
+
isTTY: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function defaultSkillsIO(): SkillsIO {
|
|
40
|
+
return {
|
|
41
|
+
log: (line?: string) => console.log(line ?? ""),
|
|
42
|
+
warn: (line: string) => console.warn(line),
|
|
43
|
+
isTTY: process.stdout.isTTY === true,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The status that matters for an install: the one for the scope being written to. */
|
|
48
|
+
export function targetStatus(statuses: Awaited<ReturnType<typeof skillStatuses>>, global: boolean) {
|
|
49
|
+
return global ? statuses.global : statuses.project;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface Chosen {
|
|
53
|
+
skill: EmbeddedSkill;
|
|
54
|
+
status: SkillStatus;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Which skills this run acts on, and what state each is already in for the
|
|
59
|
+
* target scope.
|
|
60
|
+
*
|
|
61
|
+
* The menu appears only when the user asked for nothing specific and has a
|
|
62
|
+
* terminal. Piped, CI, or `--all`, it installs everything — an installer that
|
|
63
|
+
* waits on a keyboard nobody is holding is a hung build, not a helpful tool.
|
|
64
|
+
*/
|
|
65
|
+
async function chooseSkills(
|
|
66
|
+
cmd: SkillsCommand,
|
|
67
|
+
cwd: string,
|
|
68
|
+
io: SkillsIO,
|
|
69
|
+
): Promise<{ chosen: Chosen[]; aborted: boolean; fromMenu: boolean }> {
|
|
70
|
+
const withStatus: Chosen[] = [];
|
|
71
|
+
for (const skill of EMBEDDED_SKILLS) {
|
|
72
|
+
withStatus.push({ skill, status: targetStatus(await skillStatuses(skill, cwd), cmd.global) });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (cmd.select.length > 0) {
|
|
76
|
+
return {
|
|
77
|
+
chosen: withStatus.filter((c) => cmd.select.includes(c.skill.name)),
|
|
78
|
+
aborted: false,
|
|
79
|
+
fromMenu: false,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
if (cmd.all || !io.isTTY) return { chosen: withStatus, aborted: false, fromMenu: false };
|
|
83
|
+
|
|
84
|
+
const picked = await pickSkills(
|
|
85
|
+
skillsBaseDir(cmd.global, cwd),
|
|
86
|
+
withStatus.map((c) => ({ name: c.skill.name, hint: hintFor(c.status) })),
|
|
87
|
+
defaultSelection(withStatus.map((c) => ({ name: c.skill.name, status: c.status }))),
|
|
88
|
+
);
|
|
89
|
+
if (picked === undefined) return { chosen: [], aborted: true, fromMenu: true };
|
|
90
|
+
return {
|
|
91
|
+
chosen: withStatus.filter((c) => picked.includes(c.skill.name)),
|
|
92
|
+
aborted: false,
|
|
93
|
+
fromMenu: true,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function hintFor(status: SkillStatus): string {
|
|
98
|
+
if (status === "current") return "installed, up to date";
|
|
99
|
+
if (status === "edited") return "installed, edited locally — --force to overwrite";
|
|
100
|
+
return "not installed";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Write the canonical SKILL.md. False means it could not be written. */
|
|
104
|
+
async function installCanonical(chosen: Chosen, cmd: SkillsCommand, cwd: string): Promise<boolean> {
|
|
105
|
+
const base = skillsBaseDir(cmd.global, cwd);
|
|
106
|
+
try {
|
|
107
|
+
await mkdir(skillDir(base, chosen.skill.name), { recursive: true });
|
|
108
|
+
await writeFile(skillPath(base, chosen.skill.name), chosen.skill.content, { mode: 0o644 });
|
|
109
|
+
return true;
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function runList(io: SkillsIO, cwd: string): Promise<number> {
|
|
116
|
+
const width = Math.max(...EMBEDDED_SKILLS.map((s) => s.name.length));
|
|
117
|
+
io.log("flawless-react agent skills, embedded in this binary:");
|
|
118
|
+
io.log();
|
|
119
|
+
for (const skill of EMBEDDED_SKILLS) {
|
|
120
|
+
const s = await skillStatuses(skill, cwd);
|
|
121
|
+
io.log(` ${skill.name.padEnd(width)} ${s.project.padEnd(7)} ${s.global}`);
|
|
122
|
+
}
|
|
123
|
+
io.log();
|
|
124
|
+
io.log(" columns are ./.agents/skills and ~/.agents/skills — current means the file");
|
|
125
|
+
io.log(" matches this binary, edited that it differs and was left as written.");
|
|
126
|
+
io.log(" install with: flawless-react skills install");
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function runInstall(cmd: SkillsCommand, cwd: string, io: SkillsIO): Promise<number> {
|
|
131
|
+
const { chosen, aborted, fromMenu } = await chooseSkills(cmd, cwd, io);
|
|
132
|
+
if (aborted) return 0;
|
|
133
|
+
if (!fromMenu && !cmd.all && cmd.select.length === 0 && chosen.length > 0) {
|
|
134
|
+
io.log("not a terminal — installing every skill (use the menu on a tty, or --select)");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const base = skillsBaseDir(cmd.global, cwd);
|
|
138
|
+
const written: string[] = [];
|
|
139
|
+
const refused: string[] = [];
|
|
140
|
+
const failed: string[] = [];
|
|
141
|
+
for (const c of chosen) {
|
|
142
|
+
if (c.status === "edited" && !cmd.force) {
|
|
143
|
+
refused.push(c.skill.name);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
// `current` is not rewritten: the bytes on disk already equal the bytes in
|
|
147
|
+
// this binary, so writing them again would only refresh mtime and make a
|
|
148
|
+
// user's own `git status`-by-mtime habits lie.
|
|
149
|
+
if (c.status === "current") continue;
|
|
150
|
+
if (await installCanonical(c, cmd, cwd)) written.push(c.skill.name);
|
|
151
|
+
else failed.push(c.skill.name);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Only skills whose canonical file is actually in place get a compat entry: a
|
|
155
|
+
// refused or failed one would otherwise leave a link pointing at nothing.
|
|
156
|
+
const compat: Array<{ name: string; outcome: CompatOutcome }> = [];
|
|
157
|
+
for (const c of chosen) {
|
|
158
|
+
if (refused.includes(c.skill.name) || failed.includes(c.skill.name)) continue;
|
|
159
|
+
compat.push({
|
|
160
|
+
name: c.skill.name,
|
|
161
|
+
outcome: await ensureCompatEntry(c.skill, cmd.global, cmd.copy, cwd),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
const report = summarizeCompat(compat, cmd.global, cwd);
|
|
165
|
+
|
|
166
|
+
const upToDate = chosen.length - written.length - refused.length - failed.length;
|
|
167
|
+
if (written.length === 0 && refused.length === 0 && failed.length === 0 && upToDate > 0) {
|
|
168
|
+
io.log(
|
|
169
|
+
`✔ ${chosen.length} ${chosen.length === 1 ? "skill" : "skills"} already current → ${base}`,
|
|
170
|
+
);
|
|
171
|
+
} else if (written.length + upToDate > 0) {
|
|
172
|
+
const parts = [];
|
|
173
|
+
if (written.length > 0) parts.push(`installed ${written.length}`);
|
|
174
|
+
if (upToDate > 0) parts.push(`up-to-date ${upToDate}`);
|
|
175
|
+
io.log(`✔ ${parts.join(", ")} → ${base}`);
|
|
176
|
+
}
|
|
177
|
+
if (report.summary) io.log(` ${report.summary}`);
|
|
178
|
+
for (const w of report.warnings) io.warn(w);
|
|
179
|
+
|
|
180
|
+
if (refused.length > 0) {
|
|
181
|
+
io.warn(
|
|
182
|
+
`! ${refused.length} ${refused.length === 1 ? "skill" : "skills"} edited locally, kept:\n` +
|
|
183
|
+
refused.map((n) => ` ${skillPath(base, n)}`).join("\n") +
|
|
184
|
+
"\n re-run with --force to overwrite them",
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
if (failed.length > 0) {
|
|
188
|
+
io.warn(
|
|
189
|
+
`! could not write ${failed.length} ${failed.length === 1 ? "skill" : "skills"}: ${failed.join(", ")}`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return refused.length > 0 || failed.length > 0 ? 1 : 0;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Run the subcommand. Returns the process exit code: 0 done — including a
|
|
197
|
+
* cancelled menu and a compat miss, which is reported and best-effort — 1
|
|
198
|
+
* something the user asked for was not installed, 2 bad usage (parseSkillsCommand).
|
|
199
|
+
*/
|
|
200
|
+
export async function runSkillsCommand(
|
|
201
|
+
cmd: SkillsCommand,
|
|
202
|
+
io: SkillsIO = defaultSkillsIO(),
|
|
203
|
+
cwd = process.cwd(),
|
|
204
|
+
): Promise<number> {
|
|
205
|
+
if (cmd.verb === "help") {
|
|
206
|
+
io.log(renderSkillsHelp());
|
|
207
|
+
return 0;
|
|
208
|
+
}
|
|
209
|
+
if (cmd.verb === "list") return runList(io, cwd);
|
|
210
|
+
return runInstall(cmd, cwd, io);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The one question asked at the end of an interactive run: want the skills?
|
|
215
|
+
*
|
|
216
|
+
* Three things make this the right place and stop it being a nag. It is asked
|
|
217
|
+
* only on the interactive path — `--no-interactive`, `--json` and CI mean do not
|
|
218
|
+
* speak to me, and this is not a machine's errand. It is asked only while the
|
|
219
|
+
* project has no `flawless-*` skill anywhere, and answering YES is what makes it
|
|
220
|
+
* stop asking: the installed skills are the marker, so no state file has to
|
|
221
|
+
* follow the user around. And answering NO costs nothing but the same question
|
|
222
|
+
* next time, which is a fair price for never storing a decision nobody made.
|
|
223
|
+
*
|
|
224
|
+
* `FLAWLESS_NO_SKILLS` silences it outright, and is the same switch the npm
|
|
225
|
+
* postinstall honours, so one environment variable ends all asking.
|
|
226
|
+
*/
|
|
227
|
+
export async function offerSkills(
|
|
228
|
+
io: SkillsIO,
|
|
229
|
+
opts: { cwd?: string; env?: NodeJS.ProcessEnv; prompts?: ClackPrompts } = {},
|
|
230
|
+
): Promise<void> {
|
|
231
|
+
const env = opts.env ?? process.env;
|
|
232
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
233
|
+
if (env.FLAWLESS_NO_SKILLS) return;
|
|
234
|
+
if (hasFlawlessSkills(false, cwd)) return;
|
|
235
|
+
|
|
236
|
+
const p = opts.prompts ?? (await loadClack());
|
|
237
|
+
const yes = await p.confirm({
|
|
238
|
+
message: `Install the ${EMBEDDED_SKILLS.length} agent skills into ${SKILLS_SUBDIR}?`,
|
|
239
|
+
initialValue: true,
|
|
240
|
+
});
|
|
241
|
+
if (p.isCancel(yes) || !yes) {
|
|
242
|
+
io.log(" skipped — `flawless-react skills install` does it later, any time.");
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
// The same code path as the command, reached through the same parser, so the
|
|
246
|
+
// yes-answer cannot install something the CLI itself would refuse to.
|
|
247
|
+
await runSkillsCommand(parseSkillsCommand(["install", "--all"]), io, cwd);
|
|
248
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Claude Code compatibility for the install: the real file lands in
|
|
2
|
+
// `.agents/skills/<name>/` (the cross-client convention — see SKILLS_SUBDIR) and
|
|
3
|
+
// `.claude/skills/<name>` is a link to that same directory, because Claude Code
|
|
4
|
+
// is the one mainstream host that does not read the standard path.
|
|
5
|
+
//
|
|
6
|
+
// A link rather than a copy so there is one file to edit and no way for the two
|
|
7
|
+
// views to disagree; per-skill rather than one link over `skills/` because Codex
|
|
8
|
+
// writes its own `.system/` in there and other CLIs expect a real directory.
|
|
9
|
+
//
|
|
10
|
+
// Nothing here may fail an install. The canonical write already succeeded; a
|
|
11
|
+
// compatibility entry is a convenience, and losing it says so and moves on.
|
|
12
|
+
//
|
|
13
|
+
// And nothing here is ever DELETED. A real directory under `.claude/skills` may
|
|
14
|
+
// be a skill somebody wrote, or another tool's, so it is reported and left alone
|
|
15
|
+
// — including under `--force`, which governs files we own, not ones we did not
|
|
16
|
+
// create. (crust lets its `--force` replace the directory; this is the one place
|
|
17
|
+
// we deliberately differ, so the rule is written where the difference lives
|
|
18
|
+
// rather than in a flag description.)
|
|
19
|
+
import { lstat, mkdir, readlink, symlink, writeFile } from "node:fs/promises";
|
|
20
|
+
import { dirname, relative, resolve } from "node:path";
|
|
21
|
+
import {
|
|
22
|
+
claudeBaseDir,
|
|
23
|
+
claudeSkillDir,
|
|
24
|
+
type EmbeddedSkill,
|
|
25
|
+
skillDir,
|
|
26
|
+
skillsBaseDir,
|
|
27
|
+
} from "./data.ts";
|
|
28
|
+
|
|
29
|
+
export type CompatOutcome =
|
|
30
|
+
/** Link created now. */
|
|
31
|
+
| { kind: "linked" }
|
|
32
|
+
/** Already a link to the canonical directory — nothing to do. */
|
|
33
|
+
| { kind: "current" }
|
|
34
|
+
/** A copy written instead of a link. `reason` names the flag or the errno. */
|
|
35
|
+
| { kind: "copied"; reason: string }
|
|
36
|
+
/** A real directory, or a plain file, is here. Untouched, and reported. */
|
|
37
|
+
| { kind: "occupied" }
|
|
38
|
+
/** A link, but to somewhere else — the user put it there. Untouched. */
|
|
39
|
+
| { kind: "foreign"; target: string }
|
|
40
|
+
/** Could not tell or act. Reported; the canonical file is still installed. */
|
|
41
|
+
| { kind: "failed"; error: string };
|
|
42
|
+
|
|
43
|
+
/** The link target, written relative so a moved project or a synced `$HOME` keeps working. */
|
|
44
|
+
export function compatLinkTarget(name: string, global: boolean, cwd?: string): string {
|
|
45
|
+
const link = claudeSkillDir(name, global, cwd);
|
|
46
|
+
return relative(dirname(link), skillDir(skillsBaseDir(global, cwd), name));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Make `.claude/skills/<name>` point at the installed skill.
|
|
51
|
+
*
|
|
52
|
+
* `copy` asks for a copy instead of a link (an agent that does not follow
|
|
53
|
+
* symlinks, or a filesystem in the way). Symlinks being refused by the system —
|
|
54
|
+
* Windows without Developer Mode — falls back to a copy on its own and says so.
|
|
55
|
+
*/
|
|
56
|
+
export async function ensureCompatEntry(
|
|
57
|
+
skill: EmbeddedSkill,
|
|
58
|
+
global: boolean,
|
|
59
|
+
copy: boolean,
|
|
60
|
+
cwd?: string,
|
|
61
|
+
): Promise<CompatOutcome> {
|
|
62
|
+
const link = claudeSkillDir(skill.name, global, cwd);
|
|
63
|
+
const canonical = skillDir(skillsBaseDir(global, cwd), skill.name);
|
|
64
|
+
try {
|
|
65
|
+
const st = await lstat(link);
|
|
66
|
+
if (st.isSymbolicLink()) {
|
|
67
|
+
const target = await readlink(link);
|
|
68
|
+
return resolve(dirname(link), target) === resolve(canonical)
|
|
69
|
+
? { kind: "current" }
|
|
70
|
+
: { kind: "foreign", target };
|
|
71
|
+
}
|
|
72
|
+
// A real directory or a plain file. It is not ours, so it is not touched —
|
|
73
|
+
// not here, and not by --force either. The warning tells the user what to
|
|
74
|
+
// remove if they want the link.
|
|
75
|
+
return { kind: "occupied" };
|
|
76
|
+
} catch (err) {
|
|
77
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
78
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") {
|
|
79
|
+
return { kind: "failed", error: (err as Error).message };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
await mkdir(dirname(link), { recursive: true });
|
|
85
|
+
} catch (err) {
|
|
86
|
+
return { kind: "failed", error: (err as Error).message };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!copy) {
|
|
90
|
+
try {
|
|
91
|
+
await symlink(relative(dirname(link), canonical), link, "dir");
|
|
92
|
+
return { kind: "linked" };
|
|
93
|
+
} catch (err) {
|
|
94
|
+
// Only a filesystem that cannot do symlinks is worth working around; any
|
|
95
|
+
// other failure is the real cause and goes to the report.
|
|
96
|
+
if (!isSymlinkUnsupported(err)) return { kind: "failed", error: (err as Error).message };
|
|
97
|
+
return writeCopy(skill, link, (err as Error).message);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return writeCopy(skill, link, "--copy");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function writeCopy(
|
|
104
|
+
skill: EmbeddedSkill,
|
|
105
|
+
link: string,
|
|
106
|
+
reason: string,
|
|
107
|
+
): Promise<CompatOutcome> {
|
|
108
|
+
try {
|
|
109
|
+
// The link path is a DIRECTORY holding SKILL.md — both hosts discover a skill
|
|
110
|
+
// as a directory, not a loose file — so the copy needs the directory made too.
|
|
111
|
+
await mkdir(link, { recursive: true });
|
|
112
|
+
await writeFile(`${link}/SKILL.md`, skill.content, { mode: 0o644 });
|
|
113
|
+
return { kind: "copied", reason };
|
|
114
|
+
} catch (err) {
|
|
115
|
+
return { kind: "failed", error: (err as Error).message };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Windows without Developer Mode answers EPERM; some filesystems say ENOTSUP. */
|
|
120
|
+
function isSymlinkUnsupported(err: unknown): boolean {
|
|
121
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
122
|
+
return code === "EPERM" || code === "ENOTSUP" || code === "ENOSYS" || code === "EOPNOTSUPP";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** One line for the install summary, plus anything the user should act on. */
|
|
126
|
+
export function summarizeCompat(
|
|
127
|
+
results: Array<{ name: string; outcome: CompatOutcome }>,
|
|
128
|
+
global: boolean,
|
|
129
|
+
cwd?: string,
|
|
130
|
+
): { summary: string | null; warnings: string[] } {
|
|
131
|
+
const kinds = (kind: CompatOutcome["kind"]) => results.filter((r) => r.outcome.kind === kind);
|
|
132
|
+
const linked = kinds("linked").length;
|
|
133
|
+
const copied = kinds("copied").length;
|
|
134
|
+
const made = [linked > 0 ? `linked ${linked}` : null, copied > 0 ? `copied ${copied}` : null]
|
|
135
|
+
.filter((s): s is string => s !== null)
|
|
136
|
+
.join(", ");
|
|
137
|
+
const claude = claudeBaseDir(global, cwd);
|
|
138
|
+
const summary = made === "" ? null : `${made} into ${claude} for Claude Code`;
|
|
139
|
+
|
|
140
|
+
const warnings: string[] = [];
|
|
141
|
+
const occupied = kinds("occupied");
|
|
142
|
+
if (occupied.length > 0) {
|
|
143
|
+
const one = occupied.length === 1;
|
|
144
|
+
warnings.push(
|
|
145
|
+
`! ${occupied.length} real ${one ? "directory" : "directories"} under ${claude} ` +
|
|
146
|
+
`flawless-react did not write — left untouched. Remove ${one ? "it" : "them"} by hand ` +
|
|
147
|
+
`if you want the link instead (--force does not delete what we did not create):\n` +
|
|
148
|
+
occupied.map((r) => ` ${claudeSkillDir(r.name, global, cwd)}\n`).join(""),
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
for (const r of kinds("foreign")) {
|
|
152
|
+
if (r.outcome.kind === "foreign") {
|
|
153
|
+
warnings.push(
|
|
154
|
+
`! ${claudeSkillDir(r.name, global, cwd)} links to ${r.outcome.target}, not to flawless-react's copy — left alone\n`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
for (const r of kinds("failed")) {
|
|
159
|
+
if (r.outcome.kind === "failed") {
|
|
160
|
+
warnings.push(
|
|
161
|
+
`! could not write ${claudeSkillDir(r.name, global, cwd)} (${r.outcome.error}) — the skill is installed; Claude Code may not find it\n`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return { summary, warnings };
|
|
166
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// The agent skills, embedded into the binary by static text imports.
|
|
2
|
+
//
|
|
3
|
+
// Embedding rather than reading the repo at run time is what lets `flawless-react
|
|
4
|
+
// skills install` work from an installed package or a compiled binary, on a
|
|
5
|
+
// machine that has no checkout of this repo anywhere on it.
|
|
6
|
+
//
|
|
7
|
+
// The sources are the tracked files under `.agents/skills` — the same bytes
|
|
8
|
+
// tests/docsLint.test.ts lints against the CLI — so what ships is exactly what
|
|
9
|
+
// was checked. That makes this repo its own installer dogfood, with one rule
|
|
10
|
+
// doing the safety work: a file on disk that differs from the embedded copy is
|
|
11
|
+
// `edited`, and `edited` is refused without `--force`. A stale binary therefore
|
|
12
|
+
// cannot quietly roll back a SKILL.md you just changed here.
|
|
13
|
+
import { readdirSync } from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import flawlessA11y from "../../.agents/skills/flawless-a11y/SKILL.md" with { type: "text" };
|
|
16
|
+
import flawlessApi from "../../.agents/skills/flawless-api/SKILL.md" with { type: "text" };
|
|
17
|
+
import flawlessApiFixtures from "../../.agents/skills/flawless-api-fixtures/SKILL.md" with {
|
|
18
|
+
type: "text",
|
|
19
|
+
};
|
|
20
|
+
import flawlessE2E from "../../.agents/skills/flawless-e2e/SKILL.md" with { type: "text" };
|
|
21
|
+
import flawlessExpo from "../../.agents/skills/flawless-expo/SKILL.md" with { type: "text" };
|
|
22
|
+
import flawlessFindings from "../../.agents/skills/flawless-findings/SKILL.md" with {
|
|
23
|
+
type: "text",
|
|
24
|
+
};
|
|
25
|
+
import flawlessLoad from "../../.agents/skills/flawless-load/SKILL.md" with { type: "text" };
|
|
26
|
+
import flawlessNextjs from "../../.agents/skills/flawless-nextjs/SKILL.md" with { type: "text" };
|
|
27
|
+
import flawlessPerfHarness from "../../.agents/skills/flawless-perf-harness/SKILL.md" with {
|
|
28
|
+
type: "text",
|
|
29
|
+
};
|
|
30
|
+
import flawlessPerfStatic from "../../.agents/skills/flawless-perf-static/SKILL.md" with {
|
|
31
|
+
type: "text",
|
|
32
|
+
};
|
|
33
|
+
import flawlessReact from "../../.agents/skills/flawless-react/SKILL.md" with { type: "text" };
|
|
34
|
+
import flawlessReactRules from "../../.agents/skills/flawless-react-rules/SKILL.md" with {
|
|
35
|
+
type: "text",
|
|
36
|
+
};
|
|
37
|
+
import flawlessRegen from "../../.agents/skills/flawless-regen/SKILL.md" with { type: "text" };
|
|
38
|
+
import flawlessUnitBrowser from "../../.agents/skills/flawless-unit-browser/SKILL.md" with {
|
|
39
|
+
type: "text",
|
|
40
|
+
};
|
|
41
|
+
import { SKILL_CHOICES } from "./spec.ts";
|
|
42
|
+
|
|
43
|
+
export interface EmbeddedSkill {
|
|
44
|
+
name: string;
|
|
45
|
+
content: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The names live in spec.ts and this maps them onto bytes, which makes the two
|
|
50
|
+
* agree by type rather than by test: the Record has to cover every name and
|
|
51
|
+
* nothing else, so a skill added without being listed — or listed without being
|
|
52
|
+
* embedded — is a compile error.
|
|
53
|
+
*/
|
|
54
|
+
const CONTENT: Record<(typeof SKILL_CHOICES)[number], string> = {
|
|
55
|
+
"flawless-react": flawlessReact,
|
|
56
|
+
"flawless-nextjs": flawlessNextjs,
|
|
57
|
+
"flawless-unit-browser": flawlessUnitBrowser,
|
|
58
|
+
"flawless-perf-harness": flawlessPerfHarness,
|
|
59
|
+
"flawless-perf-static": flawlessPerfStatic,
|
|
60
|
+
"flawless-a11y": flawlessA11y,
|
|
61
|
+
"flawless-react-rules": flawlessReactRules,
|
|
62
|
+
"flawless-findings": flawlessFindings,
|
|
63
|
+
"flawless-e2e": flawlessE2E,
|
|
64
|
+
"flawless-load": flawlessLoad,
|
|
65
|
+
"flawless-api-fixtures": flawlessApiFixtures,
|
|
66
|
+
"flawless-regen": flawlessRegen,
|
|
67
|
+
"flawless-api": flawlessApi,
|
|
68
|
+
"flawless-expo": flawlessExpo,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** Order is display order: the umbrella first, since it is the router. */
|
|
72
|
+
export const EMBEDDED_SKILLS: EmbeddedSkill[] = SKILL_CHOICES.map((name) => ({
|
|
73
|
+
name,
|
|
74
|
+
content: CONTENT[name],
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Where the real files land, relative to a project root or to `$HOME`.
|
|
79
|
+
*
|
|
80
|
+
* `.agents/skills` is the cross-client convention: Codex reads **only** that
|
|
81
|
+
* path, Cursor and OpenCode read it alongside their own vendor directories, and
|
|
82
|
+
* Claude Code reads only `.claude/skills`. So this is the canonical half and
|
|
83
|
+
* {@link CLAUDE_SUBDIR} is the compatibility half — a per-skill link, not a
|
|
84
|
+
* second copy, because two copies drift and one edited file becomes two
|
|
85
|
+
* questions.
|
|
86
|
+
*/
|
|
87
|
+
export const SKILLS_SUBDIR = ".agents/skills";
|
|
88
|
+
|
|
89
|
+
/** Claude Code's own discovery path, which does not read {@link SKILLS_SUBDIR}. */
|
|
90
|
+
export const CLAUDE_SUBDIR = ".claude/skills";
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* What identifies a skill as ours on disk. The install gate uses it to decide
|
|
94
|
+
* "this user has already done this", and a future `skills uninstall` uses it to
|
|
95
|
+
* find what we own without a manifest — the embedded set plus this prefix.
|
|
96
|
+
*/
|
|
97
|
+
export const SKILL_PREFIX = "flawless-";
|
|
98
|
+
|
|
99
|
+
/** Root of a scope: the project cwd, or the user's home when `global`. */
|
|
100
|
+
export function skillsRoot(global: boolean, cwd = process.cwd()): string {
|
|
101
|
+
return global ? homeDir() : cwd.replace(/\/+$/, "");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The user's home, honouring `$HOME` when it is set.
|
|
106
|
+
*
|
|
107
|
+
* Bun's `os.homedir()` reads the passwd entry and ignores `$HOME`; in a
|
|
108
|
+
* container, where the passwd entry is root's and the process runs with
|
|
109
|
+
* `HOME=/home/deploy`, that difference decides whether a `--global` install
|
|
110
|
+
* lands where the agent will actually look for it. POSIX convention is that
|
|
111
|
+
* `$HOME` wins when present, and it also makes the global scope testable without
|
|
112
|
+
* pretending to be another user.
|
|
113
|
+
*/
|
|
114
|
+
export function homeDir(): string {
|
|
115
|
+
const fromEnv = process.env.HOME?.trim();
|
|
116
|
+
return fromEnv ? fromEnv.replace(/\/+$/, "") : homedir();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** `<root>/.agents/skills` — the canonical directory for this scope. */
|
|
120
|
+
export function skillsBaseDir(global: boolean, cwd?: string): string {
|
|
121
|
+
return `${skillsRoot(global, cwd)}/${SKILLS_SUBDIR}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** `<root>/.claude/skills` — Claude Code's discovery directory for this scope. */
|
|
125
|
+
export function claudeBaseDir(global: boolean, cwd?: string): string {
|
|
126
|
+
return `${skillsRoot(global, cwd)}/${CLAUDE_SUBDIR}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The directory one skill owns: `<base>/<name>`. */
|
|
130
|
+
export function skillDir(baseDir: string, name: string): string {
|
|
131
|
+
return `${baseDir}/${name}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function skillPath(baseDir: string, name: string): string {
|
|
135
|
+
return `${skillDir(baseDir, name)}/SKILL.md`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** `<root>/.claude/skills/<name>` — where the link to {@link skillDir} belongs. */
|
|
139
|
+
export function claudeSkillDir(name: string, global: boolean, cwd?: string): string {
|
|
140
|
+
return `${claudeBaseDir(global, cwd)}/${name}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* `missing` — nothing installed. `current` — byte-identical to the embedded
|
|
145
|
+
* copy. `edited` — present and DIFFERENT, which is the one state an install must
|
|
146
|
+
* not silently overwrite, and so the one the menu draws in yellow.
|
|
147
|
+
*/
|
|
148
|
+
export type SkillStatus = "missing" | "current" | "edited";
|
|
149
|
+
|
|
150
|
+
export interface SkillStatuses {
|
|
151
|
+
project: SkillStatus;
|
|
152
|
+
global: SkillStatus;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function statusAt(file: string, content: string): Promise<SkillStatus> {
|
|
156
|
+
const f = Bun.file(file);
|
|
157
|
+
if (!(await f.exists())) return "missing";
|
|
158
|
+
return (await f.text()) === content ? "current" : "edited";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Both scopes in one pass, because the only caller is `skills list`, which shows
|
|
163
|
+
* the two columns side by side. Passing `cwd` (rather than letting it default) is
|
|
164
|
+
* what lets the tests run against a scratch project without chdir'ing the runner.
|
|
165
|
+
*/
|
|
166
|
+
export async function skillStatuses(skill: EmbeddedSkill, cwd?: string): Promise<SkillStatuses> {
|
|
167
|
+
return {
|
|
168
|
+
project: await statusAt(skillPath(skillsBaseDir(false, cwd), skill.name), skill.content),
|
|
169
|
+
global: await statusAt(skillPath(skillsBaseDir(true, cwd), skill.name), skill.content),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Whether ANY of our skills is already on disk for a scope — "this has been done"
|
|
175
|
+
* for the two questions (npm's postinstall and the one after an interactive run),
|
|
176
|
+
* and the reason neither needs a marker file: the installed skills ARE the marker.
|
|
177
|
+
*
|
|
178
|
+
* Both roots of the scope are checked, and {@link SKILL_PREFIX} is the only rule,
|
|
179
|
+
* which is what keeps a third-party `.claude/skills/review-react` from counting as
|
|
180
|
+
* ours while one of ours, installed before `.agents` existed, still does.
|
|
181
|
+
*/
|
|
182
|
+
export function hasFlawlessSkills(global: boolean, cwd?: string): boolean {
|
|
183
|
+
return [skillsBaseDir(global, cwd), claudeBaseDir(global, cwd)].some((dir) => {
|
|
184
|
+
try {
|
|
185
|
+
return readdirSync(dir, { withFileTypes: true }).some(
|
|
186
|
+
(e) => e.name.startsWith(SKILL_PREFIX) && (e.isDirectory() || e.isSymbolicLink()),
|
|
187
|
+
);
|
|
188
|
+
} catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// bun-types declares `*.txt`, `*.toml`, `*.yaml`, `*.json5` … but not `*.md`, so
|
|
2
|
+
// the `with { type: "text" }` imports in data.ts — which embed the agent skills
|
|
3
|
+
// into the compiled binary — would have no type.
|
|
4
|
+
declare module "*.md" {
|
|
5
|
+
const content: string;
|
|
6
|
+
export default content;
|
|
7
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// The menu `flawless-react skills install` shows on a terminal.
|
|
2
|
+
//
|
|
3
|
+
// clack's multiselect rather than a hand-rolled raw-mode picker: the project
|
|
4
|
+
// already depends on @clack/prompts, already speaks it in the generation prompts,
|
|
5
|
+
// and already has an injection seam for it (FR-024), so a second cursor library
|
|
6
|
+
// would be one more thing to keep alive for no added behaviour. Scope and
|
|
7
|
+
// overwrite are NOT keys in this menu — they are the `--global` and `--force`
|
|
8
|
+
// flags — which keeps one meaning per spelling and lets the menu answer exactly
|
|
9
|
+
// one question: which skills.
|
|
10
|
+
import { type ClackPrompts, loadClack } from "../prompt.ts";
|
|
11
|
+
import type { SkillStatus } from "./data.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What the menu pre-selects: everything that is not already installed and
|
|
15
|
+
* byte-identical. So the default answer to Enter is "bring this machine up to
|
|
16
|
+
* date", not "rewrite what is already right" — and an `edited` entry is offered
|
|
17
|
+
* because the usual reason it is edited is that the user wants the new one.
|
|
18
|
+
*/
|
|
19
|
+
export function defaultSelection(entries: Array<{ name: string; status: SkillStatus }>): string[] {
|
|
20
|
+
return entries.filter((e) => e.status !== "current").map((e) => e.name);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface MenuItem {
|
|
24
|
+
name: string;
|
|
25
|
+
hint?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Ask which skills to install. `undefined` means the user cancelled — the caller
|
|
30
|
+
* must not install anything in that case, and must not call it a failure.
|
|
31
|
+
*
|
|
32
|
+
* `target` is in the question because the answer means different things per
|
|
33
|
+
* scope: the same menu writing into `~/.agents/skills` is a machine-wide
|
|
34
|
+
* decision, and a menu that hides where it is about to write is how you get a
|
|
35
|
+
* skills directory you cannot explain later.
|
|
36
|
+
*/
|
|
37
|
+
export async function pickSkills(
|
|
38
|
+
target: string,
|
|
39
|
+
items: MenuItem[],
|
|
40
|
+
defaults: string[],
|
|
41
|
+
prompts?: ClackPrompts,
|
|
42
|
+
): Promise<string[] | undefined> {
|
|
43
|
+
const p = prompts ?? (await loadClack());
|
|
44
|
+
const picked = await p.multiselect({
|
|
45
|
+
message: `Install which skills into ${target}?`,
|
|
46
|
+
options: items.map((i) => ({ value: i.name, label: i.name, hint: i.hint })),
|
|
47
|
+
initialValues: defaults,
|
|
48
|
+
required: true,
|
|
49
|
+
});
|
|
50
|
+
if (p.isCancel(picked)) {
|
|
51
|
+
p.cancel("aborted — nothing installed");
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
return Array.isArray(picked) ? picked : [];
|
|
55
|
+
}
|