vibecoding-devtools 1.0.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 +46 -0
- package/hooks/full-suite.mjs +156 -0
- package/hooks/lib/io.mjs +135 -0
- package/hooks/maestri-protocol.mjs +48 -0
- package/hooks/secret-scan.mjs +90 -0
- package/installer/cli.mjs +263 -0
- package/installer/clis.mjs +46 -0
- package/installer/convert.mjs +91 -0
- package/installer/detect.mjs +389 -0
- package/installer/execute.mjs +386 -0
- package/installer/hooks.mjs +67 -0
- package/installer/json-merge.mjs +211 -0
- package/installer/panel.mjs +99 -0
- package/installer/plan.mjs +255 -0
- package/installer/state.mjs +105 -0
- package/installer/third-party.json +16 -0
- package/installer/third-party.mjs +204 -0
- package/npm-shrinkwrap.json +80 -0
- package/package.json +43 -0
- package/rules/base.md +25 -0
- package/rules/context-on-demand.md +25 -0
- package/rules/maestri-messaging.md +10 -0
- package/rules/maestri-usage.md +12 -0
- package/rules/model-tiers.md +17 -0
- package/rules/risk-process.md +69 -0
- package/rules/testing.md +53 -0
- package/skills/maestri-agent-cli/SKILL.md +150 -0
- package/skills/maestri-agent-cli/references/_template.md +62 -0
- package/skills/maestri-agent-cli/references/antigravity.md +206 -0
- package/skills/maestri-agent-cli/references/claude-code.md +203 -0
- package/skills/maestri-agent-cli/references/codex.md +190 -0
- package/skills/maestri-budget/SKILL.md +118 -0
- package/skills/maestri-protocol/SKILL.md +72 -0
- package/skills/risk-triage/SKILL.md +44 -0
- package/skills/security-surfaces/SKILL.md +31 -0
- package/skills/security-surfaces/surfaces/accounts-and-data.md +41 -0
- package/skills/security-surfaces/surfaces/inputs-and-services.md +47 -0
- package/skills/security-surfaces/surfaces/money.md +57 -0
- package/skills/security-surfaces/surfaces/secrets-and-infra.md +43 -0
- package/templates/AGENTS.md +68 -0
- package/templates/CLAUDE.md +1 -0
- package/templates/coderabbit.yaml +127 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import * as clack from '@clack/prompts';
|
|
3
|
+
|
|
4
|
+
export const CLIS = ['claude', 'codex', 'agy'];
|
|
5
|
+
export const STACKS = ['typescript', 'python', 'golang', 'rust', 'java', 'kotlin', 'php', 'ruby', 'swift', 'csharp', 'fsharp', 'dart', 'cpp', 'perl', 'arkts', 'react', 'react-native', 'vue', 'nuxt', 'angular'];
|
|
6
|
+
const names = { claude: 'Claude Code', codex: 'Codex', agy: 'Antigravity' };
|
|
7
|
+
const uniqueList = (value, allowed, name) => {
|
|
8
|
+
const items = value === '' || value === 'none' ? [] : value.split(',').map((s) => s.trim());
|
|
9
|
+
if (items.some((s) => !allowed.includes(s))) throw new Error(`Invalid ${name}: ${value}`);
|
|
10
|
+
return [...new Set(items)];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function parseFlags(args = []) {
|
|
14
|
+
const booleans = ['dry-run', 'yes', 'force', 'json', 'maestri', 'no-maestri', 'hooks', 'no-hooks', 'ecc-hooks', 'no-ecc-hooks', 'impeccable-hooks', 'no-impeccable-hooks', 'project', 'help'];
|
|
15
|
+
const strings = ['clis', 'cli', 'scope', 'ecc', 'ecc-clis', 'ecc-stacks', 'stacks', 'impeccable', 'impeccable-clis'];
|
|
16
|
+
const options = Object.fromEntries([...booleans.map((k) => [k, { type: 'boolean' }]), ...strings.map((k) => [k, { type: 'string' }])]);
|
|
17
|
+
const { values, positionals } = parseArgs({ args, options, allowPositionals: true, strict: true });
|
|
18
|
+
if (positionals.length > 1) throw new Error('Specify only one command');
|
|
19
|
+
const command = positionals[0] ?? 'install';
|
|
20
|
+
if (!['install', 'update', 'detect', 'uninstall'].includes(command)) throw new Error(`Unknown command: ${command}`);
|
|
21
|
+
for (const [a, b] of [['clis', 'cli'], ['ecc', 'ecc-clis'], ['ecc-stacks', 'stacks'], ['impeccable', 'impeccable-clis']]) {
|
|
22
|
+
if (values[a] !== undefined && values[b] !== undefined) throw new Error(`Use only --${a} or --${b}`);
|
|
23
|
+
}
|
|
24
|
+
if (values.project && values.scope && values.scope !== 'project') throw new Error('--project conflicts with --scope');
|
|
25
|
+
for (const name of ['maestri', 'hooks', 'ecc-hooks', 'impeccable-hooks']) {
|
|
26
|
+
if (values[name] && values[`no-${name}`]) throw new Error(`Use only --${name} or --no-${name}`);
|
|
27
|
+
}
|
|
28
|
+
return { command, ...values,
|
|
29
|
+
hasChoices: [...strings, 'project', 'maestri', 'no-maestri', 'hooks', 'no-hooks', 'ecc-hooks', 'no-ecc-hooks', 'impeccable-hooks', 'no-impeccable-hooks'].some((k) => values[k] !== undefined) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function choicesFromFlags(flags, detection, saved) {
|
|
33
|
+
if (saved) {
|
|
34
|
+
// Normalize aliases before merging, so an explicit alias wins over saved choices.
|
|
35
|
+
const scope = flags.project ? 'project' : flags.scope ?? saved.scope;
|
|
36
|
+
const ecc = flags.ecc ?? flags['ecc-clis'] ?? saved.ecc.clis.join(',');
|
|
37
|
+
const impeccable = flags.impeccable ?? flags['impeccable-clis'] ?? saved.impeccable.clis.join(',');
|
|
38
|
+
flags = { ...flags, scope, clis: flags.clis ?? flags.cli ?? saved.clis.join(','), ecc, impeccable,
|
|
39
|
+
maestri: flags.maestri ?? saved.maestri, hooks: flags.hooks ?? saved.hooks,
|
|
40
|
+
'ecc-hooks': flags['ecc-hooks'] ?? (ecc && ecc !== 'none' ? saved.ecc.hooks : false),
|
|
41
|
+
'impeccable-hooks': flags['impeccable-hooks'] ?? (impeccable && impeccable !== 'none' ? saved.impeccable.hooks : false),
|
|
42
|
+
'ecc-stacks': flags['ecc-stacks'] ?? flags.stacks ?? (scope === 'project' && ecc && ecc !== 'none' ? saved.ecc.stacks.join(',') : '') };
|
|
43
|
+
}
|
|
44
|
+
flags = { ...flags };
|
|
45
|
+
for (const name of ['maestri', 'hooks', 'ecc-hooks', 'impeccable-hooks']) {
|
|
46
|
+
if (flags[`no-${name}`]) flags[name] = false;
|
|
47
|
+
}
|
|
48
|
+
const clis = flags.clis ?? flags.cli;
|
|
49
|
+
const targets = clis === undefined ? (detection.clis ?? []).filter((c) => ['ok', 'off-path'].includes(c.state)).map((c) => c.id) : uniqueList(clis, CLIS, 'CLI');
|
|
50
|
+
const scope = flags.project ? 'project' : flags.scope ?? 'global';
|
|
51
|
+
if (!['global', 'project'].includes(scope)) throw new Error('Scope must be global or project');
|
|
52
|
+
if (scope === 'project' && (!detection.project?.inRepository || !detection.project.root)) throw new Error('Project scope requires a git repository');
|
|
53
|
+
const eccClis = uniqueList(flags.ecc ?? flags['ecc-clis'] ?? '', ['claude', 'codex'], 'ECC CLI');
|
|
54
|
+
const impeccableClis = uniqueList(flags.impeccable ?? flags['impeccable-clis'] ?? '', CLIS, 'Impeccable CLI');
|
|
55
|
+
const stacksFlag = flags['ecc-stacks'] ?? flags.stacks;
|
|
56
|
+
const stacks = stacksFlag === undefined ? (scope === 'project' && eccClis.length ? detection.project.stacks ?? [] : []) : uniqueList(stacksFlag, STACKS, 'ECC package');
|
|
57
|
+
if (flags['ecc-hooks'] && !eccClis.length) throw new Error('--ecc-hooks requires --ecc with CLIs');
|
|
58
|
+
if (flags['impeccable-hooks'] && !impeccableClis.length) throw new Error('--impeccable-hooks requires --impeccable with CLIs');
|
|
59
|
+
if (stacks.length && (!eccClis.length || scope !== 'project')) throw new Error('Stack packages require ECC and project scope');
|
|
60
|
+
return { clis: targets, scope, ...(scope === 'project' ? { root: detection.project.root } : {}), maestri: flags.maestri ?? false,
|
|
61
|
+
hooks: flags['no-hooks'] ? false : flags.hooks ?? true,
|
|
62
|
+
ecc: { clis: eccClis, hooks: flags['ecc-hooks'] ?? false, stacks: [...stacks] }, impeccable: { clis: impeccableClis, hooks: flags['impeccable-hooks'] ?? false } };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class Cancellation extends Error {
|
|
66
|
+
constructor() { super('Operation cancelled'); this.name = 'Cancellation'; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function confirmExecution({ prompts = clack } = {}) {
|
|
70
|
+
const answer = await prompts.confirm({ message: 'Apply the summary actions?', initialValue: false });
|
|
71
|
+
if (prompts.isCancel(answer)) throw new Cancellation();
|
|
72
|
+
return answer;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function panel(detection, { prompts = clack } = {}) {
|
|
76
|
+
const ask = async (type, options) => {
|
|
77
|
+
const answer = await prompts[type](options);
|
|
78
|
+
if (prompts.isCancel(answer)) throw new Cancellation();
|
|
79
|
+
return answer;
|
|
80
|
+
};
|
|
81
|
+
const cliOptions = CLIS.map((value) => ({ value, label: names[value], hint: detection.clis?.find((c) => c.id === value)?.state ?? 'missing' }));
|
|
82
|
+
const defaults = choicesFromFlags({}, detection);
|
|
83
|
+
const clis = await ask('multiselect', { message: 'Target CLIs for the kit', options: cliOptions, initialValues: defaults.clis, required: false });
|
|
84
|
+
const scope = await ask('select', { message: 'Installation scope', initialValue: 'global', options: [
|
|
85
|
+
{ value: 'global', label: 'Global' }, ...(detection.project?.inRepository ? [{ value: 'project', label: `Project: ${detection.project.root}` }] : []),
|
|
86
|
+
] });
|
|
87
|
+
const maestri = await ask('confirm', { message: 'Include the Maestri pack?', initialValue: false });
|
|
88
|
+
const kitHooks = await ask('confirm', { message: 'Install kit hooks?', initialValue: true });
|
|
89
|
+
const eccClis = await ask('multiselect', { message: 'Which CLIs should receive ECC?', options: cliOptions.filter(({ value }) => value !== 'agy'), initialValues: [], required: false });
|
|
90
|
+
let hooks = false;
|
|
91
|
+
let stacks = [];
|
|
92
|
+
if (eccClis.length) {
|
|
93
|
+
hooks = await ask('confirm', { message: 'Enable ECC hooks?', initialValue: false });
|
|
94
|
+
if (scope === 'project') stacks = await ask('multiselect', { message: 'ECC stack rule packages', options: STACKS.map((value) => ({ value, label: value })), initialValues: (detection.project.stacks ?? []).filter((s) => STACKS.includes(s)), required: false });
|
|
95
|
+
}
|
|
96
|
+
const impeccableClis = await ask('multiselect', { message: 'Which CLIs should receive Impeccable?', options: cliOptions, initialValues: [], required: false });
|
|
97
|
+
const impeccableHooks = impeccableClis.length ? await ask('confirm', { message: 'Enable Impeccable hooks?', initialValue: false }) : false;
|
|
98
|
+
return choicesFromFlags({ clis: clis.join(','), scope, maestri, hooks: kitHooks, ecc: eccClis.join(','), 'ecc-hooks': hooks, 'ecc-stacks': stacks.join(','), impeccable: impeccableClis.join(','), 'impeccable-hooks': impeccableHooks }, detection);
|
|
99
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// Read-only planning performs no writes, network calls, execution or real-home access.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { isUtf8 } from "node:buffer";
|
|
5
|
+
import { readState, hashContent } from "./state.mjs";
|
|
6
|
+
import { convertAntigravityRule, readFrontmatter, buildCodexBlock } from "./convert.mjs";
|
|
7
|
+
import { hookActions } from './hooks.mjs';
|
|
8
|
+
import { jsonMergeSituation } from './json-merge.mjs';
|
|
9
|
+
|
|
10
|
+
const CLIS = ["claude", "codex", "agy"];
|
|
11
|
+
const uniqueList = (items = []) => [...new Set(items)].sort();
|
|
12
|
+
const entries = (directory) => fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) =>
|
|
13
|
+
a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
14
|
+
const readRules = (directory) => entries(directory).filter((e) => e.isFile() && e.name.endsWith(".md"))
|
|
15
|
+
.map((e) => ({ name: e.name, source: path.join(directory, e.name),
|
|
16
|
+
content: fs.readFileSync(path.join(directory, e.name), "utf8") }));
|
|
17
|
+
|
|
18
|
+
const START = "<!-- vibecoding-devtools:start -->";
|
|
19
|
+
const END = "<!-- vibecoding-devtools:end -->";
|
|
20
|
+
const stat = (file) => {
|
|
21
|
+
try { return fs.lstatSync(file); }
|
|
22
|
+
catch (error) { if (["ENOENT", "ENOTDIR"].includes(error.code)) return null; throw error; }
|
|
23
|
+
};
|
|
24
|
+
const samePath = (a, b) => typeof a === "string" && path.resolve(a) === path.resolve(b);
|
|
25
|
+
|
|
26
|
+
function extractBlock(text) {
|
|
27
|
+
const start = text.indexOf(START);
|
|
28
|
+
const end = text.indexOf(END);
|
|
29
|
+
if (start < 0 && end < 0) return null;
|
|
30
|
+
if (start < 0 || end < start || text.indexOf(START, start + START.length) >= 0
|
|
31
|
+
|| text.indexOf(END, end + END.length) >= 0) return { invalid: true };
|
|
32
|
+
return { content: text.slice(start, end + END.length), start, end: end + END.length };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Measure complete resulting files including markers, separators and preserved external content.
|
|
36
|
+
function codexBytes(actions, home, project, items) {
|
|
37
|
+
const files = new Set([path.join(home, ".codex", "AGENTS.md")]);
|
|
38
|
+
if (project) files.add(path.join(project, "AGENTS.md"));
|
|
39
|
+
let bytes = 0;
|
|
40
|
+
for (const file of files) {
|
|
41
|
+
const copied = actions.find((a) => a.type === "copy" && samePath(a.destination, file));
|
|
42
|
+
let text = stat(file) ? fs.readFileSync(file, "utf8") : "";
|
|
43
|
+
if (copied && ["new", "update"].includes(situationOf(copied, items))) {
|
|
44
|
+
const previous = extractBlock(text);
|
|
45
|
+
const record = items.find((i) => i.type === "block" && samePath(i.destination, file));
|
|
46
|
+
text = fs.readFileSync(copied.source, "utf8");
|
|
47
|
+
if (previous && !previous.invalid && record && hashContent(previous.content) === record.hash) {
|
|
48
|
+
text += (record.separator ?? "") + previous.content;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const action = actions.find((a) => a.type === "block" && samePath(a.destination, file));
|
|
52
|
+
if (action) {
|
|
53
|
+
const block = extractBlock(text);
|
|
54
|
+
const replacement = `${START}\n${action.content}\n${END}`;
|
|
55
|
+
text = block && !block.invalid ? text.slice(0, block.start) + replacement + text.slice(block.end)
|
|
56
|
+
: text + (text && !text.endsWith("\n") ? "\n" : "") + replacement;
|
|
57
|
+
}
|
|
58
|
+
bytes += Buffer.byteLength(text);
|
|
59
|
+
}
|
|
60
|
+
return bytes;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function situationOf(action, items) {
|
|
64
|
+
// Destination links and linked ancestors are conflicts and never escape the target tree.
|
|
65
|
+
for (let p = path.resolve(action.destination); ; p = path.dirname(p)) {
|
|
66
|
+
const ancestor = stat(p);
|
|
67
|
+
if (ancestor?.isSymbolicLink()
|
|
68
|
+
|| (p !== path.resolve(action.destination) && ancestor && !ancestor.isDirectory())) return "conflict";
|
|
69
|
+
if (p === path.dirname(p)) break;
|
|
70
|
+
}
|
|
71
|
+
const st = stat(action.destination);
|
|
72
|
+
if (!st) return "new";
|
|
73
|
+
if (action.type === "copy" && fs.lstatSync(action.source).isDirectory()) {
|
|
74
|
+
if (!st.isDirectory()) return "conflict";
|
|
75
|
+
const names = entries(action.source).map((e) => e.name);
|
|
76
|
+
// Extra content survives copying, so the complete directory cannot be classified as identical.
|
|
77
|
+
if (entries(action.destination).some((e) => !names.includes(e.name))) return "conflict";
|
|
78
|
+
const situations = names.map((name) => situationOf({ ...action,
|
|
79
|
+
source: path.join(action.source, name), destination: path.join(action.destination, name) }, items));
|
|
80
|
+
return ["conflict", "update", "new"].find((s) => situations.includes(s)) ?? "same";
|
|
81
|
+
}
|
|
82
|
+
if (!st.isFile()) return "conflict";
|
|
83
|
+
const current = fs.readFileSync(action.destination);
|
|
84
|
+
let previous = items.find((item) => item.type === action.type && samePath(item.destination, action.destination));
|
|
85
|
+
if (previous?.pending && previous.hashBefore === hashContent(current)) previous = previous.previous;
|
|
86
|
+
if (action.type === 'json-merge') return jsonMergeSituation(current, action, previous);
|
|
87
|
+
if (action.type === "block") {
|
|
88
|
+
if (!isUtf8(current)) return "conflict";
|
|
89
|
+
const block = extractBlock(current.toString("utf8"));
|
|
90
|
+
if (!block) return "new"; // Adding a block preserves external AGENTS.md content.
|
|
91
|
+
if (block.invalid) return "conflict";
|
|
92
|
+
const replacement = `${START}\n${action.content}\n${END}`;
|
|
93
|
+
if (block.content === replacement) return "same";
|
|
94
|
+
return previous?.type === "block" && previous.hash === hashContent(block.content)
|
|
95
|
+
? "update" : "conflict";
|
|
96
|
+
}
|
|
97
|
+
const expected = action.type === "copy" ? fs.readFileSync(action.source) : Buffer.from(action.content);
|
|
98
|
+
if (current.equals(expected)) return "same";
|
|
99
|
+
// Project templates may already contain a Codex block from the initial installation.
|
|
100
|
+
let base = current;
|
|
101
|
+
if (action.description === "Project template AGENTS.md") {
|
|
102
|
+
const text = current.toString("utf8");
|
|
103
|
+
const block = extractBlock(text);
|
|
104
|
+
if (block && !block.invalid) {
|
|
105
|
+
const record = items.find((i) => i.type === "block" && samePath(i.destination, action.destination));
|
|
106
|
+
let start = block.start;
|
|
107
|
+
if (record?.separator && text.slice(start - record.separator.length, start) === record.separator) {
|
|
108
|
+
start -= record.separator.length;
|
|
109
|
+
}
|
|
110
|
+
const withoutBlock = Buffer.from(text.slice(0, start) + text.slice(block.end));
|
|
111
|
+
if (withoutBlock.equals(expected)) return "same";
|
|
112
|
+
if (record && hashContent(block.content) === record.hash) base = withoutBlock;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return previous && !previous.pending && previous.hash === hashContent(base)
|
|
116
|
+
? "update" : "conflict";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Directory copies are recorded per file. Compare their current source tree, not
|
|
120
|
+
// just the directory destination, so removed children also leave the installation.
|
|
121
|
+
export function reconcileUpdate(plan, { home }) {
|
|
122
|
+
const state = readState({ home });
|
|
123
|
+
const actions = [];
|
|
124
|
+
const expand = (action) => {
|
|
125
|
+
if (action.owner === 'kit' && action.type === 'copy' && fs.lstatSync(action.source).isDirectory()) {
|
|
126
|
+
const children = entries(action.source);
|
|
127
|
+
if (!children.length) actions.push({ ...action, situation: situationOf(action, state.items) });
|
|
128
|
+
for (const child of children) expand({ ...action, source: path.join(action.source, child.name),
|
|
129
|
+
destination: path.join(action.destination, child.name) });
|
|
130
|
+
} else actions.push(action.owner === 'kit' && action.destination
|
|
131
|
+
? { ...action, situation: situationOf(action, state.items) } : action);
|
|
132
|
+
};
|
|
133
|
+
for (const action of plan) expand(action);
|
|
134
|
+
const key = (item) => JSON.stringify([item.type, path.resolve(item.destination),
|
|
135
|
+
item.type === 'block' ? item.marker ?? START : null]);
|
|
136
|
+
const wanted = new Set(actions.filter((a) => a.destination).map(key));
|
|
137
|
+
const removals = state.items.filter((item) => item.owner === 'kit'
|
|
138
|
+
&& ['copy', 'write', 'block', 'json-merge'].includes(item.type) && !wanted.has(key(item)))
|
|
139
|
+
.map((item) => ({ ...item, operation: 'remove', situation: 'remove' }));
|
|
140
|
+
return Object.assign([...removals, ...actions], { warnings: plan.warnings, preparation: plan.preparation });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function buildPlan(choices, detection, paths) {
|
|
144
|
+
const { home, kit, ecc, project } = paths;
|
|
145
|
+
if (!home || !kit) throw new Error("paths.home and paths.kit are required");
|
|
146
|
+
const state = detection?.installed?.state ?? readState({ home });
|
|
147
|
+
if (!["global", "project"].includes(choices.scope)) throw new Error("Invalid scope");
|
|
148
|
+
const local = choices.scope === "project";
|
|
149
|
+
if (local && !project) throw new Error("Project scope requires paths.project");
|
|
150
|
+
const kitClis = uniqueList(choices.clis);
|
|
151
|
+
const eccClis = uniqueList(choices.ecc?.clis);
|
|
152
|
+
if (eccClis.includes("agy")) throw new Error("ECC is not supported on Antigravity");
|
|
153
|
+
const clis = uniqueList([...kitClis, ...eccClis]);
|
|
154
|
+
if (clis.some((cli) => !CLIS.includes(cli))) throw new Error("Invalid CLI");
|
|
155
|
+
if (eccClis.length && !ecc) throw new Error("Selected ECC requires downloaded paths.ecc");
|
|
156
|
+
const root = local ? project : home;
|
|
157
|
+
const actions = [];
|
|
158
|
+
const warnings = [];
|
|
159
|
+
const destinations = new Map();
|
|
160
|
+
const add = (type, owner, cli, source, destination, content, description) => {
|
|
161
|
+
const action = { type, owner, cli, source, destination, content, description };
|
|
162
|
+
const key = `${type}:${destination.toLowerCase()}`;
|
|
163
|
+
if (destinations.has(key)) {
|
|
164
|
+
const previous = destinations.get(key);
|
|
165
|
+
if (previous.source !== source || previous.content !== content) {
|
|
166
|
+
throw new Error(`Destination collision: ${destination}`);
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
destinations.set(key, action);
|
|
171
|
+
actions.push(action);
|
|
172
|
+
};
|
|
173
|
+
if (local && clis.length) {
|
|
174
|
+
for (const name of ["AGENTS.md", ...(clis.includes("claude") ? ["CLAUDE.md"] : [])]) {
|
|
175
|
+
add("copy", "kit", name === "CLAUDE.md" ? "claude" : clis.includes("codex") ? "codex" : clis[0],
|
|
176
|
+
path.join(kit, "templates", name), path.join(project, name), null, `Project template ${name}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
for (const cli of clis) {
|
|
180
|
+
const destinationRules = cli === "claude" ? path.join(root, ".claude", "rules")
|
|
181
|
+
: local ? path.join(project, ".agents", "rules") : path.join(home, ".gemini", "config", "rules");
|
|
182
|
+
const codexBase = path.join(root, ".codex", "vibecoding");
|
|
183
|
+
const codexRules = [];
|
|
184
|
+
if (kitClis.includes(cli)) {
|
|
185
|
+
const rules = readRules(path.join(kit, "rules")).filter((r) => choices.maestri || !r.name.startsWith("maestri-"));
|
|
186
|
+
for (const rule of rules) {
|
|
187
|
+
if (cli === "claude") {
|
|
188
|
+
add("copy", "kit", cli, rule.source, path.join(destinationRules, "vibecoding", rule.name),
|
|
189
|
+
null, `Kit rule: ${rule.name}`);
|
|
190
|
+
} else if (cli === "agy") {
|
|
191
|
+
add("write", "kit", cli, rule.source, path.join(destinationRules, `vibecoding-${rule.name}`),
|
|
192
|
+
convertAntigravityRule(rule.content), `Converted rule: ${rule.name}`);
|
|
193
|
+
} else {
|
|
194
|
+
const destination = path.join(codexBase, "rules", rule.name);
|
|
195
|
+
if (readFrontmatter(rule.content).paths.length) {
|
|
196
|
+
add("copy", "kit", cli, rule.source, destination, null, `On-demand rule: ${rule.name}`);
|
|
197
|
+
}
|
|
198
|
+
codexRules.push({ ...rule, destination });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const destinationSkills = cli === "claude" ? path.join(root, ".claude", "skills")
|
|
202
|
+
: cli === "codex" || local ? path.join(root, ".agents", "skills")
|
|
203
|
+
: path.join(home, ".gemini", "config", "skills");
|
|
204
|
+
for (const skill of entries(path.join(kit, "skills"))) {
|
|
205
|
+
if (!skill.isDirectory() || (!choices.maestri && skill.name.startsWith("maestri-"))) continue;
|
|
206
|
+
add("copy", "kit", cli, path.join(kit, "skills", skill.name),
|
|
207
|
+
path.join(destinationSkills, skill.name), null, `Kit skill: ${skill.name}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (eccClis.includes(cli)) {
|
|
211
|
+
const packages = uniqueList(["common", ...(cli === "claude" ? ["web"] : []),
|
|
212
|
+
...(local ? choices.ecc?.stacks ?? [] : [])]);
|
|
213
|
+
const packageFiles = Object.fromEntries(packages.map((packageName) => {
|
|
214
|
+
if (!/^[a-z0-9-]+$/i.test(packageName)) throw new Error(`Invalid ECC package: ${packageName}`);
|
|
215
|
+
return [packageName, readRules(path.join(ecc, "rules", packageName))];
|
|
216
|
+
}));
|
|
217
|
+
for (const packageName of packages) {
|
|
218
|
+
const directory = path.join(ecc, "rules", packageName);
|
|
219
|
+
const rules = packageFiles[packageName];
|
|
220
|
+
if (cli === "claude") {
|
|
221
|
+
add("copy", "ecc", cli, directory, path.join(destinationRules, "ecc", packageName),
|
|
222
|
+
null, `ECC rules: ${packageName}`);
|
|
223
|
+
} else {
|
|
224
|
+
// Preserve directories so original relative links to common rules keep working.
|
|
225
|
+
add("copy", "ecc", cli, directory, path.join(codexBase, "ecc", packageName), null, `ECC rules: ${packageName}`);
|
|
226
|
+
for (const rule of rules) codexRules.push({ ...rule, owner: "ecc",
|
|
227
|
+
packageName, destinationDirectory: path.join(codexBase, "ecc", packageName),
|
|
228
|
+
destination: path.join(codexBase, "ecc", packageName, rule.name),
|
|
229
|
+
when: packageName === "common" ? `code (${packageName}/${rule.name})` : `the stack ${packageName} (${rule.name})` });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (cli === "codex" && codexRules.length) {
|
|
234
|
+
add("block", kitClis.includes(cli) ? "kit" : "ecc", cli, null,
|
|
235
|
+
local ? path.join(project, "AGENTS.md") : path.join(home, ".codex", "AGENTS.md"),
|
|
236
|
+
buildCodexBlock(codexRules), "Codex rules and routing");
|
|
237
|
+
const original = codexBytes(actions, home, project, state.items);
|
|
238
|
+
if (original > 32 * 1024) {
|
|
239
|
+
const action = actions.at(-1);
|
|
240
|
+
action.content = buildCodexBlock(codexRules, { byPackage: true });
|
|
241
|
+
const reduced = codexBytes(actions, home, project, state.items);
|
|
242
|
+
const warning = `Codex: estimated context of ${original} bytes exceeds 32 KiB; `
|
|
243
|
+
+ `routing reduced to one line per package (${reduced} bytes).`;
|
|
244
|
+
if (reduced > 32 * 1024) {
|
|
245
|
+
throw new Error(`${warning} Still exceeds the 32768-byte limit; reduce AGENTS.md files before installing.`);
|
|
246
|
+
}
|
|
247
|
+
warnings.push(warning);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
actions.push(...hookActions(choices, { home, kit, project }));
|
|
252
|
+
const plan = actions.map((action) => ({ ...action, situation: situationOf(action, state.items) }));
|
|
253
|
+
if (warnings.length) plan.warnings = warnings;
|
|
254
|
+
return plan;
|
|
255
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
export const kitVersion = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
7
|
+
export const hashContent = (content) => createHash('sha256').update(content).digest('hex');
|
|
8
|
+
export const statePath = (home = os.homedir()) => path.join(home, '.vibecoding-devtools', 'state.json');
|
|
9
|
+
|
|
10
|
+
export function readState({ home = os.homedir() } = {}) {
|
|
11
|
+
try {
|
|
12
|
+
const state = JSON.parse(fs.readFileSync(statePath(home), 'utf8'));
|
|
13
|
+
if (!state || !Array.isArray(state.items) || typeof state.version !== 'string') {
|
|
14
|
+
throw new Error('Invalid installer state');
|
|
15
|
+
}
|
|
16
|
+
return state;
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (error.code === 'ENOENT') return { version: kitVersion, date: null, items: [] };
|
|
19
|
+
throw error; // Corrupt state never becomes an empty installation.
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function writeAtomic(destination, content) {
|
|
24
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
25
|
+
const temporary = `${destination}.${randomUUID()}.tmp`;
|
|
26
|
+
let fd;
|
|
27
|
+
try {
|
|
28
|
+
let mode = 0o600;
|
|
29
|
+
try { mode = fs.statSync(destination).mode & 0o777; } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
30
|
+
fd = fs.openSync(temporary, 'wx', mode);
|
|
31
|
+
fs.writeFileSync(fd, content);
|
|
32
|
+
fs.fsyncSync(fd);
|
|
33
|
+
fs.closeSync(fd);
|
|
34
|
+
fd = undefined;
|
|
35
|
+
fs.renameSync(temporary, destination);
|
|
36
|
+
} finally {
|
|
37
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
38
|
+
fs.rmSync(temporary, { force: true });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function saveState(state, { home = os.homedir() } = {}) {
|
|
43
|
+
const content = JSON.stringify({ ...state, version: kitVersion, date: new Date().toISOString() }, null, 2) + '\n';
|
|
44
|
+
writeAtomic(statePath(home), content);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const lockPath = (home) => path.join(home, '.vibecoding-devtools', 'installer.lock');
|
|
48
|
+
|
|
49
|
+
// The lock covers the whole read, effect and write transaction, including JSON replacement.
|
|
50
|
+
// Live PIDs never expire; incomplete locks can be recovered only after 30 seconds.
|
|
51
|
+
export function withLock(home, work, { dryRun = false } = {}) {
|
|
52
|
+
if (dryRun) return work(); // Dry-run creates and removes no files.
|
|
53
|
+
const file = lockPath(home);
|
|
54
|
+
for (let p = path.dirname(file); ; p = path.dirname(p)) {
|
|
55
|
+
try { if (fs.lstatSync(p).isSymbolicLink()) throw new Error(`Link in state directory: ${p}`); }
|
|
56
|
+
catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
57
|
+
if (p === path.dirname(p)) break;
|
|
58
|
+
}
|
|
59
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
60
|
+
const token = randomUUID();
|
|
61
|
+
let fd;
|
|
62
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
63
|
+
try {
|
|
64
|
+
fd = fs.openSync(file, 'wx', 0o600);
|
|
65
|
+
break;
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error.code !== 'EEXIST') throw error;
|
|
68
|
+
const st = fs.lstatSync(file);
|
|
69
|
+
if (!st.isFile() || st.isSymbolicLink()) throw new Error('Invalid installer lock');
|
|
70
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
71
|
+
let stale = false;
|
|
72
|
+
try {
|
|
73
|
+
const lock = JSON.parse(text);
|
|
74
|
+
if (!Number.isInteger(lock.pid) || lock.pid <= 0 || typeof lock.token !== 'string') throw new Error();
|
|
75
|
+
try { process.kill(lock.pid, 0); }
|
|
76
|
+
catch (e) { if (e.code === 'ESRCH') stale = true; }
|
|
77
|
+
} catch {
|
|
78
|
+
stale = Date.now() - st.mtimeMs > 30000;
|
|
79
|
+
}
|
|
80
|
+
if (!stale) throw new Error('Another installation is in progress (active lock)');
|
|
81
|
+
if (fs.readFileSync(file, 'utf8') !== text) continue;
|
|
82
|
+
fs.unlinkSync(file);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (fd === undefined) throw new Error('Could not acquire the installer lock');
|
|
86
|
+
const acquired = fs.fstatSync(fd);
|
|
87
|
+
try {
|
|
88
|
+
fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, token }));
|
|
89
|
+
fs.fsyncSync(fd);
|
|
90
|
+
return work();
|
|
91
|
+
} finally {
|
|
92
|
+
fs.closeSync(fd);
|
|
93
|
+
// Never release a lock now owned by another process.
|
|
94
|
+
try {
|
|
95
|
+
const current = fs.lstatSync(file);
|
|
96
|
+
if (current.dev === acquired.dev && current.ino === acquired.ino) {
|
|
97
|
+
let mine = true;
|
|
98
|
+
try { mine = JSON.parse(fs.readFileSync(file, 'utf8')).token === token; }
|
|
99
|
+
catch (e) { if (!(e instanceof SyntaxError)) throw e; } // The initial lock write failed.
|
|
100
|
+
if (mine) fs.unlinkSync(file);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"ecc": {
|
|
3
|
+
"packageName": "ecc-universal",
|
|
4
|
+
"version": "2.2.3",
|
|
5
|
+
"repository": "https://github.com/affaan-m/ECC.git",
|
|
6
|
+
"tag": "v2.2.3",
|
|
7
|
+
"marketplace": "affaan-m/ECC@v2.2.3",
|
|
8
|
+
"plugin": "ecc@ecc",
|
|
9
|
+
"modules": ["agents-core", "commands-core", "platform-configs", "workflow-quality"],
|
|
10
|
+
"hooksModule": "hooks-runtime"
|
|
11
|
+
},
|
|
12
|
+
"impeccable": {
|
|
13
|
+
"packageName": "impeccable",
|
|
14
|
+
"version": "4.8.0"
|
|
15
|
+
}
|
|
16
|
+
}
|