@ryuxdsgn/design-intelligence 0.0.0-stage → 2.6.1
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 +107 -2
- package/dist/adapters.js +29 -0
- package/dist/cli/fsutil.js +61 -0
- package/dist/cli/index.js +701 -0
- package/dist/intelligence/analyze.js +140 -0
- package/dist/intelligence/capture.js +12 -0
- package/dist/intelligence/content.js +1244 -0
- package/dist/intelligence/critique.js +171 -0
- package/dist/intelligence/guides.js +601 -0
- package/dist/product.js +18 -0
- package/dist/render.js +276 -0
- package/dist/validate.js +68 -0
- package/package.json +58 -4
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import * as p from "@clack/prompts";
|
|
6
|
+
import pc from "picocolors";
|
|
7
|
+
import { AGENT_TARGETS, AGENTS_MD_INLINE, LEGACY_CURSOR_RULES_DIR, LEGACY_SKILL_DIRS, } from "../adapters.js";
|
|
8
|
+
import { ALL_INSTALLABLE_IDS } from "../intelligence/content.js";
|
|
9
|
+
import { CLI_CMD, CONTEXT_END, CONTEXT_FILE, CONTEXT_START, MARK_START, MCP_LOCAL_ADD_CMD, MCP_STATUS, VERSION } from "../product.js";
|
|
10
|
+
import { validateBundle } from "../validate.js";
|
|
11
|
+
import { renderAgentsBlock, renderBundle, renderPointerBlock, SKILL_NAME } from "../render.js";
|
|
12
|
+
import { hasBlock, readIfExists, rel, removeBlock, removePath, upsertBlock, writeFileEnsured } from "./fsutil.js";
|
|
13
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
14
|
+
const AGENT_IDS = [...AGENT_TARGETS.map((a) => a.id), AGENTS_MD_INLINE.id];
|
|
15
|
+
const targetDir = (a, scope) => join(scope.root, scope.global ? a.globalDir : a.dir);
|
|
16
|
+
/** Unique skill folders for the chosen agents (agents that share a folder are written once). */
|
|
17
|
+
function dirsFor(agentIds, scope) {
|
|
18
|
+
const dirs = AGENT_TARGETS.filter((a) => agentIds.includes(a.id)).map((a) => targetDir(a, scope));
|
|
19
|
+
return [...new Set(dirs)];
|
|
20
|
+
}
|
|
21
|
+
function pointersFor(agentIds) {
|
|
22
|
+
const files = AGENT_TARGETS.filter((a) => agentIds.includes(a.id)).map((a) => a.pointer);
|
|
23
|
+
return [...new Set(files)];
|
|
24
|
+
}
|
|
25
|
+
async function removeLegacy(dir, root) {
|
|
26
|
+
const done = [];
|
|
27
|
+
for (const c of LEGACY_SKILL_DIRS) {
|
|
28
|
+
const target = join(dir, `ryux-${c}`);
|
|
29
|
+
if (await removePath(target))
|
|
30
|
+
done.push(`${rel(target)} (legacy)`);
|
|
31
|
+
}
|
|
32
|
+
const cursorRules = join(root, LEGACY_CURSOR_RULES_DIR);
|
|
33
|
+
if (existsSync(cursorRules)) {
|
|
34
|
+
for (const f of readdirSync(cursorRules)) {
|
|
35
|
+
if (f.startsWith("ryux-") && f.endsWith(".mdc") && (await removePath(join(cursorRules, f)))) {
|
|
36
|
+
done.push(`${rel(join(cursorRules, f))} (legacy)`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return done;
|
|
41
|
+
}
|
|
42
|
+
/** Former per-skill folders (RYUX 1.x), removed when the single `ryux` skill is written. */
|
|
43
|
+
const OLD_SKILL_DIRS = ["core", ...ALL_INSTALLABLE_IDS].map((id) => `ryux-${id}`);
|
|
44
|
+
async function writeSkills(dir, root) {
|
|
45
|
+
const done = await removeLegacy(dir, root);
|
|
46
|
+
for (const name of OLD_SKILL_DIRS)
|
|
47
|
+
if (await removePath(join(dir, name)))
|
|
48
|
+
done.push(`${rel(join(dir, name))} (replaced)`);
|
|
49
|
+
const skillDir = join(dir, SKILL_NAME);
|
|
50
|
+
await removePath(skillDir);
|
|
51
|
+
const files = renderBundle();
|
|
52
|
+
for (const [path, content] of Object.entries(files))
|
|
53
|
+
await writeFileEnsured(join(skillDir, path), content);
|
|
54
|
+
done.push(`${rel(skillDir)}/ (${Object.keys(files).length} files)`);
|
|
55
|
+
return done;
|
|
56
|
+
}
|
|
57
|
+
async function install(agentIds, scope) {
|
|
58
|
+
const done = [];
|
|
59
|
+
for (const dir of dirsFor(agentIds, scope))
|
|
60
|
+
done.push(...(await writeSkills(dir, scope.root)));
|
|
61
|
+
if (scope.global)
|
|
62
|
+
return done;
|
|
63
|
+
const inline = agentIds.includes(AGENTS_MD_INLINE.id);
|
|
64
|
+
for (const file of pointersFor(agentIds)) {
|
|
65
|
+
if (file === "AGENTS.md" && inline)
|
|
66
|
+
continue;
|
|
67
|
+
await upsertBlock(join(scope.root, file), renderPointerBlock());
|
|
68
|
+
done.push(`${file} (block)`);
|
|
69
|
+
}
|
|
70
|
+
if (inline) {
|
|
71
|
+
await upsertBlock(join(scope.root, "AGENTS.md"), renderAgentsBlock());
|
|
72
|
+
done.push("AGENTS.md (rules inline)");
|
|
73
|
+
}
|
|
74
|
+
return done;
|
|
75
|
+
}
|
|
76
|
+
const hasInstall = (dir) => existsSync(join(dir, SKILL_NAME, "SKILL.md")) || existsSync(join(dir, "ryux-core")) || existsSync(join(dir, "ryux-rules"));
|
|
77
|
+
/** Skill folders that already hold a RYUX install (the single skill, or the RYUX 1.x folders). */
|
|
78
|
+
function detectInstalled(scope) {
|
|
79
|
+
return [...new Set(AGENT_TARGETS.map((a) => targetDir(a, scope)))].filter(hasInstall);
|
|
80
|
+
}
|
|
81
|
+
/** Agent ids whose folders hold a RYUX install (or, for preselection, whose folders exist). */
|
|
82
|
+
function agentsAt(scope, requireInstall) {
|
|
83
|
+
return AGENT_TARGETS.filter((a) => {
|
|
84
|
+
const dir = targetDir(a, scope);
|
|
85
|
+
return requireInstall ? hasInstall(dir) : existsSync(join(dir, ".."));
|
|
86
|
+
}).map((a) => a.id);
|
|
87
|
+
}
|
|
88
|
+
function parseFlags(argv) {
|
|
89
|
+
const flags = {};
|
|
90
|
+
for (let i = 0; i < argv.length; i++) {
|
|
91
|
+
const a = argv[i];
|
|
92
|
+
if (!a.startsWith("--"))
|
|
93
|
+
continue;
|
|
94
|
+
const key = a.slice(2);
|
|
95
|
+
const next = argv[i + 1];
|
|
96
|
+
if (next && !next.startsWith("--")) {
|
|
97
|
+
flags[key] = next;
|
|
98
|
+
i++;
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
flags[key] = true;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return flags;
|
|
105
|
+
}
|
|
106
|
+
const list = (v) => typeof v === "string" ? v.split(",").map((s) => s.trim()).filter(Boolean) : null;
|
|
107
|
+
const scopeOf = (flags) => ({ global: Boolean(flags.global), root: flags.global ? homedir() : process.cwd() });
|
|
108
|
+
function cancel() {
|
|
109
|
+
p.cancel("Cancelled.");
|
|
110
|
+
}
|
|
111
|
+
async function runInstall(flags) {
|
|
112
|
+
const scope = scopeOf(flags);
|
|
113
|
+
const flagAgents = list(flags.agent);
|
|
114
|
+
const nonInteractive = Boolean(flagAgents);
|
|
115
|
+
if (flags.for || flags.groups || flags.concerns) {
|
|
116
|
+
console.log(pc.yellow("RYUX 2 installs one skill that picks the knowledge each task needs; --for, --groups, and --concerns are no longer needed."));
|
|
117
|
+
}
|
|
118
|
+
let agents;
|
|
119
|
+
let showMcp;
|
|
120
|
+
if (flagAgents) {
|
|
121
|
+
agents = flagAgents.includes("all") ? AGENT_TARGETS.map((a) => a.id) : flagAgents.filter((a) => AGENT_IDS.includes(a));
|
|
122
|
+
showMcp = Boolean(flags.mcp);
|
|
123
|
+
if (!agents.length) {
|
|
124
|
+
console.error(`Invalid agent.\nAgents: ${AGENT_IDS.join(",")},all`);
|
|
125
|
+
process.exitCode = 1;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
if (!process.stdin.isTTY) {
|
|
131
|
+
console.error(`Interactive mode needs a terminal. Use flags, e.g.: ${CLI_CMD} install --agent claude,cursor`);
|
|
132
|
+
process.exitCode = 1;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
p.intro(pc.bgCyan(pc.black(" RYUX ")));
|
|
136
|
+
const detected = agentsAt(scope, false);
|
|
137
|
+
const a = await p.multiselect({
|
|
138
|
+
message: `Which agents do you use?${keys("↑/↓ move", "Space select", "a all", "Enter continue", "Ctrl+C cancel")}`,
|
|
139
|
+
options: [
|
|
140
|
+
...AGENT_TARGETS.map((x) => ({ value: x.id, label: x.label, hint: scope.global ? `~/${x.globalDir}` : x.dir })),
|
|
141
|
+
{ value: AGENTS_MD_INLINE.id, label: AGENTS_MD_INLINE.label, hint: "AGENTS.md" },
|
|
142
|
+
],
|
|
143
|
+
initialValues: detected.length ? detected : ["claude"],
|
|
144
|
+
required: true,
|
|
145
|
+
});
|
|
146
|
+
if (p.isCancel(a))
|
|
147
|
+
return cancel();
|
|
148
|
+
const m = await p.confirm({ message: `Show how to connect the ryux MCP?${keys(YES_NO, "Enter continue")}`, initialValue: false });
|
|
149
|
+
if (p.isCancel(m))
|
|
150
|
+
return cancel();
|
|
151
|
+
agents = a;
|
|
152
|
+
showMcp = Boolean(m);
|
|
153
|
+
}
|
|
154
|
+
if (scope.global && agents.includes(AGENTS_MD_INLINE.id)) {
|
|
155
|
+
console.log(pc.yellow("agents-md is project-only; skipped for --global."));
|
|
156
|
+
agents = agents.filter((x) => x !== AGENTS_MD_INLINE.id);
|
|
157
|
+
}
|
|
158
|
+
const written = await install(agents, scope);
|
|
159
|
+
const summary = written.map((w) => pc.green("✓ ") + w).join("\n");
|
|
160
|
+
if (nonInteractive)
|
|
161
|
+
console.log(summary);
|
|
162
|
+
else
|
|
163
|
+
p.note(summary, "Installed");
|
|
164
|
+
if (showMcp) {
|
|
165
|
+
if (nonInteractive)
|
|
166
|
+
console.log(`\nMCP: ${MCP_STATUS}`);
|
|
167
|
+
else
|
|
168
|
+
p.note(MCP_STATUS, "ryux MCP");
|
|
169
|
+
}
|
|
170
|
+
if (nonInteractive)
|
|
171
|
+
return;
|
|
172
|
+
if (!scope.global) {
|
|
173
|
+
const s = await p.confirm({ message: `Set up project context now? Every question can be skipped.${keys(YES_NO, "Enter continue")}`, initialValue: true });
|
|
174
|
+
if (p.isCancel(s))
|
|
175
|
+
return cancel();
|
|
176
|
+
if (s && !(await setupInteractive(scope.root)))
|
|
177
|
+
return cancel();
|
|
178
|
+
if (!s)
|
|
179
|
+
p.log.info(`Set it up later with: ${CLI_CMD} setup`);
|
|
180
|
+
}
|
|
181
|
+
p.outro(`RYUX is ready. Try: ${pc.cyan('"Design a transaction detail page for our app."')}`);
|
|
182
|
+
}
|
|
183
|
+
async function runUpdate(flags) {
|
|
184
|
+
const scope = scopeOf(flags);
|
|
185
|
+
const dirs = detectInstalled(scope);
|
|
186
|
+
const agentsFile = scope.global ? null : await readIfExists(join(scope.root, "AGENTS.md"));
|
|
187
|
+
const inline = Boolean(agentsFile?.includes(MARK_START) && (agentsFile.includes("# RYUX\n") || agentsFile.includes("# ryux-core")));
|
|
188
|
+
if (!dirs.length && !inline) {
|
|
189
|
+
console.log(`No RYUX install found. Run: ${CLI_CMD} install`);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const done = [];
|
|
193
|
+
for (const dir of dirs)
|
|
194
|
+
done.push(...(await writeSkills(dir, scope.root)));
|
|
195
|
+
if (!scope.global) {
|
|
196
|
+
for (const file of ["CLAUDE.md", "GEMINI.md", "AGENTS.md"]) {
|
|
197
|
+
const path = join(scope.root, file);
|
|
198
|
+
const content = await readIfExists(path);
|
|
199
|
+
if (!content?.includes(MARK_START))
|
|
200
|
+
continue;
|
|
201
|
+
await upsertBlock(path, file === "AGENTS.md" && inline ? renderAgentsBlock() : renderPointerBlock());
|
|
202
|
+
done.push(`${file} (block)`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
console.log(`${pc.green("✓ ")}Updated to v${VERSION}:\n${done.map((d) => ` ${d}`).join("\n")}`);
|
|
206
|
+
}
|
|
207
|
+
async function runRemove(flags) {
|
|
208
|
+
const scope = scopeOf(flags);
|
|
209
|
+
const dirs = [...new Set(AGENT_TARGETS.map((a) => targetDir(a, scope)))];
|
|
210
|
+
const hasSkills = dirs.some(hasInstall);
|
|
211
|
+
const pointerFiles = scope.global ? [] : ["CLAUDE.md", "GEMINI.md", "AGENTS.md"];
|
|
212
|
+
const hasBlocks = (await Promise.all(pointerFiles.map(async (f) => (await readIfExists(join(scope.root, f)))?.includes(MARK_START)))).some(Boolean);
|
|
213
|
+
const hasLegacyCursor = existsSync(join(scope.root, LEGACY_CURSOR_RULES_DIR));
|
|
214
|
+
if (!hasSkills && !hasBlocks && !hasLegacyCursor) {
|
|
215
|
+
console.log("No RYUX install found.");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (!flags.yes) {
|
|
219
|
+
const ok = await p.confirm({ message: `Remove RYUX from ${scope.global ? "your home directory" : "this project"}?${keys(YES_NO, "Enter confirm")}` });
|
|
220
|
+
if (p.isCancel(ok) || !ok) {
|
|
221
|
+
console.log("Cancelled.");
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const removed = [];
|
|
226
|
+
for (const dir of dirs) {
|
|
227
|
+
for (const name of [SKILL_NAME, ...OLD_SKILL_DIRS]) {
|
|
228
|
+
const target = join(dir, name);
|
|
229
|
+
if (await removePath(target))
|
|
230
|
+
removed.push(rel(target));
|
|
231
|
+
}
|
|
232
|
+
removed.push(...(await removeLegacy(dir, scope.root)));
|
|
233
|
+
}
|
|
234
|
+
for (const f of pointerFiles)
|
|
235
|
+
if (await removeBlock(join(scope.root, f)))
|
|
236
|
+
removed.push(`${f} (block)`);
|
|
237
|
+
console.log(removed.length ? removed.map((r) => pc.red("− ") + r).join("\n") : "No files removed.");
|
|
238
|
+
}
|
|
239
|
+
/** Read every file of an installed skill folder, keyed by its path inside the folder. */
|
|
240
|
+
async function readBundle(skillDir) {
|
|
241
|
+
const files = {};
|
|
242
|
+
for (const entry of await readdir(skillDir, { recursive: true, withFileTypes: true })) {
|
|
243
|
+
if (!entry.isFile())
|
|
244
|
+
continue;
|
|
245
|
+
const full = join(entry.parentPath, entry.name);
|
|
246
|
+
files[full.slice(skillDir.length + 1)] = await readFile(full, "utf8");
|
|
247
|
+
}
|
|
248
|
+
return files;
|
|
249
|
+
}
|
|
250
|
+
async function runCheck(flags) {
|
|
251
|
+
const scope = scopeOf(flags);
|
|
252
|
+
const ok = (m) => console.log(`${pc.green("✓")} ${m}`);
|
|
253
|
+
const warn = (m) => console.log(`${pc.yellow("!")} ${m}`);
|
|
254
|
+
const fail = (m) => {
|
|
255
|
+
console.log(`${pc.red("✗")} ${m}`);
|
|
256
|
+
process.exitCode = 1;
|
|
257
|
+
};
|
|
258
|
+
const dirs = detectInstalled(scope);
|
|
259
|
+
if (!dirs.length) {
|
|
260
|
+
fail(`RYUX is not installed ${scope.global ? "in your home directory" : "in this project"}. Run: ${CLI_CMD} init`);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
for (const dir of dirs) {
|
|
264
|
+
const where = rel(dir);
|
|
265
|
+
const skillDir = join(dir, SKILL_NAME);
|
|
266
|
+
const leftovers = OLD_SKILL_DIRS.filter((n) => existsSync(join(dir, n)));
|
|
267
|
+
if (leftovers.length)
|
|
268
|
+
fail(`${where}: RYUX 1.x folders remain (${leftovers.length}). Run: ${CLI_CMD} update`);
|
|
269
|
+
if (!existsSync(join(skillDir, "SKILL.md"))) {
|
|
270
|
+
fail(`${where}: no ${SKILL_NAME}/ skill. Run: ${CLI_CMD} update`);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
const report = validateBundle(await readBundle(skillDir));
|
|
274
|
+
for (const e of report.errors)
|
|
275
|
+
fail(`${where}/${SKILL_NAME}: ${e}`);
|
|
276
|
+
for (const w of report.warnings)
|
|
277
|
+
warn(`${where}/${SKILL_NAME}: ${w}`);
|
|
278
|
+
if (report.version && report.version !== VERSION)
|
|
279
|
+
warn(`${where}: installed RYUX ${report.version}, this CLI is ${VERSION}. Run: ${CLI_CMD} update`);
|
|
280
|
+
if (!report.errors.length)
|
|
281
|
+
ok(`${where}/${SKILL_NAME}: complete, RYUX ${report.version}`);
|
|
282
|
+
}
|
|
283
|
+
if (!scope.global) {
|
|
284
|
+
const agentsHere = agentsAt(scope, true);
|
|
285
|
+
for (const file of pointersFor(agentsHere)) {
|
|
286
|
+
if (hasBlock(await readIfExists(join(scope.root, file))))
|
|
287
|
+
ok(`${file}: RYUX pointer block present`);
|
|
288
|
+
else
|
|
289
|
+
warn(`${file}: no RYUX pointer block. Run: ${CLI_CMD} update`);
|
|
290
|
+
}
|
|
291
|
+
const context = contextBody(await readIfExists(join(scope.root, CONTEXT_FILE)));
|
|
292
|
+
if (context !== null) {
|
|
293
|
+
ok(`${CONTEXT_FILE}: project context present, ${filledCount(context)} of ${ASKED_FIELDS.length} setup fields filled (blanks are treated as unknown)`);
|
|
294
|
+
}
|
|
295
|
+
else
|
|
296
|
+
warn(`${CONTEXT_FILE}: no RYUX project context. Run: ${CLI_CMD} setup`);
|
|
297
|
+
}
|
|
298
|
+
if (!process.exitCode)
|
|
299
|
+
console.log(pc.green("\nRYUX is ready."));
|
|
300
|
+
}
|
|
301
|
+
/** Facts init can detect without guessing: the project name and where design tokens live. */
|
|
302
|
+
function detectContext(root) {
|
|
303
|
+
let name = null;
|
|
304
|
+
try {
|
|
305
|
+
name = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).name ?? null;
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
name = null;
|
|
309
|
+
}
|
|
310
|
+
const candidates = [
|
|
311
|
+
"tailwind.config.ts", "tailwind.config.js", "tailwind.config.mjs", "tokens.json", "design-tokens.json",
|
|
312
|
+
"src/components", "components", "app/components", "src/styles", "styles",
|
|
313
|
+
];
|
|
314
|
+
return { name, system: candidates.filter((c) => existsSync(join(root, c))) };
|
|
315
|
+
}
|
|
316
|
+
/** A choice whose value is empty means "not decided": the field stays unknown. */
|
|
317
|
+
const CONTEXT_FIELDS = [
|
|
318
|
+
{
|
|
319
|
+
label: "Product", hint: "(what it does, in one sentence:)",
|
|
320
|
+
ask: { flag: "product", group: "Project", question: "What kind of product is it?", options: [
|
|
321
|
+
{ value: "Online store", label: "Online store" },
|
|
322
|
+
{ value: "Payments or finance", label: "Payments or finance" },
|
|
323
|
+
{ value: "Marketplace", label: "Marketplace" },
|
|
324
|
+
{ value: "Booking or scheduling", label: "Booking or scheduling" },
|
|
325
|
+
{ value: "SaaS or dashboard", label: "SaaS or dashboard", hint: "a tool people use for work" },
|
|
326
|
+
{ value: "Content or media", label: "Content or media" },
|
|
327
|
+
{ value: "Internal tool", label: "Internal tool", hint: "used by your own team" },
|
|
328
|
+
] },
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
label: "Current work", hint: "",
|
|
332
|
+
ask: { flag: "work", group: "Project", question: "What are you building right now?", options: [
|
|
333
|
+
{ value: "A new product", label: "A new product" },
|
|
334
|
+
{ value: "A new feature", label: "A new feature" },
|
|
335
|
+
{ value: "A new screen", label: "A new screen" },
|
|
336
|
+
{ value: "An existing flow", label: "An existing flow", hint: "changing something that already exists" },
|
|
337
|
+
{ value: "A redesign", label: "A redesign" },
|
|
338
|
+
{ value: "", label: "Not decided yet", hint: "stays unknown" },
|
|
339
|
+
] },
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
label: "Audience", hint: "",
|
|
343
|
+
ask: { flag: "audience", group: "Project", question: "Who is it for?", options: [
|
|
344
|
+
{ value: "Consumers", label: "Consumers", hint: "people using it for themselves" },
|
|
345
|
+
{ value: "Small business owners", label: "Small business owners" },
|
|
346
|
+
{ value: "Professionals at work", label: "Professionals at work" },
|
|
347
|
+
{ value: "An internal team", label: "An internal team" },
|
|
348
|
+
{ value: "Developers", label: "Developers" },
|
|
349
|
+
] },
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
label: "Market and locale", hint: "(for example Indonesia, id-ID, Rupiah; or global, en-US, USD)",
|
|
353
|
+
ask: { flag: "market", group: "Project", question: "Where is it used? This sets language and currency.", options: [
|
|
354
|
+
{ value: "Global, several markets", label: "Global, several markets" },
|
|
355
|
+
{ value: "Indonesia (id-ID, IDR)", label: "Indonesia", hint: "id-ID, Rupiah" },
|
|
356
|
+
{ value: "United States (en-US, USD)", label: "United States", hint: "en-US, US dollar" },
|
|
357
|
+
{ value: "", label: "Not decided yet", hint: "stays unknown" },
|
|
358
|
+
] },
|
|
359
|
+
},
|
|
360
|
+
{ label: "Brand and design system", hint: "" },
|
|
361
|
+
{ label: "Evidence sources", hint: `RYUX MCP (hosted server not live yet; local: ${MCP_LOCAL_ADD_CMD}), Figma files, reference URLs:` },
|
|
362
|
+
{
|
|
363
|
+
label: "Constraints", hint: "platforms, accessibility target (for example WCAG 2.2 AA), what must not change:",
|
|
364
|
+
ask: { flag: "constraints", group: "Project", question: "What must RYUX keep or support?", multi: true, options: [
|
|
365
|
+
{ value: "Mobile app", label: "Mobile app" },
|
|
366
|
+
{ value: "Web", label: "Web" },
|
|
367
|
+
{ value: "Responsive web", label: "Responsive web", hint: "one site for phone and desktop" },
|
|
368
|
+
{ value: "Web and mobile app", label: "Web and mobile app" },
|
|
369
|
+
{ value: "Existing design system", label: "Existing design system" },
|
|
370
|
+
{ value: "Existing brand identity", label: "Existing brand identity" },
|
|
371
|
+
{ value: "Accessibility, WCAG 2.2 AA", label: "Accessibility", hint: "WCAG 2.2 AA" },
|
|
372
|
+
] },
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
label: "Design intent", hint: "(what users should understand, feel, and do)",
|
|
376
|
+
ask: { flag: "intent", group: "Design", question: "What should people get from it?", options: [
|
|
377
|
+
{ value: "Understand their status at a glance", label: "Understand their status at a glance" },
|
|
378
|
+
{ value: "Finish a task quickly", label: "Finish a task quickly" },
|
|
379
|
+
{ value: "Feel safe and in control", label: "Feel safe and in control" },
|
|
380
|
+
{ value: "Learn the product step by step", label: "Learn the product step by step" },
|
|
381
|
+
] },
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
label: "UX direction", hint: "",
|
|
385
|
+
ask: { flag: "ux", group: "Design", question: "How should the experience help users?", options: [
|
|
386
|
+
{ value: "Fast for people who use it often", label: "Fast for people who use it often" },
|
|
387
|
+
{ value: "Clear for people using it for the first time", label: "Clear for people using it for the first time" },
|
|
388
|
+
{ value: "Easy to scan and understand", label: "Easy to scan and understand" },
|
|
389
|
+
{ value: "Guided through a complex task", label: "Guided through a complex task" },
|
|
390
|
+
] },
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
label: "UI direction", hint: "(character, for example calm, trustworthy, restrained)",
|
|
394
|
+
ask: { flag: "ui", group: "Design", question: "What character should it have? A character, not colors or fonts. Pick up to 3.", multi: true, max: 3, options: [
|
|
395
|
+
{ value: "Calm", label: "Calm" },
|
|
396
|
+
{ value: "Trustworthy", label: "Trustworthy" },
|
|
397
|
+
{ value: "Friendly", label: "Friendly" },
|
|
398
|
+
{ value: "Playful", label: "Playful" },
|
|
399
|
+
{ value: "Bold", label: "Bold" },
|
|
400
|
+
{ value: "Minimal", label: "Minimal" },
|
|
401
|
+
{ value: "Information-rich", label: "Information-rich" },
|
|
402
|
+
{ value: "Premium", label: "Premium" },
|
|
403
|
+
] },
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
label: "Motion direction", hint: "(feel, what motion communicates, what to avoid)",
|
|
407
|
+
ask: { flag: "motion", group: "Design", question: "How much motion?", options: [
|
|
408
|
+
{ value: "No motion unless necessary", label: "No motion unless necessary" },
|
|
409
|
+
{ value: "Subtle, only to show change", label: "Subtle, only to show change" },
|
|
410
|
+
{ value: "Expressive, part of the brand", label: "Expressive, part of the brand" },
|
|
411
|
+
] },
|
|
412
|
+
},
|
|
413
|
+
];
|
|
414
|
+
const ASKED_FIELDS = CONTEXT_FIELDS.filter((f) => f.ask);
|
|
415
|
+
const CONTEXT_MARKS = [CONTEXT_START, CONTEXT_END];
|
|
416
|
+
const fieldLine = (label, text) => `- **${label}**:${text ? ` ${text}` : ""}`;
|
|
417
|
+
const fieldRe = (label) => new RegExp(`^- \\*\\*${label}\\*\\*:.*$`, "m");
|
|
418
|
+
function contextBlock(root) {
|
|
419
|
+
const { name, system } = detectContext(root);
|
|
420
|
+
const initial = {
|
|
421
|
+
Product: `${name ?? ""} ${CONTEXT_FIELDS[0].hint}`,
|
|
422
|
+
"Brand and design system": system.map((x) => `\`${x}\``).join(", "),
|
|
423
|
+
};
|
|
424
|
+
return `## RYUX project context
|
|
425
|
+
|
|
426
|
+
RYUX reads this block before any design task. These answers are the team's context and direction,
|
|
427
|
+
not evidence or verified facts: RYUX follows them and still backs its decisions with evidence.
|
|
428
|
+
Blanks stay unknown. UI direction describes character, not colors, fonts, or spacing.
|
|
429
|
+
|
|
430
|
+
${CONTEXT_FIELDS.map((f) => fieldLine(f.label, initial[f.label] ?? f.hint)).join("\n")}`;
|
|
431
|
+
}
|
|
432
|
+
/** The body between the context markers, or null when there is no block. */
|
|
433
|
+
function contextBody(content) {
|
|
434
|
+
if (!content)
|
|
435
|
+
return null;
|
|
436
|
+
const start = content.indexOf(CONTEXT_START);
|
|
437
|
+
const end = content.indexOf(CONTEXT_END, start);
|
|
438
|
+
return start === -1 || end === -1 ? null : content.slice(start + CONTEXT_START.length, end).trim();
|
|
439
|
+
}
|
|
440
|
+
/** What the user (or detection) wrote for a field; template hints count as blank. */
|
|
441
|
+
function fieldValue(body, f) {
|
|
442
|
+
const line = body.match(fieldRe(f.label))?.[0];
|
|
443
|
+
if (!line)
|
|
444
|
+
return "";
|
|
445
|
+
const value = line.slice(line.indexOf(":") + 1).trim();
|
|
446
|
+
if (!f.hint)
|
|
447
|
+
return value;
|
|
448
|
+
if (value === f.hint)
|
|
449
|
+
return "";
|
|
450
|
+
return value.endsWith(` ${f.hint}`) ? value.slice(0, -f.hint.length).trim() : value;
|
|
451
|
+
}
|
|
452
|
+
const filledCount = (body) => ASKED_FIELDS.filter((f) => fieldValue(body, f)).length;
|
|
453
|
+
/** Replace only the answered field lines; every other line of the block stays as the user left it. */
|
|
454
|
+
async function writeContext(root, answers) {
|
|
455
|
+
const path = join(root, CONTEXT_FILE);
|
|
456
|
+
let body = contextBody(await readIfExists(path)) ?? contextBlock(root);
|
|
457
|
+
for (const f of ASKED_FIELDS) {
|
|
458
|
+
const value = answers[f.label]?.trim();
|
|
459
|
+
if (!value)
|
|
460
|
+
continue;
|
|
461
|
+
const line = fieldLine(f.label, value);
|
|
462
|
+
if (fieldRe(f.label).test(body)) {
|
|
463
|
+
body = body.replace(fieldRe(f.label), () => line);
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
// A field added in a later version (e.g. Current work): place it after the field before it.
|
|
467
|
+
const prev = CONTEXT_FIELDS[CONTEXT_FIELDS.indexOf(f) - 1];
|
|
468
|
+
const anchor = prev && body.match(fieldRe(prev.label));
|
|
469
|
+
body = anchor ? body.replace(fieldRe(prev.label), (m) => `${m}\n${line}`) : `${body}\n${line}`;
|
|
470
|
+
}
|
|
471
|
+
await upsertBlock(path, body, CONTEXT_MARKS);
|
|
472
|
+
return body;
|
|
473
|
+
}
|
|
474
|
+
/** A dim second line under a prompt's question: which keys move, select, and what Enter does here. */
|
|
475
|
+
function keys(...parts) {
|
|
476
|
+
return `\n${pc.gray("│")} ${pc.dim(parts.join(" · "))}`;
|
|
477
|
+
}
|
|
478
|
+
const YES_NO = "←/→ or y/n choose";
|
|
479
|
+
const TEXT_KEYS = "Type, then Enter save (empty skips)";
|
|
480
|
+
const OWN = "\u0000own";
|
|
481
|
+
const KEEP = "\u0000keep";
|
|
482
|
+
const SKIP = "\u0000skip";
|
|
483
|
+
/** One select: Keep (when set), the choices, Write my own, Skip. Returns "" for skip, null on cancel. */
|
|
484
|
+
async function askOne(question, options, current) {
|
|
485
|
+
const v = await p.select({
|
|
486
|
+
message: `${question}${keys("↑/↓ move", "Enter choose and go on")}`,
|
|
487
|
+
options: [
|
|
488
|
+
...(current ? [{ value: KEEP, label: `Keep: ${current}` }] : []),
|
|
489
|
+
...options.map((o) => ({ value: o.value || SKIP, label: o.label, hint: o.hint })),
|
|
490
|
+
{ value: OWN, label: "Write my own…" },
|
|
491
|
+
{ value: SKIP, label: "Skip, leave unknown" },
|
|
492
|
+
],
|
|
493
|
+
initialValue: current ? KEEP : SKIP,
|
|
494
|
+
});
|
|
495
|
+
if (p.isCancel(v))
|
|
496
|
+
return null;
|
|
497
|
+
if (v === KEEP || v === SKIP)
|
|
498
|
+
return "";
|
|
499
|
+
if (v === OWN) {
|
|
500
|
+
const t = await p.text({ message: `${question}${keys(TEXT_KEYS)}`, placeholder: "Enter to skip" });
|
|
501
|
+
return p.isCancel(t) ? null : (t ?? "").trim();
|
|
502
|
+
}
|
|
503
|
+
return v;
|
|
504
|
+
}
|
|
505
|
+
/** A multiselect: pick any, or none to skip. Returns "" for skip, null on cancel. */
|
|
506
|
+
async function askMany(question, options, current, max) {
|
|
507
|
+
const parts = current ? current.split(",").map((x) => x.trim()).filter(Boolean) : [];
|
|
508
|
+
const known = options.map((o) => o.value);
|
|
509
|
+
const custom = parts.filter((x) => !known.includes(x)).join(", ");
|
|
510
|
+
for (;;) {
|
|
511
|
+
const v = await p.multiselect({
|
|
512
|
+
message: `${question}${keys("↑/↓ move", "Space select", "Enter go on (nothing selected skips)")}`,
|
|
513
|
+
options: [
|
|
514
|
+
...(custom ? [{ value: KEEP, label: `Keep: ${custom}` }] : []),
|
|
515
|
+
...options.map((o) => ({ value: o.value, label: o.label, hint: o.hint })),
|
|
516
|
+
{ value: OWN, label: "Write my own…" },
|
|
517
|
+
],
|
|
518
|
+
initialValues: [...(custom ? [KEEP] : []), ...parts.filter((x) => known.includes(x))],
|
|
519
|
+
required: false,
|
|
520
|
+
});
|
|
521
|
+
if (p.isCancel(v))
|
|
522
|
+
return null;
|
|
523
|
+
const picked = v;
|
|
524
|
+
const values = picked.filter((x) => x !== KEEP && x !== OWN);
|
|
525
|
+
if (picked.includes(KEEP))
|
|
526
|
+
values.push(custom);
|
|
527
|
+
if (picked.includes(OWN)) {
|
|
528
|
+
const t = await p.text({ message: `${question}${keys(TEXT_KEYS)}`, placeholder: "Enter to skip" });
|
|
529
|
+
if (p.isCancel(t))
|
|
530
|
+
return null;
|
|
531
|
+
if (t?.trim())
|
|
532
|
+
values.push(t.trim());
|
|
533
|
+
}
|
|
534
|
+
if (max && values.length > max) {
|
|
535
|
+
p.log.warn(`Pick up to ${max}.`);
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
return values.join(", ");
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
/** Ask the setup questions as choices; every answer can be skipped. Returns null when cancelled. */
|
|
542
|
+
async function askContext(root) {
|
|
543
|
+
const body = contextBody(await readIfExists(join(root, CONTEXT_FILE))) ?? contextBlock(root);
|
|
544
|
+
const answers = {};
|
|
545
|
+
let group = "";
|
|
546
|
+
for (const f of ASKED_FIELDS) {
|
|
547
|
+
const ask = f.ask;
|
|
548
|
+
if (ask.group !== group) {
|
|
549
|
+
group = ask.group;
|
|
550
|
+
p.log.step(group === "Project" ? "Project setup" : "Design setup");
|
|
551
|
+
}
|
|
552
|
+
const current = fieldValue(body, f);
|
|
553
|
+
const v = ask.multi ? await askMany(ask.question, ask.options, current, ask.max) : await askOne(ask.question, ask.options, current);
|
|
554
|
+
if (v === null)
|
|
555
|
+
return null;
|
|
556
|
+
answers[f.label] = v;
|
|
557
|
+
if (f.label === "Product" && v) {
|
|
558
|
+
const t = await p.text({ message: `In one sentence, what does it do?${keys(TEXT_KEYS)}`, placeholder: "Enter to skip" });
|
|
559
|
+
if (p.isCancel(t))
|
|
560
|
+
return null;
|
|
561
|
+
if (t?.trim())
|
|
562
|
+
answers[f.label] = `${v}: ${t.trim()}`;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return answers;
|
|
566
|
+
}
|
|
567
|
+
async function setupInteractive(root) {
|
|
568
|
+
p.log.info("Teach RYUX about this project. Every question can be skipped: blanks stay unknown, and RYUX asks when they matter.");
|
|
569
|
+
const answers = await askContext(root);
|
|
570
|
+
if (!answers)
|
|
571
|
+
return false;
|
|
572
|
+
const body = await writeContext(root, answers);
|
|
573
|
+
p.log.success(`${CONTEXT_FILE} updated · ${filledCount(body)} of ${ASKED_FIELDS.length} setup fields filled`);
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
async function runSetup(flags) {
|
|
577
|
+
if (flags.global) {
|
|
578
|
+
console.error(`setup is per project. Run it inside the project folder: ${CLI_CMD} setup`);
|
|
579
|
+
process.exitCode = 1;
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const root = scopeOf(flags).root;
|
|
583
|
+
const fromFlags = {};
|
|
584
|
+
for (const f of ASKED_FIELDS) {
|
|
585
|
+
const v = flags[f.ask.flag];
|
|
586
|
+
if (typeof v === "string")
|
|
587
|
+
fromFlags[f.label] = v;
|
|
588
|
+
}
|
|
589
|
+
if (Object.keys(fromFlags).length) {
|
|
590
|
+
const body = await writeContext(root, fromFlags);
|
|
591
|
+
console.log(`${pc.green("✓ ")}${CONTEXT_FILE} updated · ${filledCount(body)} of ${ASKED_FIELDS.length} setup fields filled`);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
if (!process.stdin.isTTY) {
|
|
595
|
+
const names = ASKED_FIELDS.map((f) => `--${f.ask.flag}`).join(" ");
|
|
596
|
+
console.error(`setup asks questions and needs a terminal. Without one, pass fields as flags (${names}) or edit ${CONTEXT_FILE}.`);
|
|
597
|
+
process.exitCode = 1;
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
p.intro(pc.bgCyan(pc.black(" RYUX setup ")));
|
|
601
|
+
if (!(await setupInteractive(root)))
|
|
602
|
+
return cancel();
|
|
603
|
+
p.outro(`RYUX is ready. Next: ${CLI_CMD} check`);
|
|
604
|
+
}
|
|
605
|
+
async function runInit(flags) {
|
|
606
|
+
if (flags.global) {
|
|
607
|
+
console.error(`init sets up a project. For a global install, use: ${CLI_CMD} install --global`);
|
|
608
|
+
process.exitCode = 1;
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
const scope = scopeOf(flags);
|
|
612
|
+
const path = join(scope.root, CONTEXT_FILE);
|
|
613
|
+
const had = hasBlock(await readIfExists(path), CONTEXT_MARKS);
|
|
614
|
+
await runInstall(flags);
|
|
615
|
+
if (process.exitCode)
|
|
616
|
+
return;
|
|
617
|
+
if (had) {
|
|
618
|
+
console.log(`${pc.green("✓ ")}${CONTEXT_FILE}: project context already present (left as you wrote it). Edit it with: ${CLI_CMD} setup`);
|
|
619
|
+
}
|
|
620
|
+
else if (!hasBlock(await readIfExists(path), CONTEXT_MARKS)) {
|
|
621
|
+
await upsertBlock(path, contextBlock(scope.root), CONTEXT_MARKS);
|
|
622
|
+
console.log(`${pc.green("✓ ")}${CONTEXT_FILE}: project context added. Fill it in with: ${CLI_CMD} setup`);
|
|
623
|
+
}
|
|
624
|
+
console.log(`\nNext: ${CLI_CMD} check`);
|
|
625
|
+
}
|
|
626
|
+
function help() {
|
|
627
|
+
const agents = AGENT_TARGETS.map((a) => ` ${a.id.padEnd(12)} ${a.label.padEnd(15)} ${a.dir} (global ~/${a.globalDir})`).join("\n");
|
|
628
|
+
console.log(`ryux v${VERSION}: install RYUX, design intelligence for AI agents and designers, into your agents.
|
|
629
|
+
One skill, five entry points: Analyze, Design, Build, Critique, QA. RYUX picks the knowledge each task needs.
|
|
630
|
+
|
|
631
|
+
Usage:
|
|
632
|
+
${CLI_CMD} [command] [options]
|
|
633
|
+
|
|
634
|
+
Commands:
|
|
635
|
+
install Install RYUX (interactive, or with flags). Interactive also offers setup. Default. Start here.
|
|
636
|
+
setup Teach RYUX about this project: a few skippable questions, saved to DESIGN.md.
|
|
637
|
+
init Install RYUX and add the DESIGN.md project context block.
|
|
638
|
+
check Check the install: files, version, references, pointers, project context.
|
|
639
|
+
update Update an install to this version (also migrates RYUX 1.x folders).
|
|
640
|
+
remove Remove RYUX and the marked blocks.
|
|
641
|
+
help Show this help.
|
|
642
|
+
|
|
643
|
+
Agents:
|
|
644
|
+
${agents}
|
|
645
|
+
${AGENTS_MD_INLINE.id.padEnd(12)} any other agent: RYUX inline in AGENTS.md (project only)
|
|
646
|
+
|
|
647
|
+
Options:
|
|
648
|
+
--agent <list|all> agents to install for (non-interactive)
|
|
649
|
+
--global install into your home directory instead of this project
|
|
650
|
+
--mcp print the ryux MCP connect command
|
|
651
|
+
--yes skip the confirmation (remove)
|
|
652
|
+
--product, --work, --audience, --market, --constraints, --intent, --ux, --ui, --motion <text>
|
|
653
|
+
set project context fields without questions (setup)
|
|
654
|
+
|
|
655
|
+
Examples:
|
|
656
|
+
${CLI_CMD}
|
|
657
|
+
${CLI_CMD} setup
|
|
658
|
+
${CLI_CMD} setup --audience "small business owners" --market "Indonesia, id-ID, IDR"
|
|
659
|
+
${CLI_CMD} init --agent claude
|
|
660
|
+
${CLI_CMD} check
|
|
661
|
+
${CLI_CMD} install --agent claude,cursor,codex
|
|
662
|
+
${CLI_CMD} install --agent all
|
|
663
|
+
${CLI_CMD} install --agent claude --global
|
|
664
|
+
${CLI_CMD} update
|
|
665
|
+
${CLI_CMD} remove --yes`);
|
|
666
|
+
}
|
|
667
|
+
async function main() {
|
|
668
|
+
const argv = process.argv.slice(2);
|
|
669
|
+
const first = argv[0];
|
|
670
|
+
if (argv.includes("--help") || argv.includes("-h") || first === "help")
|
|
671
|
+
return help();
|
|
672
|
+
if (argv.includes("--version") || argv.includes("-v") || first === "version") {
|
|
673
|
+
console.log(VERSION);
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
const cmd = first && !first.startsWith("--") ? first : "install";
|
|
677
|
+
const flags = parseFlags(first && first.startsWith("--") ? argv : argv.slice(1));
|
|
678
|
+
switch (cmd) {
|
|
679
|
+
case "update":
|
|
680
|
+
return runUpdate(flags);
|
|
681
|
+
case "check":
|
|
682
|
+
case "doctor":
|
|
683
|
+
return runCheck(flags);
|
|
684
|
+
case "init":
|
|
685
|
+
return runInit(flags);
|
|
686
|
+
case "setup":
|
|
687
|
+
return runSetup(flags);
|
|
688
|
+
case "remove":
|
|
689
|
+
return runRemove(flags);
|
|
690
|
+
case "install":
|
|
691
|
+
return runInstall(flags);
|
|
692
|
+
default:
|
|
693
|
+
console.error(`Unknown command: ${cmd}\n`);
|
|
694
|
+
help();
|
|
695
|
+
process.exitCode = 1;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
main().catch((err) => {
|
|
699
|
+
console.error(err instanceof Error ? err.message : err);
|
|
700
|
+
process.exitCode = 1;
|
|
701
|
+
});
|