@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.
- package/README.md +22 -43
- package/agents/personas/yaawn-product-owner.md +4 -4
- package/agents/skills/yaawn-create-plan/SKILL.md +83 -50
- package/agents/skills/yaawn-extract-learnings/SKILL.md +138 -0
- package/agents/skills/yaawn-grill-bug-report/SKILL.md +7 -0
- package/agents/skills/yaawn-grill-other/SKILL.md +7 -0
- package/agents/skills/yaawn-grill-prototype/SKILL.md +38 -0
- package/agents/skills/yaawn-grill-prototype/references/prototype-checklist.md +23 -0
- package/agents/skills/yaawn-grill-refactor/SKILL.md +7 -0
- package/agents/skills/yaawn-grill-spike/SKILL.md +7 -0
- package/agents/skills/yaawn-grill-story/SKILL.md +8 -1
- package/agents/skills/yaawn-grill-story/references/concept-challenge.md +12 -2
- package/agents/skills/yaawn-grill-story/references/story-checklist.md +2 -1
- package/agents/skills/yaawn-implement-plan/SKILL.md +10 -8
- package/agents/skills/yaawn-implement-plan/scripts/agent.ts +1 -1
- package/agents/skills/yaawn-implement-plan/scripts/overnight.ts +7 -4
- package/agents/skills/yaawn-implement-plan/scripts/parallel.ts +155 -12
- package/agents/skills/yaawn-implement-plan/scripts/processes.ts +161 -1
- package/agents/skills/yaawn-implement-plan/scripts/targets.ts +13 -2
- package/agents/skills/yaawn-implement-plan/scripts/worktrees.ts +219 -52
- package/agents/skills/yaawn-prepare-plan/SKILL.md +20 -19
- package/agents/skills/yaawn-save-plan/SKILL.md +26 -7
- package/agents/skills/yaawn-save-plan/scripts/plan-body.ts +75 -9
- package/agents/skills/yaawn-save-plan/scripts/save-plan.ts +97 -1
- package/agents/skills/yaawn-split-story/SKILL.md +1 -1
- package/agents/skills/yaawn-verify-executed-plan/SKILL.md +28 -13
- package/agents/skills/yaawn-verify-executed-plan/scripts/overnight.ts +44 -12
- package/agents/skills/yaawn-write-bug-report/SKILL.md +6 -5
- package/agents/skills/yaawn-write-other/SKILL.md +6 -5
- package/agents/skills/yaawn-write-prototype/SKILL.md +96 -0
- package/agents/skills/yaawn-write-refactor/SKILL.md +6 -5
- package/agents/skills/yaawn-write-spike/SKILL.md +1 -1
- package/agents/skills/yaawn-write-user-story/SKILL.md +4 -3
- package/agents/yaawn-glossary.md +6 -5
- package/config/install-tasks.json +0 -1
- package/config/wizard.json +5 -10
- package/dist/cli/args.js +66 -0
- package/dist/cli/errors.js +103 -0
- package/dist/cli/exit-codes.js +3 -0
- package/dist/cli/index.js +11 -0
- package/dist/cli/install-report.js +92 -0
- package/dist/cli/install.js +124 -0
- package/dist/cli/main.js +17 -0
- package/dist/cli/package-root.js +17 -0
- package/dist/cli/project-root.js +11 -0
- package/dist/cli/run.js +141 -0
- package/dist/cli/session.js +1 -0
- package/dist/cli/terminal.js +5 -0
- package/dist/cli/uninstall.js +89 -0
- package/dist/cli/version.js +7 -0
- package/dist/config/agents.js +108 -0
- package/dist/config/config.js +118 -0
- package/dist/config/errors.js +45 -0
- package/dist/config/index.js +11 -0
- package/dist/config/manifest.js +28 -0
- package/dist/config/messages.js +86 -0
- package/dist/config/names.js +40 -0
- package/dist/config/paths.js +73 -0
- package/dist/config/settings.js +101 -0
- package/dist/config/strict-json.js +119 -0
- package/dist/config/tasks.js +77 -0
- package/dist/config/wizard.js +139 -0
- package/dist/display/index.js +3 -0
- package/dist/display/palette.js +61 -0
- package/dist/display/renderer.js +169 -0
- package/dist/display/report.js +15 -0
- package/dist/display/sanitize.js +10 -0
- package/dist/link/errors.js +34 -0
- package/dist/link/fs-utils.js +79 -0
- package/dist/link/index.js +4 -0
- package/dist/link/install.js +20 -0
- package/dist/link/linker.js +137 -0
- package/dist/link/memory.js +221 -0
- package/dist/link/names.js +30 -0
- package/dist/link/types.js +30 -0
- package/dist/link/uninstall.js +90 -0
- package/dist/sync/apply.js +74 -0
- package/dist/sync/changes.js +31 -0
- package/dist/sync/fs-utils.js +63 -0
- package/dist/sync/index.js +7 -0
- package/dist/sync/install.js +57 -0
- package/dist/sync/languages.js +80 -0
- package/dist/sync/plan.js +146 -0
- package/dist/sync/projects.js +294 -0
- package/dist/sync/uninstall.js +234 -0
- package/dist/wizard/build.js +89 -0
- package/dist/wizard/index.js +5 -0
- package/dist/wizard/items.js +83 -0
- package/dist/wizard/prompts.js +167 -0
- package/dist/wizard/section.js +143 -0
- package/dist/wizard/wizard.js +73 -0
- package/github/ISSUE_TEMPLATE/yaawn-prototype.md +48 -0
- package/package.json +11 -10
- package/yaawn/yaawn-user-roles.md +1 -1
- package/agents/instructions/output-styles/yaawn-eli5.instructions.md +0 -45
- package/scripts/install.js +0 -73
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
const RESET_FOREGROUND = "\u001b[39m";
|
|
3
|
+
const BOLD = "\u001b[1m";
|
|
4
|
+
const RESET_BOLD_FAINT = "\u001b[22m";
|
|
5
|
+
const FAINT = "\u001b[2m";
|
|
6
|
+
const YELLOW = "\u001b[33m";
|
|
7
|
+
const TRUE_COLOR_DEPTH = 2 ** 24;
|
|
8
|
+
const ANSI_256_DEPTH = 256;
|
|
9
|
+
const HEX_COLOR = /^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/;
|
|
10
|
+
const plain = (text) => text;
|
|
11
|
+
/** Styles for the installer output; every style degrades to plain text on terminals without colour. */
|
|
12
|
+
export class Palette {
|
|
13
|
+
title;
|
|
14
|
+
brand;
|
|
15
|
+
muted;
|
|
16
|
+
warning;
|
|
17
|
+
constructor(brandColor, target, env = process.env) {
|
|
18
|
+
const colors = supportsColor(target, env);
|
|
19
|
+
const brandCode = colors ? foregroundCode(brandColor, target) : undefined;
|
|
20
|
+
this.brand = brandCode === undefined ? plain : wrap(brandCode, RESET_FOREGROUND);
|
|
21
|
+
this.title = colors ? (text) => wrap(BOLD, RESET_BOLD_FAINT)(this.brand(text)) : plain;
|
|
22
|
+
this.muted = colors ? wrap(FAINT, RESET_BOLD_FAINT) : plain;
|
|
23
|
+
this.warning = colors ? wrap(YELLOW, RESET_FOREGROUND) : plain;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function supportsColor(target, env) {
|
|
27
|
+
const noColor = env["NO_COLOR"];
|
|
28
|
+
if (noColor !== undefined && noColor !== "") {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
return target.isTTY === true && target.hasColors?.() === true;
|
|
32
|
+
}
|
|
33
|
+
function foregroundCode(hex, target) {
|
|
34
|
+
const rgb = parseHex(hex);
|
|
35
|
+
if (rgb === undefined) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
if (target.hasColors?.(TRUE_COLOR_DEPTH) === true) {
|
|
39
|
+
return `\u001b[38;2;${rgb.join(";")}m`;
|
|
40
|
+
}
|
|
41
|
+
if (target.hasColors?.(ANSI_256_DEPTH) === true) {
|
|
42
|
+
return `\u001b[38;5;${toAnsi256(rgb)}m`;
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
function parseHex(hex) {
|
|
47
|
+
const match = HEX_COLOR.exec(hex);
|
|
48
|
+
if (match === null) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
const [, red = "", green = "", blue = ""] = match;
|
|
52
|
+
return [Number.parseInt(red, 16), Number.parseInt(green, 16), Number.parseInt(blue, 16)];
|
|
53
|
+
}
|
|
54
|
+
/** Maps a colour onto the 6×6×6 cube of the 256-colour palette. */
|
|
55
|
+
function toAnsi256(rgb) {
|
|
56
|
+
const [red, green, blue] = rgb.map((channel) => Math.round((channel / 255) * 5));
|
|
57
|
+
return 16 + 36 * (red ?? 0) + 6 * (green ?? 0) + (blue ?? 0);
|
|
58
|
+
}
|
|
59
|
+
function wrap(open, close) {
|
|
60
|
+
return (text) => (text === "" ? text : `${open}${text}${close}`);
|
|
61
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { confirm, intro, isCancel, log, outro, select, spinner } from "@clack/prompts";
|
|
2
|
+
import { fill } from "../config/index.js";
|
|
3
|
+
import { Palette } from "./palette.js";
|
|
4
|
+
import { MODE_INSTALL, MODE_UNINSTALL, } from "./report.js";
|
|
5
|
+
import { sanitize } from "./sanitize.js";
|
|
6
|
+
const LIST_INDENT = " ";
|
|
7
|
+
/** Throws the same error the guard recognises as a Ctrl+C cancellation. */
|
|
8
|
+
function cancelled() {
|
|
9
|
+
return Object.assign(new Error("cancelled"), { name: "ExitPromptError" });
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Writes the installer's output with @clack/prompts: the left-rail guide,
|
|
13
|
+
* step symbols, animated spinner and interactive prompts. Every text comes
|
|
14
|
+
* from the config messages, and every printed string is sanitized.
|
|
15
|
+
*/
|
|
16
|
+
export class Renderer {
|
|
17
|
+
messages;
|
|
18
|
+
palette;
|
|
19
|
+
output;
|
|
20
|
+
constructor(messages, brandColor, output) {
|
|
21
|
+
this.messages = messages;
|
|
22
|
+
this.palette = new Palette(brandColor, output);
|
|
23
|
+
this.output = output;
|
|
24
|
+
}
|
|
25
|
+
/** Prints the branded title block for the given package version. */
|
|
26
|
+
intro(version) {
|
|
27
|
+
const values = { version };
|
|
28
|
+
intro(this.palette.title(this.text(this.messages.introTitle, values)), { output: this.output });
|
|
29
|
+
log.message(this.text(this.messages.introSubtitle, values), { output: this.output });
|
|
30
|
+
log.message(this.text(this.messages.intro, values), { output: this.output });
|
|
31
|
+
}
|
|
32
|
+
/** Prints the closing block with the next steps; values fill its placeholders. */
|
|
33
|
+
outro(values) {
|
|
34
|
+
outro(this.text(this.messages.outro, values), { output: this.output });
|
|
35
|
+
}
|
|
36
|
+
info(text) {
|
|
37
|
+
log.info(sanitize(text), { output: this.output });
|
|
38
|
+
}
|
|
39
|
+
/** Prints a skipped or unexpected step, marked by a symbol as well as colour. */
|
|
40
|
+
warn(text) {
|
|
41
|
+
log.warn(sanitize(text), { output: this.output });
|
|
42
|
+
}
|
|
43
|
+
/** Runs task behind the "installing" spinner. */
|
|
44
|
+
async installing(task) {
|
|
45
|
+
const s = spinner({ output: this.output });
|
|
46
|
+
s.start(this.text(this.messages.installing));
|
|
47
|
+
try {
|
|
48
|
+
const result = await task();
|
|
49
|
+
s.stop(this.text(this.messages.installing));
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
s.stop(this.text(this.messages.installing));
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Runs task behind the "removing" spinner. */
|
|
58
|
+
async uninstalling(task) {
|
|
59
|
+
const s = spinner({ output: this.output });
|
|
60
|
+
s.start(this.text(this.messages.uninstalling));
|
|
61
|
+
try {
|
|
62
|
+
const result = await task();
|
|
63
|
+
s.stop(this.text(this.messages.uninstalling));
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
s.stop(this.text(this.messages.uninstalling));
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Prints the warnings and install output, then the failure or, on success, the outro. */
|
|
72
|
+
installSummary(summary) {
|
|
73
|
+
summary.warnings.forEach((warning) => this.warn(warning));
|
|
74
|
+
summary.lines.forEach((line) => {
|
|
75
|
+
if (line.warning) {
|
|
76
|
+
this.warn(line.text);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
log.step(sanitize(line.text), { output: this.output });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
if (summary.failure !== undefined) {
|
|
83
|
+
log.error(sanitize(summary.failure), { output: this.output });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
this.outro(summary.outroValues);
|
|
87
|
+
}
|
|
88
|
+
/** Asks whether to install / update or uninstall, with install preselected. Ctrl+C rejects with ExitPromptError. */
|
|
89
|
+
async chooseMode() {
|
|
90
|
+
const result = await select({
|
|
91
|
+
message: this.text(this.messages.modeTitle),
|
|
92
|
+
options: [
|
|
93
|
+
{ value: MODE_INSTALL, label: this.text(this.messages.modeInstall) },
|
|
94
|
+
{ value: MODE_UNINSTALL, label: this.text(this.messages.modeUninstall) },
|
|
95
|
+
],
|
|
96
|
+
initialValue: MODE_INSTALL,
|
|
97
|
+
output: this.output,
|
|
98
|
+
});
|
|
99
|
+
if (isCancel(result)) {
|
|
100
|
+
throw cancelled();
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
/** Lists entries and asks to remove them, with "No" preselected. Ctrl+C rejects with ExitPromptError. */
|
|
105
|
+
async confirmUninstall(entries) {
|
|
106
|
+
log.message(this.palette.title(this.text(this.messages.uninstallHeaderTitle)), { output: this.output });
|
|
107
|
+
log.message(this.pathList(this.messages.uninstallList, entries), { output: this.output });
|
|
108
|
+
const result = await confirm({
|
|
109
|
+
message: this.text(this.messages.uninstallConfirm),
|
|
110
|
+
active: this.text(this.messages.uninstallYes),
|
|
111
|
+
inactive: this.text(this.messages.uninstallNo),
|
|
112
|
+
initialValue: false,
|
|
113
|
+
output: this.output,
|
|
114
|
+
});
|
|
115
|
+
if (isCancel(result)) {
|
|
116
|
+
throw cancelled();
|
|
117
|
+
}
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
uninstallCancelled() {
|
|
121
|
+
log.warn(this.text(this.messages.uninstallCancelled), { output: this.output });
|
|
122
|
+
}
|
|
123
|
+
/** Prints that no yaawn files were found, followed by the npm hint. */
|
|
124
|
+
nothingToRemove(packageName) {
|
|
125
|
+
log.info(this.text(this.messages.nothingToRemove), { output: this.output });
|
|
126
|
+
log.message(this.npmHint(packageName), { output: this.output });
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Lists the removed entries, the pruned folders, the kept-plans note and the
|
|
130
|
+
* npm hint. After a failure it shows the error and what was removed so far
|
|
131
|
+
* instead, without the npm hint.
|
|
132
|
+
*/
|
|
133
|
+
uninstallSummary(report) {
|
|
134
|
+
if (report.failure !== undefined) {
|
|
135
|
+
log.error(fill(this.messages.uninstallFailed, { error: sanitize(report.failure) }), { output: this.output });
|
|
136
|
+
this.printPathList(this.messages.uninstallRemovedSoFar, report.summary.removed);
|
|
137
|
+
this.printPathList(this.messages.uninstallPruned, report.summary.pruned);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
this.printPathList(this.messages.uninstallRemoved, report.summary.removed);
|
|
141
|
+
this.printPathList(this.messages.uninstallPruned, report.summary.pruned);
|
|
142
|
+
if (report.summary.keptPlans) {
|
|
143
|
+
log.message(this.text(this.messages.uninstallKeptPlans), { output: this.output });
|
|
144
|
+
}
|
|
145
|
+
log.message(this.npmHint(report.packageName), { output: this.output });
|
|
146
|
+
}
|
|
147
|
+
printPathList(heading, paths) {
|
|
148
|
+
if (paths.length === 0) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const lines = paths.map((path) => LIST_INDENT + sanitize(path));
|
|
152
|
+
log.message([this.palette.title(sanitize(heading)), ...lines].join("\n"), { output: this.output });
|
|
153
|
+
}
|
|
154
|
+
/** Renders heading and the paths below it, or "" when there are none. */
|
|
155
|
+
pathList(heading, paths) {
|
|
156
|
+
if (paths.length === 0) {
|
|
157
|
+
return "";
|
|
158
|
+
}
|
|
159
|
+
const lines = paths.map((path) => LIST_INDENT + sanitize(path));
|
|
160
|
+
return [this.palette.title(sanitize(heading)), ...lines].join("\n");
|
|
161
|
+
}
|
|
162
|
+
npmHint(packageName) {
|
|
163
|
+
return this.text(this.messages.uninstallNpmHint, { package: packageName });
|
|
164
|
+
}
|
|
165
|
+
/** Fills template, then sanitizes the result so neither config nor values can drive the terminal. */
|
|
166
|
+
text(template, values = {}) {
|
|
167
|
+
return sanitize(fill(template, values));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Records progress lines while the spinner runs, so they print afterwards without garbling it. */
|
|
2
|
+
export class ReportCollector {
|
|
3
|
+
recorded = [];
|
|
4
|
+
get lines() {
|
|
5
|
+
return this.recorded;
|
|
6
|
+
}
|
|
7
|
+
info(text) {
|
|
8
|
+
this.recorded.push({ text, warning: false });
|
|
9
|
+
}
|
|
10
|
+
warn(text) {
|
|
11
|
+
this.recorded.push({ text, warning: true });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export const MODE_INSTALL = "install";
|
|
15
|
+
export const MODE_UNINSTALL = "uninstall";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
// eslint-disable-next-line no-control-regex -- matches the C0/C1 control characters that could drive the terminal
|
|
3
|
+
const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
|
|
4
|
+
/**
|
|
5
|
+
* Strips escape sequences and control characters other than line breaks
|
|
6
|
+
* and tabs, so text from the user's disk or config cannot drive the terminal.
|
|
7
|
+
*/
|
|
8
|
+
export function sanitize(text) {
|
|
9
|
+
return stripVTControlCharacters(text).replace(CONTROL_CHARACTERS, "");
|
|
10
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** The managed block's start and end markers are incomplete, repeated or out of order. */
|
|
2
|
+
export class MalformedBlockError extends Error {
|
|
3
|
+
name = "MalformedBlockError";
|
|
4
|
+
constructor() {
|
|
5
|
+
super("the yaawn import markers are incomplete or repeated; fix them by hand");
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
/** A memory file larger than the installer reads. */
|
|
9
|
+
export class MemoryTooLargeError extends Error {
|
|
10
|
+
limit;
|
|
11
|
+
name = "MemoryTooLargeError";
|
|
12
|
+
constructor(limit) {
|
|
13
|
+
super(`file is larger than ${limit} bytes`);
|
|
14
|
+
this.limit = limit;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** A path that is not one of the memory files yaawn manages an instruction block in. */
|
|
18
|
+
export class UnmanagedFileError extends Error {
|
|
19
|
+
path;
|
|
20
|
+
name = "UnmanagedFileError";
|
|
21
|
+
constructor(path) {
|
|
22
|
+
super("not a file yaawn manages an instruction block in");
|
|
23
|
+
this.path = path;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** A path that is not one of the agent folders yaawn links, nor an entry directly inside one. */
|
|
27
|
+
export class UnmanagedLinkError extends Error {
|
|
28
|
+
path;
|
|
29
|
+
name = "UnmanagedLinkError";
|
|
30
|
+
constructor(path) {
|
|
31
|
+
super("not a link yaawn manages");
|
|
32
|
+
this.path = path;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { chmod, lstat, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { contain } from "../config/index.js";
|
|
5
|
+
export const DIR_MODE = 0o755;
|
|
6
|
+
export const FILE_MODE = 0o644;
|
|
7
|
+
const PERMISSION_BITS = 0o777;
|
|
8
|
+
export function isMissing(error) {
|
|
9
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
10
|
+
}
|
|
11
|
+
/** The entry's own stats without following a symlink, or undefined when it does not exist. */
|
|
12
|
+
export async function lstatIfExists(target) {
|
|
13
|
+
try {
|
|
14
|
+
return await lstat(target);
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (isMissing(error)) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export async function isDirectory(target) {
|
|
24
|
+
try {
|
|
25
|
+
return (await stat(target)).isDirectory();
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Reports whether the slash-separated elements name a directory contained in root. */
|
|
32
|
+
export async function isContainedDirectory(root, ...elements) {
|
|
33
|
+
try {
|
|
34
|
+
return await isDirectory(contain(root, ...elements));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Contains the parent of the slash-separated, root-relative entry rather
|
|
42
|
+
* than the entry itself, so a symlinked entry resolves to the link, never
|
|
43
|
+
* to its target.
|
|
44
|
+
*/
|
|
45
|
+
export function containEntry(root, relative) {
|
|
46
|
+
const parent = contain(root, path.posix.dirname(relative));
|
|
47
|
+
return path.join(parent, path.posix.basename(relative));
|
|
48
|
+
}
|
|
49
|
+
/** The entries of dir sorted by name, in the order the Go installer listed them. */
|
|
50
|
+
export async function readSortedDir(dir) {
|
|
51
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
52
|
+
return entries.sort((left, right) => compareNames(left.name, right.name));
|
|
53
|
+
}
|
|
54
|
+
function compareNames(left, right) {
|
|
55
|
+
if (left === right) {
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
return left < right ? -1 : 1;
|
|
59
|
+
}
|
|
60
|
+
export function permissionBits(stats) {
|
|
61
|
+
return stats.mode & PERMISSION_BITS;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Writes content to a sibling temp file and renames it over filePath, so
|
|
65
|
+
* a crash never leaves a half-written file and an entry that replaced
|
|
66
|
+
* filePath is swapped out rather than written through.
|
|
67
|
+
*/
|
|
68
|
+
export async function writeFileAtomically(filePath, content, mode) {
|
|
69
|
+
const tempPath = `${filePath}.${process.pid}.tmp`;
|
|
70
|
+
try {
|
|
71
|
+
await writeFile(tempPath, content, { flag: "wx", mode });
|
|
72
|
+
await chmod(tempPath, mode);
|
|
73
|
+
await rename(tempPath, filePath);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
await rm(tempPath, { force: true });
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { MalformedBlockError, MemoryTooLargeError, UnmanagedFileError, UnmanagedLinkError } from "./errors.js";
|
|
2
|
+
export { linkAgent } from "./install.js";
|
|
3
|
+
export { findInstalledLinks, removeLink, removeLinks, } from "./uninstall.js";
|
|
4
|
+
export { AGENTS_DIR, countLinkFailures, failureReason, hasLinkFailure, KIND_IMPORTS, KIND_SYMLINK, LINK_CREATED, LINK_EXISTED, LINK_FAILED, LINK_SKIPPED, } from "./types.js";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { isAgent, UnknownAgentError } from "../config/index.js";
|
|
3
|
+
import { Linker } from "./linker.js";
|
|
4
|
+
import { writeInstructionBlock } from "./memory.js";
|
|
5
|
+
/**
|
|
6
|
+
* Links the .agents payload for agent: relative symlinks into .agents
|
|
7
|
+
* and, for agents without a linked instructions folder, the managed
|
|
8
|
+
* instruction block in their memory file. A failure is recorded in its
|
|
9
|
+
* result and the remaining links are still attempted; only an unknown
|
|
10
|
+
* agent or an unusable project root rejects.
|
|
11
|
+
*/
|
|
12
|
+
export async function linkAgent(projectRoot, prefix, agent) {
|
|
13
|
+
if (!isAgent(agent)) {
|
|
14
|
+
throw new UnknownAgentError(agent);
|
|
15
|
+
}
|
|
16
|
+
const root = path.resolve(projectRoot);
|
|
17
|
+
const results = await new Linker(root, prefix).linkAgentFolders(agent);
|
|
18
|
+
const block = await writeInstructionBlock(root, agent);
|
|
19
|
+
return block === undefined ? results : [...results, block];
|
|
20
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { mkdir, readlink, symlink, unlink } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { AGENT_CLAUDE, AGENT_COPILOT, AGENT_VIBE, contain } from "../config/index.js";
|
|
4
|
+
import { DIR_MODE, containEntry, isContainedDirectory, lstatIfExists, readSortedDir } from "./fs-utils.js";
|
|
5
|
+
import { isSafeName, isWithin, relativeTarget } from "./names.js";
|
|
6
|
+
import { AGENTS_DIR, KIND_SYMLINK, LINK_CREATED, LINK_EXISTED, LINK_FAILED, LINK_SKIPPED, } from "./types.js";
|
|
7
|
+
/** Vibe has no folder links: it reads .agents/skills natively. */
|
|
8
|
+
const FOLDER_LINKS = {
|
|
9
|
+
[AGENT_COPILOT]: [
|
|
10
|
+
{ path: ".github/skills", source: "skills", entryIsDir: true },
|
|
11
|
+
{ path: ".github/instructions", source: "instructions", entryIsDir: false },
|
|
12
|
+
],
|
|
13
|
+
[AGENT_CLAUDE]: [{ path: ".claude/skills", source: "skills", entryIsDir: true }],
|
|
14
|
+
[AGENT_VIBE]: [],
|
|
15
|
+
};
|
|
16
|
+
/** Every folder any agent may have linked, including the legacy .claude/rules link. */
|
|
17
|
+
export const REMOVABLE_FOLDERS = [
|
|
18
|
+
".github/skills",
|
|
19
|
+
".github/instructions",
|
|
20
|
+
".claude/skills",
|
|
21
|
+
".claude/rules",
|
|
22
|
+
];
|
|
23
|
+
/**
|
|
24
|
+
* Places and inspects links into .agents inside one project. It never
|
|
25
|
+
* removes or overwrites anything that is not a link into .agents.
|
|
26
|
+
*/
|
|
27
|
+
export class Linker {
|
|
28
|
+
root;
|
|
29
|
+
prefix;
|
|
30
|
+
agentsDir;
|
|
31
|
+
results = [];
|
|
32
|
+
constructor(root, prefix = "") {
|
|
33
|
+
this.root = root;
|
|
34
|
+
this.prefix = prefix;
|
|
35
|
+
this.agentsDir = path.join(root, AGENTS_DIR);
|
|
36
|
+
}
|
|
37
|
+
/** Links every folder of agent; a failure is recorded and the remaining links are still attempted. */
|
|
38
|
+
async linkAgentFolders(agent) {
|
|
39
|
+
for (const folder of FOLDER_LINKS[agent]) {
|
|
40
|
+
await this.linkFolder(folder);
|
|
41
|
+
}
|
|
42
|
+
return [...this.results];
|
|
43
|
+
}
|
|
44
|
+
async linkFolder(folder) {
|
|
45
|
+
if (!(await isContainedDirectory(this.root, AGENTS_DIR, folder.source))) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const target = relativeTarget(folder.path, `${AGENTS_DIR}/${folder.source}`);
|
|
49
|
+
let linkPath;
|
|
50
|
+
try {
|
|
51
|
+
linkPath = containEntry(this.root, folder.path);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
this.record(LINK_FAILED, folder.path, target, error);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
await this.place({ linkPath, relative: folder.path, target, type: "dir" }, folder);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Creates a missing link, keeps a link into .agents and skips any other
|
|
61
|
+
* entry; with inside set, a real folder at the link path gets the yaawn
|
|
62
|
+
* entries linked one by one.
|
|
63
|
+
*/
|
|
64
|
+
async place(link, inside) {
|
|
65
|
+
try {
|
|
66
|
+
const state = await this.classify(link.linkPath);
|
|
67
|
+
if (state === "missing") {
|
|
68
|
+
await createLink(link);
|
|
69
|
+
this.record(LINK_CREATED, link.relative, link.target);
|
|
70
|
+
}
|
|
71
|
+
else if (state === "own") {
|
|
72
|
+
this.record(LINK_EXISTED, link.relative, link.target);
|
|
73
|
+
}
|
|
74
|
+
else if (state === "realDir" && inside !== undefined) {
|
|
75
|
+
await this.linkEntries(link.linkPath, inside);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
this.record(LINK_SKIPPED, link.relative, link.target);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
this.record(LINK_FAILED, link.relative, link.target, error);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** Links each prefixed yaawn entry of the folder's source into dir, a real folder the user owns. */
|
|
86
|
+
async linkEntries(dir, folder) {
|
|
87
|
+
const entries = await readSortedDir(contain(this.root, AGENTS_DIR, folder.source));
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
const matchesKind = folder.entryIsDir ? entry.isDirectory() : entry.isFile();
|
|
90
|
+
if (!entry.name.startsWith(this.prefix) || !isSafeName(entry.name) || !matchesKind) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const relative = `${folder.path}/${entry.name}`;
|
|
94
|
+
const target = relativeTarget(relative, `${AGENTS_DIR}/${folder.source}/${entry.name}`);
|
|
95
|
+
const type = folder.entryIsDir ? "dir" : "file";
|
|
96
|
+
await this.place({ linkPath: path.join(dir, entry.name), relative, target, type });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
record(status, relative, target, error) {
|
|
100
|
+
const failure = status === LINK_FAILED ? { manualCommand: `ln -s ${target} ${relative}`, error } : {};
|
|
101
|
+
this.results.push({ kind: KIND_SYMLINK, status, path: relative, target, ...failure });
|
|
102
|
+
}
|
|
103
|
+
/** Inspects linkPath itself and never follows it. */
|
|
104
|
+
async classify(linkPath) {
|
|
105
|
+
const stats = await lstatIfExists(linkPath);
|
|
106
|
+
if (stats === undefined) {
|
|
107
|
+
return "missing";
|
|
108
|
+
}
|
|
109
|
+
if (stats.isSymbolicLink()) {
|
|
110
|
+
return (await this.pointsIntoAgents(linkPath)) ? "own" : "foreign";
|
|
111
|
+
}
|
|
112
|
+
return stats.isDirectory() ? "realDir" : "foreign";
|
|
113
|
+
}
|
|
114
|
+
/** Resolves the link's target text against the link's folder, without touching the target, and checks it lies in .agents. */
|
|
115
|
+
async pointsIntoAgents(linkPath) {
|
|
116
|
+
let target;
|
|
117
|
+
try {
|
|
118
|
+
target = await readlink(linkPath);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
return isWithin(this.agentsDir, path.resolve(path.dirname(linkPath), target));
|
|
124
|
+
}
|
|
125
|
+
/** Removes the link at linkPath only while it still points into .agents. Reports whether it was removed. */
|
|
126
|
+
async unlinkOwn(linkPath) {
|
|
127
|
+
if ((await this.classify(linkPath)) !== "own") {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
await unlink(linkPath);
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async function createLink(link) {
|
|
135
|
+
await mkdir(path.dirname(link.linkPath), { recursive: true, mode: DIR_MODE });
|
|
136
|
+
await symlink(link.target.split("/").join(path.sep), link.linkPath, link.type);
|
|
137
|
+
}
|