@yaawn/workflow 0.3.1 → 0.5.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.
Files changed (96) hide show
  1. package/README.md +22 -43
  2. package/agents/personas/yaawn-product-owner.md +4 -4
  3. package/agents/skills/yaawn-create-plan/SKILL.md +83 -50
  4. package/agents/skills/yaawn-extract-learnings/SKILL.md +138 -0
  5. package/agents/skills/yaawn-grill-bug-report/SKILL.md +7 -0
  6. package/agents/skills/yaawn-grill-other/SKILL.md +7 -0
  7. package/agents/skills/yaawn-grill-prototype/SKILL.md +38 -0
  8. package/agents/skills/yaawn-grill-prototype/references/prototype-checklist.md +23 -0
  9. package/agents/skills/yaawn-grill-refactor/SKILL.md +7 -0
  10. package/agents/skills/yaawn-grill-spike/SKILL.md +7 -0
  11. package/agents/skills/yaawn-grill-story/SKILL.md +8 -1
  12. package/agents/skills/yaawn-grill-story/references/concept-challenge.md +12 -2
  13. package/agents/skills/yaawn-grill-story/references/story-checklist.md +2 -1
  14. package/agents/skills/yaawn-implement-plan/SKILL.md +10 -8
  15. package/agents/skills/yaawn-implement-plan/scripts/agent.ts +1 -1
  16. package/agents/skills/yaawn-implement-plan/scripts/overnight.ts +7 -4
  17. package/agents/skills/yaawn-implement-plan/scripts/parallel.ts +155 -12
  18. package/agents/skills/yaawn-implement-plan/scripts/processes.ts +161 -1
  19. package/agents/skills/yaawn-implement-plan/scripts/targets.ts +13 -2
  20. package/agents/skills/yaawn-implement-plan/scripts/worktrees.ts +219 -52
  21. package/agents/skills/yaawn-prepare-plan/SKILL.md +20 -19
  22. package/agents/skills/yaawn-save-plan/SKILL.md +26 -7
  23. package/agents/skills/yaawn-save-plan/scripts/plan-body.ts +75 -9
  24. package/agents/skills/yaawn-save-plan/scripts/save-plan.ts +97 -1
  25. package/agents/skills/yaawn-split-story/SKILL.md +1 -1
  26. package/agents/skills/yaawn-verify-executed-plan/SKILL.md +28 -13
  27. package/agents/skills/yaawn-verify-executed-plan/scripts/overnight.ts +44 -12
  28. package/agents/skills/yaawn-write-bug-report/SKILL.md +6 -5
  29. package/agents/skills/yaawn-write-other/SKILL.md +6 -5
  30. package/agents/skills/yaawn-write-prototype/SKILL.md +96 -0
  31. package/agents/skills/yaawn-write-refactor/SKILL.md +6 -5
  32. package/agents/skills/yaawn-write-spike/SKILL.md +1 -1
  33. package/agents/skills/yaawn-write-user-story/SKILL.md +4 -3
  34. package/agents/yaawn-glossary.md +6 -5
  35. package/config/install-tasks.json +0 -1
  36. package/config/wizard.json +5 -10
  37. package/dist/cli/args.js +66 -0
  38. package/dist/cli/errors.js +103 -0
  39. package/dist/cli/exit-codes.js +3 -0
  40. package/dist/cli/index.js +11 -0
  41. package/dist/cli/install-report.js +92 -0
  42. package/dist/cli/install.js +124 -0
  43. package/dist/cli/main.js +17 -0
  44. package/dist/cli/package-root.js +17 -0
  45. package/dist/cli/project-root.js +11 -0
  46. package/dist/cli/run.js +141 -0
  47. package/dist/cli/session.js +1 -0
  48. package/dist/cli/terminal.js +5 -0
  49. package/dist/cli/uninstall.js +89 -0
  50. package/dist/cli/version.js +7 -0
  51. package/dist/config/agents.js +108 -0
  52. package/dist/config/config.js +118 -0
  53. package/dist/config/errors.js +45 -0
  54. package/dist/config/index.js +11 -0
  55. package/dist/config/manifest.js +28 -0
  56. package/dist/config/messages.js +86 -0
  57. package/dist/config/names.js +40 -0
  58. package/dist/config/paths.js +73 -0
  59. package/dist/config/settings.js +101 -0
  60. package/dist/config/strict-json.js +119 -0
  61. package/dist/config/tasks.js +77 -0
  62. package/dist/config/wizard.js +139 -0
  63. package/dist/display/index.js +3 -0
  64. package/dist/display/palette.js +61 -0
  65. package/dist/display/renderer.js +169 -0
  66. package/dist/display/report.js +15 -0
  67. package/dist/display/sanitize.js +10 -0
  68. package/dist/link/errors.js +34 -0
  69. package/dist/link/fs-utils.js +79 -0
  70. package/dist/link/index.js +4 -0
  71. package/dist/link/install.js +20 -0
  72. package/dist/link/linker.js +137 -0
  73. package/dist/link/memory.js +221 -0
  74. package/dist/link/names.js +30 -0
  75. package/dist/link/types.js +30 -0
  76. package/dist/link/uninstall.js +90 -0
  77. package/dist/sync/apply.js +74 -0
  78. package/dist/sync/changes.js +31 -0
  79. package/dist/sync/fs-utils.js +63 -0
  80. package/dist/sync/index.js +7 -0
  81. package/dist/sync/install.js +57 -0
  82. package/dist/sync/languages.js +80 -0
  83. package/dist/sync/plan.js +146 -0
  84. package/dist/sync/projects.js +294 -0
  85. package/dist/sync/uninstall.js +234 -0
  86. package/dist/wizard/build.js +89 -0
  87. package/dist/wizard/index.js +5 -0
  88. package/dist/wizard/items.js +83 -0
  89. package/dist/wizard/prompts.js +167 -0
  90. package/dist/wizard/section.js +143 -0
  91. package/dist/wizard/wizard.js +73 -0
  92. package/github/ISSUE_TEMPLATE/yaawn-prototype.md +48 -0
  93. package/package.json +11 -10
  94. package/yaawn/yaawn-user-roles.md +1 -1
  95. package/agents/instructions/output-styles/yaawn-eli5.instructions.md +0 -45
  96. package/scripts/install.js +0 -73
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ import process from "node:process";
3
+ import { installProcessSafetyNet } from "./errors.js";
4
+ import { run } from "./run.js";
5
+ import { isInteractive } from "./terminal.js";
6
+ installProcessSafetyNet(process.stderr);
7
+ void run(process.argv.slice(2), {
8
+ stdin: process.stdin,
9
+ stdout: process.stdout,
10
+ stderr: process.stderr,
11
+ env: process.env,
12
+ cwd: () => process.cwd(),
13
+ isTTY: () => isInteractive(process),
14
+ entryUrl: import.meta.url,
15
+ }).then((code) => {
16
+ process.exitCode = code;
17
+ });
@@ -0,0 +1,17 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ /** This file compiles to dist/cli/, two levels below the package root. */
4
+ const compiledDirToPackageRoot = "../../";
5
+ /**
6
+ * Returns the package root, which holds config/, agents/, yaawn/, github/
7
+ * and package.json: the --package-root value when given, kept for
8
+ * compatibility with the old launcher, else the directory two levels above
9
+ * the compiled entry. Returns undefined when --package-root was given
10
+ * without a value.
11
+ */
12
+ export function resolvePackageRoot(flagValue, entryUrl = import.meta.url) {
13
+ if (flagValue !== undefined) {
14
+ return flagValue === "" ? undefined : path.resolve(flagValue);
15
+ }
16
+ return fileURLToPath(new URL(compiledDirToPackageRoot, entryUrl));
17
+ }
@@ -0,0 +1,11 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ const PROJECT_ROOT_ENV = "INIT_CWD";
4
+ /**
5
+ * Prefers INIT_CWD, which npm sets to the directory npx was started in,
6
+ * over the working directory.
7
+ */
8
+ export function resolveProjectRoot(env = process.env, workingDirectory = process.cwd()) {
9
+ const initCwd = env[PROJECT_ROOT_ENV];
10
+ return path.resolve(initCwd !== undefined && initCwd !== "" ? initCwd : workingDirectory);
11
+ }
@@ -0,0 +1,141 @@
1
+ import { agents, fill, loadConfig, readPackageVersion } from "../config/index.js";
2
+ import { MODE_UNINSTALL, Renderer } from "../display/index.js";
3
+ import { clackPrompts } from "../wizard/index.js";
4
+ import { parseArgs } from "./args.js";
5
+ import { describeError, detailTemplates, fallbackMissingPackageRoot, isPromptCancellation, printError, runWithSafetyNet, templatesFrom, } from "./errors.js";
6
+ import { EXIT_ERROR, EXIT_INTERRUPTED, EXIT_OK } from "./exit-codes.js";
7
+ import { Installer } from "./install.js";
8
+ import { resolvePackageRoot } from "./package-root.js";
9
+ import { resolveProjectRoot } from "./project-root.js";
10
+ import { Uninstaller } from "./uninstall.js";
11
+ import { helpText, versionText } from "./version.js";
12
+ /**
13
+ * Runs the installer with args (without the program name) and returns the
14
+ * exit code: 0 on success, 1 on any failure, 130 when cancelled. The
15
+ * config is loaded and validated, and a terminal required, before
16
+ * anything is written; an escaped error becomes one sanitized stderr line.
17
+ */
18
+ export function run(args, deps) {
19
+ return runWithSafetyNet(deps.stderr, () => runChecked(parseArgs(args), deps));
20
+ }
21
+ async function runChecked(options, deps) {
22
+ const packageRoot = resolvePackageRoot(options.packageRoot, deps.entryUrl);
23
+ if (packageRoot === undefined) {
24
+ printError(deps.stderr, fallbackMissingPackageRoot);
25
+ return EXIT_ERROR;
26
+ }
27
+ const config = loadConfig(packageRoot);
28
+ const messages = config.wizard.messages;
29
+ const templates = templatesFrom(messages);
30
+ const version = readVersion(packageRoot, templates, deps.stderr);
31
+ if (version === undefined) {
32
+ return EXIT_ERROR;
33
+ }
34
+ const early = earlyExit(options, messages, version, deps);
35
+ if (early !== undefined) {
36
+ return early;
37
+ }
38
+ const createUi = deps.createUi ?? createRenderer;
39
+ const session = {
40
+ config,
41
+ packageRoot,
42
+ projectRoot: resolveProjectRoot(deps.env, deps.cwd()),
43
+ version,
44
+ ui: createUi(messages, config.settings.brandColor, deps.stdout),
45
+ templates,
46
+ };
47
+ const wizardPrompts = {
48
+ prompts: deps.prompts ?? clackPrompts,
49
+ context: { input: deps.stdin, output: deps.stdout },
50
+ };
51
+ return new Dispatcher(session, wizardPrompts, deps.stderr).dispatch(options);
52
+ }
53
+ function createRenderer(messages, brandColor, output) {
54
+ return new Renderer(messages, brandColor, output);
55
+ }
56
+ function readVersion(packageRoot, templates, stderr) {
57
+ try {
58
+ return readPackageVersion(packageRoot);
59
+ }
60
+ catch (error) {
61
+ printError(stderr, describeError(templates, error));
62
+ return undefined;
63
+ }
64
+ }
65
+ /** Handles the runs that end before any flow starts: a removed flag, invalid --agent, help, version and the missing terminal. */
66
+ function earlyExit(options, messages, version, deps) {
67
+ if (options.removedFlag !== undefined) {
68
+ printError(deps.stderr, removedFlagMessage(options.removedFlag));
69
+ return EXIT_ERROR;
70
+ }
71
+ if (options.invalidAgent) {
72
+ printError(deps.stderr, fill(messages.invalidAgent, { agents: agents().join(", ") }));
73
+ return EXIT_ERROR;
74
+ }
75
+ if (options.help) {
76
+ deps.stdout.write(`${helpText(messages, version)}\n`);
77
+ return EXIT_OK;
78
+ }
79
+ if (options.version) {
80
+ deps.stdout.write(`${versionText(messages, version)}\n`);
81
+ return EXIT_OK;
82
+ }
83
+ if (!deps.isTTY()) {
84
+ printError(deps.stderr, options.uninstall ? messages.uninstallNoTerminal : messages.noTerminal);
85
+ return EXIT_ERROR;
86
+ }
87
+ return undefined;
88
+ }
89
+ function removedFlagMessage(flag) {
90
+ return `workflow: unknown option ${flag} — the installer is interactive only and needs a terminal`;
91
+ }
92
+ /**
93
+ * Runs the uninstall when asked for by --uninstall or, for a project that
94
+ * has yaawn installed, by the mode menu; every other run installs.
95
+ */
96
+ class Dispatcher {
97
+ session;
98
+ wizardPrompts;
99
+ stderr;
100
+ constructor(session, wizardPrompts, stderr) {
101
+ this.session = session;
102
+ this.wizardPrompts = wizardPrompts;
103
+ this.stderr = stderr;
104
+ }
105
+ async dispatch(options) {
106
+ const uninstaller = new Uninstaller(this.session);
107
+ if (options.uninstall) {
108
+ return this.guardUninstall(async () => uninstaller.run(await uninstaller.plan()));
109
+ }
110
+ const installed = await uninstaller.findInstalled();
111
+ if (installed !== undefined) {
112
+ const uninstalled = await this.guardUninstall(async () => (await this.session.ui.chooseMode()) === MODE_UNINSTALL ? uninstaller.run(installed) : undefined);
113
+ if (uninstalled !== undefined) {
114
+ return uninstalled;
115
+ }
116
+ }
117
+ return this.guardInstall(() => new Installer(this.session, this.wizardPrompts, options.agent).run());
118
+ }
119
+ guardInstall(action) {
120
+ const messages = this.session.config.wizard.messages;
121
+ return this.guard(action, messages.installCancelled, (error) => describeError(this.session.templates, error));
122
+ }
123
+ guardUninstall(action) {
124
+ const messages = this.session.config.wizard.messages;
125
+ return this.guard(action, messages.uninstallCancelled, (error) => fill(messages.uninstallFailed, { error: describeError(detailTemplates(this.session.templates), error) }));
126
+ }
127
+ /** Ctrl+C at a prompt prints cancelledText and exits 130; any other error prints one described line and exits 1. */
128
+ async guard(action, cancelledText, describe) {
129
+ try {
130
+ return await action();
131
+ }
132
+ catch (error) {
133
+ if (isPromptCancellation(error)) {
134
+ printError(this.stderr, cancelledText);
135
+ return EXIT_INTERRUPTED;
136
+ }
137
+ printError(this.stderr, describe(error));
138
+ return EXIT_ERROR;
139
+ }
140
+ }
141
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ import process from "node:process";
2
+ /** The installer is interactive only, so both ends must be a terminal. */
3
+ export function isInteractive(streams = process) {
4
+ return streams.stdin.isTTY === true && streams.stdout.isTTY === true;
5
+ }
@@ -0,0 +1,89 @@
1
+ import { fill, readPackageName } from "../config/index.js";
2
+ import { failureReason, findInstalledLinks, KIND_IMPORTS, removeLinks } from "../link/index.js";
3
+ import { applyUninstall, planUninstall, uninstallEntries, UninstallError } from "../sync/index.js";
4
+ import { describeError, detailTemplates } from "./errors.js";
5
+ import { EXIT_ERROR, EXIT_INTERRUPTED, EXIT_OK } from "./exit-codes.js";
6
+ const FAILURE_SEPARATOR = "; ";
7
+ /**
8
+ * Runs one uninstall: list everything yaawn installed, ask with "No"
9
+ * preselected, then remove the agent links first — trying every one —
10
+ * and the files after them. Nothing is removed without confirmation.
11
+ */
12
+ export class Uninstaller {
13
+ session;
14
+ constructor(session) {
15
+ this.session = session;
16
+ }
17
+ /** Lists the links and files to remove; it only reads the file system. */
18
+ async plan() {
19
+ const { config, packageRoot, projectRoot } = this.session;
20
+ return {
21
+ links: await findInstalledLinks(projectRoot),
22
+ files: await planUninstall(config, packageRoot, projectRoot),
23
+ };
24
+ }
25
+ /**
26
+ * Returns the targets when yaawn is installed. A planning error counts
27
+ * as not installed, so the install runs unchanged and reports the
28
+ * problem itself.
29
+ */
30
+ async findInstalled() {
31
+ try {
32
+ const targets = await this.plan();
33
+ return this.entries(targets).length > 0 ? targets : undefined;
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ }
39
+ async run(targets) {
40
+ const { packageRoot, ui } = this.session;
41
+ const packageName = readPackageName(packageRoot);
42
+ const entries = this.entries(targets);
43
+ if (entries.length === 0) {
44
+ ui.nothingToRemove(packageName);
45
+ return EXIT_OK;
46
+ }
47
+ if (!(await ui.confirmUninstall(entries))) {
48
+ ui.uninstallCancelled();
49
+ return EXIT_INTERRUPTED;
50
+ }
51
+ const outcome = await ui.uninstalling(() => this.remove(targets));
52
+ ui.uninstallSummary({ ...outcome, packageName });
53
+ return outcome.failure === undefined ? EXIT_OK : EXIT_ERROR;
54
+ }
55
+ /** The entries shown before asking, in removal order. */
56
+ entries(targets) {
57
+ return [
58
+ ...targets.links.map((entry) => this.linkDisplay(entry)),
59
+ ...uninstallEntries(targets.files).map((entry) => entry.display),
60
+ ];
61
+ }
62
+ async remove(targets) {
63
+ const links = await removeLinks(this.session.projectRoot, targets.links);
64
+ const linkFailures = links.failures.map(({ entry, error }) => `remove ${this.linkDisplay(entry)}: ${failureReason(error)}`);
65
+ const files = await this.removeFiles(targets.files);
66
+ const failures = files.failure === undefined ? linkFailures : [...linkFailures, files.failure];
67
+ const summary = {
68
+ removed: [...links.removed.map((entry) => this.linkDisplay(entry)), ...files.summary.removed],
69
+ pruned: files.summary.pruned,
70
+ keptPlans: files.summary.keptPlans,
71
+ };
72
+ return failures.length === 0 ? { summary } : { summary, failure: failures.join(FAILURE_SEPARATOR) };
73
+ }
74
+ /** Stops at the first failure; the outcome lists what was removed before it. There is no rollback. */
75
+ async removeFiles(plan) {
76
+ try {
77
+ return { summary: await applyUninstall(plan) };
78
+ }
79
+ catch (error) {
80
+ const summary = error instanceof UninstallError ? error.result : { removed: [], pruned: [], keptPlans: plan.keptPlans };
81
+ return { summary, failure: describeError(detailTemplates(this.session.templates), error) };
82
+ }
83
+ }
84
+ linkDisplay(entry) {
85
+ return entry.kind === KIND_IMPORTS
86
+ ? fill(this.session.config.wizard.messages.uninstallImportsEntry, { path: entry.path })
87
+ : entry.path;
88
+ }
89
+ }
@@ -0,0 +1,7 @@
1
+ import { fill } from "../config/index.js";
2
+ export function helpText(messages, version) {
3
+ return fill(messages.help, { version });
4
+ }
5
+ export function versionText(messages, version) {
6
+ return fill(messages.version, { version });
7
+ }
@@ -0,0 +1,108 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import { UnknownAgentError } from "./errors.js";
5
+ /**
6
+ * Coding agents the installer can set up. They are the only values
7
+ * accepted for --agent, the wizard's single-select section and the
8
+ * project's settings file.
9
+ */
10
+ export const AGENT_COPILOT = "copilot";
11
+ export const AGENT_CLAUDE = "claude";
12
+ export const AGENT_VIBE = "vibe";
13
+ const AGENTS = [AGENT_COPILOT, AGENT_CLAUDE, AGENT_VIBE];
14
+ export const DEFAULT_AGENT = AGENT_COPILOT;
15
+ /** yaawn's runtime directory inside the project; the settings file lives there. */
16
+ export const PROJECT_DIR = ".yaawn";
17
+ /** Caps how much of the settings file is read, so a huge file cannot exhaust memory. */
18
+ export const MAX_SETTINGS_SIZE = 64 * 1024;
19
+ const SETTINGS_DIR_MODE = 0o755;
20
+ const SETTINGS_FILE_MODE = 0o644;
21
+ /** Returns the allowed coding agents in display order. */
22
+ export function agents() {
23
+ return [...AGENTS];
24
+ }
25
+ export function isAgent(value) {
26
+ return typeof value === "string" && AGENTS.includes(value);
27
+ }
28
+ /**
29
+ * Returns the agent saved in the settings file at filePath, or undefined
30
+ * when the file is missing, not a regular file, oversized, unparsable or
31
+ * names an agent outside the allow-list, so callers fall back safely.
32
+ */
33
+ export function readAgentSetting(filePath) {
34
+ const text = readBoundedText(filePath);
35
+ if (text === undefined) {
36
+ return undefined;
37
+ }
38
+ const agent = parseAgent(text);
39
+ return isAgent(agent) ? agent : undefined;
40
+ }
41
+ function readBoundedText(filePath) {
42
+ try {
43
+ if (!fs.statSync(filePath).isFile()) {
44
+ return undefined;
45
+ }
46
+ const buffer = readAtMost(filePath, MAX_SETTINGS_SIZE + 1);
47
+ return buffer.length > MAX_SETTINGS_SIZE ? undefined : buffer.toString("utf8");
48
+ }
49
+ catch {
50
+ return undefined;
51
+ }
52
+ }
53
+ function readAtMost(filePath, limit) {
54
+ const buffer = Buffer.alloc(limit);
55
+ const descriptor = fs.openSync(filePath, "r");
56
+ try {
57
+ let total = 0;
58
+ let bytesRead = -1;
59
+ while (total < limit && bytesRead !== 0) {
60
+ bytesRead = fs.readSync(descriptor, buffer, total, limit - total, null);
61
+ total += bytesRead;
62
+ }
63
+ return buffer.subarray(0, total);
64
+ }
65
+ finally {
66
+ fs.closeSync(descriptor);
67
+ }
68
+ }
69
+ function parseAgent(text) {
70
+ try {
71
+ const settings = JSON.parse(text);
72
+ if (typeof settings !== "object" || settings === null || Array.isArray(settings)) {
73
+ return undefined;
74
+ }
75
+ return Object.hasOwn(settings, "agent") ? settings.agent : undefined;
76
+ }
77
+ catch {
78
+ return undefined;
79
+ }
80
+ }
81
+ /**
82
+ * Writes {"agent":"<agent>"} to filePath, creating its directory. The
83
+ * content goes to a sibling temp file first and is renamed into place,
84
+ * so a crash never leaves a half-written settings file. The caller must
85
+ * have contained filePath inside the project.
86
+ */
87
+ export function writeAgentSetting(filePath, agent) {
88
+ if (!isAgent(agent)) {
89
+ throw new UnknownAgentError(agent);
90
+ }
91
+ createSettingsDir(filePath);
92
+ writeFileAtomically(filePath, `${JSON.stringify({ agent })}\n`);
93
+ }
94
+ /** Creates the directory that holds the settings file at filePath. */
95
+ export function createSettingsDir(filePath) {
96
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: SETTINGS_DIR_MODE });
97
+ }
98
+ function writeFileAtomically(filePath, content) {
99
+ const tempPath = `${filePath}.${process.pid}.tmp`;
100
+ try {
101
+ fs.writeFileSync(tempPath, content, { flag: "wx", mode: SETTINGS_FILE_MODE });
102
+ fs.renameSync(tempPath, filePath);
103
+ }
104
+ catch (error) {
105
+ fs.rmSync(tempPath, { force: true });
106
+ throw error;
107
+ }
108
+ }
@@ -0,0 +1,118 @@
1
+ import path from "node:path";
2
+ import { createSettingsDir, DEFAULT_AGENT, isAgent, PROJECT_DIR, readAgentSetting, writeAgentSetting, } from "./agents.js";
3
+ import { UnknownAgentError } from "./errors.js";
4
+ import { contain } from "./paths.js";
5
+ import { decodeSettings, SETTINGS_FILE, settingsError, validateSettings } from "./settings.js";
6
+ import { readJsonDocument, sortedKeys } from "./strict-json.js";
7
+ import { decodeInstallTasks, INSTALL_TASKS_FILE, isSeedOnly, validateInstallTasks } from "./tasks.js";
8
+ import { decodeWizard, singleSelectSection, validateWizard, WIZARD_FILE } from "./wizard.js";
9
+ /** The config directory inside the package root. */
10
+ export const CONFIG_DIR = "config";
11
+ /** A file the wizard appends to: seeded by a sync target and owned by the user afterwards. */
12
+ export class OutputFile {
13
+ key;
14
+ name;
15
+ target;
16
+ constructor(key, name, target) {
17
+ this.key = key;
18
+ this.name = name;
19
+ this.target = target;
20
+ }
21
+ /** The seed copy of the file inside the package. */
22
+ sourcePath(packageRoot) {
23
+ return contain(packageRoot, this.target.source, this.name);
24
+ }
25
+ /** The file inside the project. */
26
+ destinationPath(projectRoot) {
27
+ return contain(projectRoot, this.target.target, this.name);
28
+ }
29
+ /** The project-relative, slash-separated path. */
30
+ displayPath() {
31
+ return path.posix.join(this.target.target, this.name);
32
+ }
33
+ }
34
+ /** The three validated config files. */
35
+ export class Config {
36
+ settings;
37
+ wizard;
38
+ tasks;
39
+ constructor(settings, wizard, tasks) {
40
+ this.settings = settings;
41
+ this.wizard = wizard;
42
+ this.tasks = tasks;
43
+ }
44
+ /** Looks up the output file registered under key. */
45
+ outputFile(key) {
46
+ if (!Object.hasOwn(this.settings.outputFileNames, key)) {
47
+ return undefined;
48
+ }
49
+ const name = this.settings.outputFileNames[key] ?? "";
50
+ const target = this.tasks.syncTargets.find((candidate) => isSeedOnly(candidate, name));
51
+ return target === undefined ? undefined : new OutputFile(key, name, target);
52
+ }
53
+ /** The output file names; the sync never deletes them even when a removeFirst pattern matches. */
54
+ userOwnedFiles() {
55
+ return sortedKeys(this.settings.outputFileNames).map((key) => this.settings.outputFileNames[key] ?? key);
56
+ }
57
+ /** The settings file inside projectRoot; throws PathEscapeError when it would escape. */
58
+ agentSettingsPath(projectRoot) {
59
+ return contain(projectRoot, PROJECT_DIR, this.settings.settingsFileName);
60
+ }
61
+ /** The agent saved in the project's settings file; undefined whenever it cannot be read safely. */
62
+ previousAgent(projectRoot) {
63
+ try {
64
+ return readAgentSetting(this.agentSettingsPath(projectRoot));
65
+ }
66
+ catch {
67
+ return undefined;
68
+ }
69
+ }
70
+ /** requested when it is an allowed agent, else the project's previous choice, else the default agent. */
71
+ resolveAgent(projectRoot, requested) {
72
+ if (isAgent(requested)) {
73
+ return requested;
74
+ }
75
+ return this.previousAgent(projectRoot) ?? this.agentDefault();
76
+ }
77
+ /**
78
+ * Writes agent to the project's settings file, creating .yaawn/ when
79
+ * missing. The path is contained before and after the directory is
80
+ * created, so a symlink planted on the way cannot redirect the write.
81
+ */
82
+ saveAgent(projectRoot, agent) {
83
+ if (!isAgent(agent)) {
84
+ throw new UnknownAgentError(agent);
85
+ }
86
+ createSettingsDir(this.agentSettingsPath(projectRoot));
87
+ writeAgentSetting(this.agentSettingsPath(projectRoot), agent);
88
+ }
89
+ /** The default of the single-select section, or DEFAULT_AGENT without one. */
90
+ agentDefault() {
91
+ const fallback = singleSelectSection(this.wizard)?.default;
92
+ return isAgent(fallback) ? fallback : DEFAULT_AGENT;
93
+ }
94
+ }
95
+ /** Reads and validates settings.json, wizard.json and install-tasks.json from the config directory of packageRoot. */
96
+ export function loadConfig(packageRoot) {
97
+ const dir = contain(packageRoot, CONFIG_DIR);
98
+ const settings = decodeSettings(readJsonDocument(contain(dir, SETTINGS_FILE), SETTINGS_FILE));
99
+ const wizard = decodeWizard(readJsonDocument(contain(dir, WIZARD_FILE), WIZARD_FILE));
100
+ const tasks = decodeInstallTasks(readJsonDocument(contain(dir, INSTALL_TASKS_FILE), INSTALL_TASKS_FILE));
101
+ return createConfig(settings, wizard, tasks);
102
+ }
103
+ /** Validates the three config parts, including the references between them. */
104
+ export function createConfig(settingsDocument, wizard, tasks) {
105
+ const settings = validateSettings(settingsDocument);
106
+ validateWizard(wizard, settings);
107
+ validateInstallTasks(tasks, settings.filePrefix);
108
+ const config = new Config(settings, wizard, tasks);
109
+ validateOutputFiles(config);
110
+ return config;
111
+ }
112
+ /** Requires every output file to be seed-only in a sync target, so it has a known location and is never deleted. */
113
+ function validateOutputFiles(config) {
114
+ const unseeded = sortedKeys(config.settings.outputFileNames).find((key) => config.outputFile(key) === undefined);
115
+ if (unseeded !== undefined) {
116
+ throw settingsError(`outputFileNames.${unseeded}`, `must be listed in a syncTargets seedOnly array of ${INSTALL_TASKS_FILE}`);
117
+ }
118
+ }
@@ -0,0 +1,45 @@
1
+ /** A config value that breaks the schema, named by file and field path. */
2
+ export class ValidationError extends Error {
3
+ file;
4
+ field;
5
+ problem;
6
+ name = "ValidationError";
7
+ constructor(file, field, problem) {
8
+ super(`${file}: ${field} ${problem}`);
9
+ this.file = file;
10
+ this.field = field;
11
+ this.problem = problem;
12
+ }
13
+ }
14
+ /** A config file that could not be read. */
15
+ export class LoadError extends Error {
16
+ file;
17
+ name = "LoadError";
18
+ constructor(file, cause) {
19
+ super(`${file}: ${describeCause(cause)}`, { cause });
20
+ this.file = file;
21
+ }
22
+ }
23
+ /** A path that resolves outside its root, directly or through a symlink. */
24
+ export class PathEscapeError extends Error {
25
+ path;
26
+ root;
27
+ name = "PathEscapeError";
28
+ constructor(path, root) {
29
+ super(`path ${path} escapes ${root}`);
30
+ this.path = path;
31
+ this.root = root;
32
+ }
33
+ }
34
+ /** An agent outside the allow-list of coding agents. */
35
+ export class UnknownAgentError extends Error {
36
+ agent;
37
+ name = "UnknownAgentError";
38
+ constructor(agent) {
39
+ super("unknown coding agent");
40
+ this.agent = agent;
41
+ }
42
+ }
43
+ function describeCause(cause) {
44
+ return cause instanceof Error ? cause.message : String(cause);
45
+ }
@@ -0,0 +1,11 @@
1
+ export { AGENT_CLAUDE, AGENT_COPILOT, AGENT_VIBE, DEFAULT_AGENT, MAX_SETTINGS_SIZE, PROJECT_DIR, agents, isAgent, readAgentSetting, writeAgentSetting, } from "./agents.js";
2
+ export { CONFIG_DIR, Config, OutputFile, createConfig, loadConfig } from "./config.js";
3
+ export { LoadError, PathEscapeError, UnknownAgentError, ValidationError } from "./errors.js";
4
+ export { PACKAGE_MANIFEST, readPackageName, readPackageVersion } from "./manifest.js";
5
+ export { fill } from "./messages.js";
6
+ export { isFileName, isRelativeDir, removeDir } from "./names.js";
7
+ export { contain } from "./paths.js";
8
+ export { SETTINGS_FILE } from "./settings.js";
9
+ export { DOCUMENT_FIELD } from "./strict-json.js";
10
+ export { INSTALL_TASKS_FILE, installedName, installedRenames, isSeedOnly, } from "./tasks.js";
11
+ export { KIND_MULTISELECT, KIND_PROJECTS_LIST, KIND_SINGLE_SELECT, KIND_TEXT_LIST, WIZARD_FILE, singleSelectSection, } from "./wizard.js";
@@ -0,0 +1,28 @@
1
+ import { ValidationError } from "./errors.js";
2
+ import { contain } from "./paths.js";
3
+ import { JsonLocation, readJsonDocument, readObject, readString } from "./strict-json.js";
4
+ /** The package manifest inside the package root. */
5
+ export const PACKAGE_MANIFEST = "package.json";
6
+ const VERSION_PATTERN = /^[0-9A-Za-z.+-]+$/;
7
+ const PACKAGE_NAME_PATTERN = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
8
+ /** Reads the version field of the package manifest. */
9
+ export function readPackageVersion(packageRoot) {
10
+ return readManifestField(packageRoot, "version", VERSION_PATTERN);
11
+ }
12
+ /** Reads the name field of the package manifest; a missing name fails instead of guessing the npm uninstall command. */
13
+ export function readPackageName(packageRoot) {
14
+ return readManifestField(packageRoot, "name", PACKAGE_NAME_PATTERN);
15
+ }
16
+ function readManifestField(packageRoot, key, pattern) {
17
+ const manifestPath = contain(packageRoot, PACKAGE_MANIFEST);
18
+ const at = new JsonLocation(PACKAGE_MANIFEST);
19
+ const manifest = readObject(readJsonDocument(manifestPath, PACKAGE_MANIFEST), at);
20
+ const value = Object.hasOwn(manifest, key) ? readString(manifest[key], at.key(key)) : "";
21
+ if (value === "") {
22
+ throw new ValidationError(PACKAGE_MANIFEST, key, "is required");
23
+ }
24
+ if (!pattern.test(value)) {
25
+ throw new ValidationError(PACKAGE_MANIFEST, key, "has an invalid format");
26
+ }
27
+ return value;
28
+ }