@aksp/opencrew 1.6.2 → 1.6.3
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/CHANGELOG.md +51 -0
- package/README.md +20 -8
- package/package.json +2 -2
- package/src/cli.js +15 -38
- package/src/commands/init.js +41 -44
- package/src/commands/update.js +78 -75
- package/src/lib/blocos.js +148 -0
- package/src/lib/deteccao.js +69 -0
- package/src/lib/fsx.js +1 -55
- package/src/lib/ides.js +4 -0
- package/src/lib/legado.js +142 -0
- package/src/lib/manifest.js +67 -26
- package/src/lib/mcp.js +131 -0
- package/src/lib/migrations.js +77 -74
- package/src/lib/node-version.js +43 -0
- package/src/lib/prompts.js +25 -2
- package/src/lib/resumo.js +125 -0
- package/templates/.mcp.json +1 -1
- package/templates/_opencrew/.opencrew-version +1 -1
- package/templates/_opencrew/core/best-practices/social-networks-publishing.md +14 -14
- package/templates/_opencrew/core/prompts/export.prompt.md +1 -1
- package/templates/_opencrew/core/prompts/sherlock-shared.md +5 -5
- package/templates/_opencrew/core/runner.pipeline.md +32 -12
- package/templates/_opencrew/core/scripts/comum.mjs +49 -4
- package/templates/_opencrew/core/scripts/conferir-fontes/busca.mjs +42 -3
- package/templates/_opencrew/core/scripts/conferir-fontes/relatorio.mjs +18 -6
- package/templates/_opencrew/core/scripts/conferir-fontes.mjs +97 -39
- package/templates/_opencrew/core/scripts/verificar.mjs +7 -4
- package/templates/_opencrew/core/skills.engine.md +7 -3
- package/templates/gitignore +1 -0
- package/templates/skills/blotato/SKILL.md +39 -10
- package/templates/skills/image-ai-generator/SKILL.md +18 -5
- package/templates/skills/image-ai-generator/scripts/generate.py +52 -10
- package/templates/skills/instagram-publisher/SKILL.md +4 -0
- package/templates/skills/opencrew-skill-creator/references/skill-format.md +1 -0
- package/templates/skills/resend/SKILL.md +52 -13
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Marked blocks (R2 rule 4). `deliverBlock` is the ONE writer of every `opencrew:start … end`
|
|
2
|
+
// block — IDE bridges, AGENTS.md, .gitignore, .env.example — for init, update and repair.
|
|
3
|
+
// The manifest records each block (`<file>#opencrew` → sha256 of the block, markers included,
|
|
4
|
+
// with LF), so a block the user edited is told apart from one that is just older: the whole
|
|
5
|
+
// file is copied to .opencrew-backup/ before a block that differs from its record (or has
|
|
6
|
+
// none) is rewritten. CRLF/LF is never a difference, and what is written follows the file.
|
|
7
|
+
import { promises as fs } from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { exists } from './fsx.js';
|
|
10
|
+
import { deliverFile, backupFile, hashOf } from './manifest.js';
|
|
11
|
+
|
|
12
|
+
// The file is handled byte by byte (latin1 goes there and back unchanged): whatever its encoding,
|
|
13
|
+
// no byte of the user outside the block changes. `bytesOf` = UTF-8 text in that same form.
|
|
14
|
+
const bytesOf = (text) => Buffer.from(text, 'utf8').toString('latin1');
|
|
15
|
+
const BOM = bytesOf(String.fromCharCode(0xfeff));
|
|
16
|
+
const isUtf16 = (raw) => (raw[0] === 0xff && raw[1] === 0xfe) || (raw[0] === 0xfe && raw[1] === 0xff);
|
|
17
|
+
|
|
18
|
+
// Files of the user where the block goes at the END, between `#` comment markers.
|
|
19
|
+
const AT_END = new Set(['.gitignore', '.env.example']);
|
|
20
|
+
const ONLY_THE_BLOCK = '(só o bloco do OpenCrew foi regravado; o resto do arquivo não mudou)';
|
|
21
|
+
|
|
22
|
+
/** Key of a block in the manifest (`file` = path relative to the project root, with `/`). */
|
|
23
|
+
export const blockKey = (file) => `${file}#opencrew`;
|
|
24
|
+
|
|
25
|
+
const markersOf = (file) => (AT_END.has(file)
|
|
26
|
+
? ['# opencrew:start', '# opencrew:end']
|
|
27
|
+
: ['<!-- opencrew:start -->', '<!-- opencrew:end -->']);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Complete blocks of `text`, as [from, to) ranges. Innermost start only: an orphan start
|
|
31
|
+
* marker (its end deleted by hand) never makes a block swallow the user lines after it.
|
|
32
|
+
*/
|
|
33
|
+
export function blockRanges(text, file) {
|
|
34
|
+
const [start, end] = markersOf(file);
|
|
35
|
+
const found = [];
|
|
36
|
+
let pos = 0;
|
|
37
|
+
for (let e = text.indexOf(end); e !== -1; e = text.indexOf(end, pos)) {
|
|
38
|
+
const s = text.lastIndexOf(start, e - start.length);
|
|
39
|
+
if (s >= pos && s + start.length <= e) found.push([s, e + end.length]);
|
|
40
|
+
pos = e + end.length;
|
|
41
|
+
}
|
|
42
|
+
return found;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** True when a marked block of `file` contains `needle`; text outside the blocks never counts. */
|
|
46
|
+
export async function blockHas(target, file, needle) {
|
|
47
|
+
const dest = path.join(target, file);
|
|
48
|
+
if (!(await exists(dest))) return false;
|
|
49
|
+
const text = await fs.readFile(dest, 'utf8');
|
|
50
|
+
return blockRanges(text, file).some(([from, to]) => text.slice(from, to).includes(needle));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function replaceRanges(text, ranges, block) {
|
|
54
|
+
let out = '';
|
|
55
|
+
let pos = 0;
|
|
56
|
+
for (const [from, to] of ranges) {
|
|
57
|
+
out += text.slice(pos, from) + block;
|
|
58
|
+
pos = to;
|
|
59
|
+
}
|
|
60
|
+
return out + text.slice(pos);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* What delivering `marked` does to a file whose content is `text` (null = no file):
|
|
65
|
+
* the action, the new content (none when kept) and the hash of each block found.
|
|
66
|
+
*/
|
|
67
|
+
function planBlock(file, text, marked) {
|
|
68
|
+
if (text === null) return { action: 'created', text: `${bytesOf(marked)}\n`, hashes: [] };
|
|
69
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
70
|
+
const block = bytesOf(marked).replace(/\n/g, eol);
|
|
71
|
+
const ranges = blockRanges(text, file);
|
|
72
|
+
if (!ranges.length) {
|
|
73
|
+
const bom = text.startsWith(BOM) ? BOM : '';
|
|
74
|
+
const added = AT_END.has(file)
|
|
75
|
+
? `${text.replace(/[ \t\r\n]+$/, '')}${eol}${eol}${block}${eol}`
|
|
76
|
+
: `${bom}${block}${eol}${eol}${text.slice(bom.length).replace(/^[ \t\r\n]+/, '')}`;
|
|
77
|
+
return { action: 'added', text: added, hashes: [] };
|
|
78
|
+
}
|
|
79
|
+
const hashes = ranges.map(([from, to]) => hashOf(Buffer.from(text.slice(from, to), 'latin1').toString('utf8')));
|
|
80
|
+
if (hashes.every((h) => h === hashOf(marked))) return { action: 'kept', hashes };
|
|
81
|
+
return { action: 'updated', text: replaceRanges(text, ranges, block), hashes };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Put `content` between the opencrew markers of `file` (path relative to the project root,
|
|
86
|
+
* with `/`) and record the block in `ctx.files`.
|
|
87
|
+
* - no file → `created`; file with no block → `added` at the top (at the end in .gitignore
|
|
88
|
+
* and .env.example), the user's content kept, no copy;
|
|
89
|
+
* - block equal to the new one → `kept`, nothing written;
|
|
90
|
+
* - otherwise → `updated`: only the block is rewritten, in the line ending of the file. The
|
|
91
|
+
* whole file is copied first unless the block is exactly what the manifest recorded.
|
|
92
|
+
* @returns {Promise<{file:string, action:'created'|'added'|'updated'|'kept', copied:boolean, block:true}>}
|
|
93
|
+
* also pushed to `ctx.blocks`. `copied` = this call made the backup copy.
|
|
94
|
+
*/
|
|
95
|
+
export async function deliverBlock(ctx, file, content) {
|
|
96
|
+
const [start, end] = markersOf(file);
|
|
97
|
+
const marked = `${start}\n${content.replace(/\r\n/g, '\n').trimEnd()}\n${end}`;
|
|
98
|
+
const dest = path.join(ctx.target, file);
|
|
99
|
+
const raw = (await exists(dest)) ? await fs.readFile(dest) : null;
|
|
100
|
+
const plan = planBlock(file, raw && raw.toString('latin1'), marked);
|
|
101
|
+
const record = ctx.manifest?.files?.[blockKey(file)];
|
|
102
|
+
const edited = plan.action === 'updated' && !plan.hashes.every((h) => h === record);
|
|
103
|
+
// A UTF-16 file cannot take a UTF-8 block without damage: the whole file is copied first.
|
|
104
|
+
const risky = raw && plan.text !== undefined && isUtf16(raw);
|
|
105
|
+
const copied = (edited || risky) && (await backupFile(ctx, file));
|
|
106
|
+
if (plan.text !== undefined) {
|
|
107
|
+
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
108
|
+
await fs.writeFile(dest, Buffer.from(plan.text, 'latin1'));
|
|
109
|
+
}
|
|
110
|
+
ctx.files[blockKey(file)] = hashOf(marked);
|
|
111
|
+
const result = { file, action: plan.action, copied, block: true };
|
|
112
|
+
ctx.blocks.push(result);
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Write the bridge files of `ides`, each path once: a file with frontmatter is delivered whole
|
|
118
|
+
* (replaced only when `overwrite`), the others by marked block.
|
|
119
|
+
* @returns {Promise<Array<{ide:object, file:string, action:string, copied:boolean, block?:true, shared?:true}>>}
|
|
120
|
+
* one entry per file of each IDE, in order. `shared` = the path had already been written for
|
|
121
|
+
* an earlier IDE of the list (same action; count copies by `ctx.copied`, not by these entries).
|
|
122
|
+
*/
|
|
123
|
+
export async function deliverBridges(ctx, ides, { overwrite = true } = {}) {
|
|
124
|
+
const byPath = new Map();
|
|
125
|
+
const results = [];
|
|
126
|
+
for (const ide of ides) {
|
|
127
|
+
for (const f of ide.files) {
|
|
128
|
+
if (byPath.has(f.path)) {
|
|
129
|
+
results.push({ ide, ...byPath.get(f.path), shared: true });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const result = f.content.startsWith('---')
|
|
133
|
+
? await deliverFile(ctx, path.join(ctx.target, f.path), f.content, { overwrite })
|
|
134
|
+
: await deliverBlock(ctx, f.path, f.content);
|
|
135
|
+
byPath.set(f.path, result);
|
|
136
|
+
results.push({ ide, ...result });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return results;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Line of a list of backup copies: a file copied because of its block says that only the
|
|
144
|
+
* block was rewritten. `shown` = how the caller prints the file (default: its path).
|
|
145
|
+
*/
|
|
146
|
+
export function copyLine(ctx, file, shown = file) {
|
|
147
|
+
return ctx.blocks.some((b) => b.copied && b.file === file) ? `${shown} ${ONLY_THE_BLOCK}` : shown;
|
|
148
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Which IDEs are installed in a project (spec R2, rules 1 and 2) — shared by `update` and
|
|
2
|
+
// `init --repair-bridges`. An IDE is proved by a FILE, never by the word "opencrew" somewhere:
|
|
3
|
+
// (a) a bridge file that only this IDE writes, on a path that has "opencrew" in it: it exists;
|
|
4
|
+
// (b) an instruction file the IDE shares with the user (no "opencrew" in the path): it has
|
|
5
|
+
// the start marker of the block, or the line of the title OpenCrew generates in it.
|
|
6
|
+
// An IDE with no file of its own (today, the Codex) counts only when its file is there and no
|
|
7
|
+
// detected IDE writes the same path. A user file that only cites the word proves nothing.
|
|
8
|
+
import { promises as fs } from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { exists } from './fsx.js';
|
|
11
|
+
import { IDES } from './ides.js';
|
|
12
|
+
|
|
13
|
+
const START = '<!-- opencrew:start -->';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The instruction files shared with the user and the title generated in each since 1.0.0.
|
|
17
|
+
* Fixed here on purpose, never read from today's bridge content: a title changed in ides.js
|
|
18
|
+
* must not hide the installs made by the versions that wrote the old one (up to 1.2.2 the
|
|
19
|
+
* file had no marker — the title is all there is). Pinned by R2-01d in tests/ides.test.js.
|
|
20
|
+
*/
|
|
21
|
+
export const USER_FILE_TITLES = Object.freeze({
|
|
22
|
+
'CLAUDE.md': '# opencrew — Project Instructions',
|
|
23
|
+
'.github/copilot-instructions.md': '# opencrew — Copilot Instructions',
|
|
24
|
+
'GEMINI.md': '# opencrew — Gemini CLI',
|
|
25
|
+
'QWEN.md': '# opencrew — Qwen Code',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const BOM = String.fromCharCode(0xfeff);
|
|
29
|
+
|
|
30
|
+
/** A line of a file as OpenCrew compares it: no BOM in front, no CR at the end. */
|
|
31
|
+
export function plainLine(raw) {
|
|
32
|
+
const line = raw.startsWith(BOM) ? raw.slice(1) : raw;
|
|
33
|
+
return line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const writersOf = (file) => IDES.filter((ide) => ide.files.some((f) => f.path === file));
|
|
37
|
+
const ownFiles = (ide) => ide.files.filter((f) => writersOf(f.path).length === 1);
|
|
38
|
+
|
|
39
|
+
/** True when the bridge file `file` (path relative to the root, with `/`) proves its IDE. */
|
|
40
|
+
async function proves(target, file) {
|
|
41
|
+
const dest = path.join(target, file);
|
|
42
|
+
if (!(await exists(dest))) return false;
|
|
43
|
+
if (file.includes('opencrew')) return true;
|
|
44
|
+
const text = await fs.readFile(dest, 'utf8');
|
|
45
|
+
return text.includes(START) || text.split('\n').map(plainLine).includes(USER_FILE_TITLES[file]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function provesAny(target, files) {
|
|
49
|
+
for (const f of files) if (await proves(target, f.path)) return true;
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* IDEs installed in `target`, in the order of `IDES`.
|
|
55
|
+
* @param {string} target project root
|
|
56
|
+
* @returns {Promise<Array<object>>} entries of `IDES`
|
|
57
|
+
*/
|
|
58
|
+
export async function detectInstalledIdes(target) {
|
|
59
|
+
const byOwnFile = [];
|
|
60
|
+
for (const ide of IDES) if (await provesAny(target, ownFiles(ide))) byOwnFile.push(ide);
|
|
61
|
+
// Rule 2: a path that a detected IDE writes proves nothing about an IDE with no own file.
|
|
62
|
+
const free = (f) => !writersOf(f.path).some((ide) => byOwnFile.includes(ide));
|
|
63
|
+
const found = [];
|
|
64
|
+
for (const ide of IDES) {
|
|
65
|
+
const alone = !ownFiles(ide).length && (await provesAny(target, ide.files.filter(free)));
|
|
66
|
+
if (alone || byOwnFile.includes(ide)) found.push(ide);
|
|
67
|
+
}
|
|
68
|
+
return found;
|
|
69
|
+
}
|
package/src/lib/fsx.js
CHANGED
|
@@ -50,61 +50,7 @@ export async function writeFileSafe(p, content, { overwrite = true } = {}) {
|
|
|
50
50
|
return true;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
* Write a bridge file using marked-block strategy.
|
|
55
|
-
* - File doesn't exist → creates with content wrapped in HTML markers
|
|
56
|
-
* - File exists with markers → replaces only the block between markers
|
|
57
|
-
* - File exists without markers → adds the marked block (prepend or append), preserving
|
|
58
|
-
* existing content byte for byte
|
|
59
|
-
*
|
|
60
|
-
* @param {string} p File path
|
|
61
|
-
* @param {string} block Content to place between markers
|
|
62
|
-
* @param {object} opts
|
|
63
|
-
* @param {string} opts.marker Marker name (default: 'opencrew')
|
|
64
|
-
* @param {'html'|'hash'} opts.comment `<!-- x:start -->` (markdown) or `# x:start` (.gitignore, .env)
|
|
65
|
-
* @param {'prepend'|'append'} opts.position Where a new block goes in an unmarked file
|
|
66
|
-
* @returns {Promise<{written: boolean, merged: boolean}>}
|
|
67
|
-
*/
|
|
68
|
-
export async function writeBridgeFile(p, block, { marker = 'opencrew', comment = 'html', position = 'prepend' } = {}) {
|
|
69
|
-
const [start, end] = comment === 'hash'
|
|
70
|
-
? [`# ${marker}:start`, `# ${marker}:end`]
|
|
71
|
-
: [`<!-- ${marker}:start -->`, `<!-- ${marker}:end -->`];
|
|
72
|
-
const marked = `${start}\n${block.trimEnd()}\n${end}`;
|
|
73
|
-
|
|
74
|
-
if (!(await exists(p))) {
|
|
75
|
-
await ensureDir(path.dirname(p));
|
|
76
|
-
await fs.writeFile(p, marked + '\n');
|
|
77
|
-
return { written: true, merged: false };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const existing = await fs.readFile(p, 'utf8');
|
|
81
|
-
|
|
82
|
-
// Already has markers → replace just the block, keep everything else
|
|
83
|
-
if (existing.includes(start) && existing.includes(end)) {
|
|
84
|
-
// Innermost block only: an orphan start marker (end deleted by hand) must never make
|
|
85
|
-
// the match swallow the user lines between it and the real block.
|
|
86
|
-
const updated = existing.replace(
|
|
87
|
-
new RegExp(`${escapeRx(start)}(?:(?!${escapeRx(start)})[\\s\\S])*?${escapeRx(end)}`, 'g'),
|
|
88
|
-
() => marked,
|
|
89
|
-
);
|
|
90
|
-
if (updated !== existing) {
|
|
91
|
-
await fs.writeFile(p, updated);
|
|
92
|
-
return { written: true, merged: false };
|
|
93
|
-
}
|
|
94
|
-
return { written: false, merged: false };
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// User content exists → add the block, preserve everything
|
|
98
|
-
const merged = position === 'append'
|
|
99
|
-
? `${existing.trimEnd()}\n\n${marked}\n`
|
|
100
|
-
: `${marked}\n\n${existing.trimStart()}`;
|
|
101
|
-
await fs.writeFile(p, merged);
|
|
102
|
-
return { written: true, merged: true };
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function escapeRx(s) {
|
|
106
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
107
|
-
}
|
|
53
|
+
// Marked blocks (`opencrew:start … opencrew:end`) are written by `deliverBlock`, in ./blocos.js.
|
|
108
54
|
|
|
109
55
|
export async function readFile(p) {
|
|
110
56
|
return fs.readFile(p, 'utf8');
|
package/src/lib/ides.js
CHANGED
|
@@ -56,6 +56,10 @@ export const AGENTS_BRIDGE = `# opencrew\n\n${ACTIVATION}\n\nType \`/opencrew\`
|
|
|
56
56
|
// Marker that identifies the maintainer STATUS.md section leaked into CLAUDE.md by 1.4.0/1.4.1.
|
|
57
57
|
export const LEAKED_STATUS_SECTION = '## STATUS.md (gestão de sessão)';
|
|
58
58
|
|
|
59
|
+
// The title of CLAUDE.md, GEMINI.md, QWEN.md and copilot-instructions.md (files shared with
|
|
60
|
+
// the user) is how `update` recognizes a bridge written up to 1.2.2, which has no marker. The
|
|
61
|
+
// four titles are fixed in ./deteccao.js and pinned by R2-01d (tests/ides.test.js): to change
|
|
62
|
+
// one here, keep the old one there.
|
|
59
63
|
const render = (title, extra = '') =>
|
|
60
64
|
`# ${title}\n\n${BRIDGE}${extra ? `\n\n${extra}` : ''}\n`;
|
|
61
65
|
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Old text of the bridges (spec R2, rule 3). Up to 1.2.2 the five bridges with no frontmatter
|
|
2
|
+
// were written as WHOLE files, telling the AI to adopt the OpenCrew role always. Since 1.3.0
|
|
3
|
+
// the bridge lives in a marked block, and a file that kept the old text outside the block
|
|
4
|
+
// keeps giving that order. `update` and `init --repair-bridges` take it out — only when it
|
|
5
|
+
// is exactly what was generated (CRLF tolerated), and after copying the whole file. An edited
|
|
6
|
+
// one (only the title or the "adopt" sentence is left) is never touched: it is reported.
|
|
7
|
+
// `src/lib/ides.js` wrote ONE text per file from v1.0.0 to v1.2.2; they are fixed below.
|
|
8
|
+
import { promises as fs } from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { exists } from './fsx.js';
|
|
11
|
+
import { backupFile } from './manifest.js';
|
|
12
|
+
import { blockRanges } from './blocos.js';
|
|
13
|
+
import { USER_FILE_TITLES, plainLine } from './deteccao.js';
|
|
14
|
+
|
|
15
|
+
const OLD_BRIDGE = `Read \`AGENTS.md\` at the project root and adopt the opencrew system role.
|
|
16
|
+
Follow all initialization, command routing, and workflow instructions defined there.
|
|
17
|
+
|
|
18
|
+
If invoked with arguments (e.g. \`/opencrew create ...\`, \`/opencrew run ...\`),
|
|
19
|
+
route to the matching action from the Command Routing table in AGENTS.md.
|
|
20
|
+
If invoked without arguments, show the Main Menu.`;
|
|
21
|
+
|
|
22
|
+
const OLD_CLAUDE_MD = `This project uses **opencrew**, a multi-agent orchestration framework.
|
|
23
|
+
The full system definition lives in \`AGENTS.md\` — read it and adopt that role.
|
|
24
|
+
|
|
25
|
+
Type \`/opencrew\` to open the main menu.
|
|
26
|
+
|
|
27
|
+
## Notes for Claude Code
|
|
28
|
+
|
|
29
|
+
- All checkpoint questions use \`AskUserQuestion\`.
|
|
30
|
+
- opencrew ships its own Playwright MCP (\`.mcp.json\`); disable the native Playwright plugin.
|
|
31
|
+
- Do not manually edit files under \`_opencrew/core/\` unless you know what you're doing.`;
|
|
32
|
+
|
|
33
|
+
const TITLES = { ...USER_FILE_TITLES, '.trae/rules/opencrew.md': '# opencrew — Trae' };
|
|
34
|
+
|
|
35
|
+
// path → the lines of the old text of that file: title, blank line, body.
|
|
36
|
+
const OLD_LINES = Object.fromEntries(Object.entries(TITLES).map(([file, title]) =>
|
|
37
|
+
[file, [title, '', ...(file === 'CLAUDE.md' ? OLD_CLAUDE_MD : OLD_BRIDGE).split('\n')]]));
|
|
38
|
+
|
|
39
|
+
const REMOVED = (file, copy) => `${file}: removi o texto antigo do OpenCrew (instalações até a 1.2.2), que mandava adotar o papel do OpenCrew sempre. O bloco novo ficou no lugar e o resto do arquivo não mudou. Cópia em ${copy}.`;
|
|
40
|
+
const EDITED = (file) => `${file} ainda tem um texto antigo do OpenCrew, alterado depois da instalação, que manda adotar o papel do OpenCrew sempre. Não mexi nele. Para o OpenCrew só agir quando chamado, apague à mão o trecho que começa em \`# opencrew — …\` fora do bloco \`opencrew:start\` / \`opencrew:end\`.`;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Lines of `text` — where each starts and ends (line break included), its content with no
|
|
44
|
+
* BOM and no CR, and whether it is outside every marked block of `file`.
|
|
45
|
+
*/
|
|
46
|
+
function linesOf(text, file) {
|
|
47
|
+
const blocks = blockRanges(text, file);
|
|
48
|
+
const lines = [];
|
|
49
|
+
let from = 0;
|
|
50
|
+
for (const raw of text.split('\n')) {
|
|
51
|
+
const to = Math.min(from + raw.length + 1, text.length);
|
|
52
|
+
const outside = !blocks.some(([start, end]) => from < end && to > start);
|
|
53
|
+
lines.push({ from, to, outside, text: plainLine(raw) });
|
|
54
|
+
from = to;
|
|
55
|
+
}
|
|
56
|
+
return lines;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** [from, to) of the first exact copy of the old text of `file` outside the blocks, or null. */
|
|
60
|
+
function findExact(text, file) {
|
|
61
|
+
const old = OLD_LINES[file];
|
|
62
|
+
const lines = linesOf(text, file);
|
|
63
|
+
for (let i = 0; i + old.length <= lines.length; i++) {
|
|
64
|
+
const run = lines.slice(i, i + old.length);
|
|
65
|
+
if (run.every((line, k) => line.outside && line.text === old[k])) return [run[0].from, run.at(-1).to];
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `text` up to its last character that is not a line break. */
|
|
71
|
+
function withoutEndBreaks(text) {
|
|
72
|
+
let end = text.length;
|
|
73
|
+
while (end > 0 && (text[end - 1] === '\n' || text[end - 1] === '\r')) end -= 1;
|
|
74
|
+
return text.slice(0, end);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* `text` with every exact copy of the old text of `file` taken out; the rest stays byte for
|
|
79
|
+
* byte. Old text that was the end of the file takes the blank lines above it along: the file
|
|
80
|
+
* ends where the text before it ends (as a new install, when only the block is left).
|
|
81
|
+
*/
|
|
82
|
+
function strip(text, file) {
|
|
83
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
84
|
+
let out = text;
|
|
85
|
+
for (let cut = findExact(out, file); cut; cut = findExact(out, file)) {
|
|
86
|
+
const [before, after] = [out.slice(0, cut[0]), out.slice(cut[1])];
|
|
87
|
+
out = after.trim() ? before + after : `${withoutEndBreaks(before)}${eol}`;
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** True when the title or the "adopt" sentence of the old text of `file` is outside the blocks. */
|
|
93
|
+
function hasTraces(text, file) {
|
|
94
|
+
const [title] = OLD_LINES[file];
|
|
95
|
+
const adopt = OLD_LINES[file].find((line) => line.includes('adopt'));
|
|
96
|
+
return linesOf(text, file).some((line) => line.outside && (line.text === title || line.text.includes(adopt)));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Take the old text out of `file`, or just copy the file when `write` is false. A file in
|
|
101
|
+
* which only traces of the old text are left is not touched: it goes to `ctx.legacy`.
|
|
102
|
+
*/
|
|
103
|
+
async function clean(ctx, file, write) {
|
|
104
|
+
const dest = path.join(ctx.target, file);
|
|
105
|
+
if (!(await exists(dest))) return;
|
|
106
|
+
const text = await fs.readFile(dest, 'utf8');
|
|
107
|
+
const stripped = strip(text, file);
|
|
108
|
+
if (stripped !== text) await backupFile(ctx, file); // once per run: the file as it was
|
|
109
|
+
if (!write) return;
|
|
110
|
+
if (stripped !== text) {
|
|
111
|
+
await fs.writeFile(dest, stripped);
|
|
112
|
+
ctx.legacy.push({ file, action: 'removed' });
|
|
113
|
+
}
|
|
114
|
+
if (hasTraces(stripped, file)) ctx.legacy.push({ file, action: 'edited' });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Run `write` — what delivers the bridges of `ides` (`deliverBridges`, ./blocos.js) — with
|
|
119
|
+
* rule 3 around it. Before: a file with the exact old text outside its block is copied whole,
|
|
120
|
+
* so the copy holds the file as it was and the block written next does not claim that copy as
|
|
121
|
+
* "only the block was rewritten". After, with the new block in place: the old text leaves.
|
|
122
|
+
* What happened to each file goes to `ctx.legacy` (`{file, action: 'removed' | 'edited'}`).
|
|
123
|
+
* @param {object} ctx delivery context (`newDelivery`, ./manifest.js)
|
|
124
|
+
* @param {Array<object>} ides entries of `IDES` whose bridges `write` delivers
|
|
125
|
+
* @param {() => Promise<T>} write
|
|
126
|
+
* @returns {Promise<T>} what `write` returned
|
|
127
|
+
* @template T
|
|
128
|
+
*/
|
|
129
|
+
export async function withoutLegacy(ctx, ides, write) {
|
|
130
|
+
const paths = new Set(ides.flatMap((ide) => ide.files.map((f) => f.path)));
|
|
131
|
+
const files = [...paths].filter((file) => OLD_LINES[file]);
|
|
132
|
+
for (const file of files) await clean(ctx, file, false);
|
|
133
|
+
const result = await write();
|
|
134
|
+
for (const file of files) await clean(ctx, file, true);
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** One line to print per entry of `ctx.legacy` (spec R2, §6), with where the copy is. */
|
|
139
|
+
export function legacyLines(ctx) {
|
|
140
|
+
const dir = path.relative(ctx.target, ctx.backupDir).split(path.sep).join('/');
|
|
141
|
+
return ctx.legacy.map(({ file, action }) => (action === 'removed' ? REMOVED(file, `${dir}/${file}`) : EDITED(file)));
|
|
142
|
+
}
|
package/src/lib/manifest.js
CHANGED
|
@@ -14,76 +14,117 @@ const normalize = (buf) => {
|
|
|
14
14
|
const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf);
|
|
15
15
|
return b.includes(0) ? b : Buffer.from(b.toString('utf8').replace(/\r\n/g, '\n'));
|
|
16
16
|
};
|
|
17
|
-
|
|
17
|
+
/** sha256 of a file or text as the manifest records it (text with LF line endings). */
|
|
18
|
+
export const hashOf = (buf) => createHash('sha256').update(normalize(buf)).digest('hex');
|
|
18
19
|
const rel = (target, abs) => path.relative(target, abs).split(path.sep).join('/');
|
|
19
20
|
|
|
20
|
-
/**
|
|
21
|
+
/** A JSON object: not null, not an array. */
|
|
22
|
+
export const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The manifest, when it can be used: valid JSON whose `files` is an object with at least one
|
|
26
|
+
* entry. @returns {Promise<{files: Record<string,string>} | null>} null = none, or unreadable
|
|
27
|
+
*/
|
|
21
28
|
export async function readManifest(target) {
|
|
22
29
|
try {
|
|
23
30
|
const data = JSON.parse(await fs.readFile(path.join(target, MANIFEST), 'utf8'));
|
|
24
|
-
return data &&
|
|
31
|
+
return isRecord(data?.files) && Object.keys(data.files).length ? data : null;
|
|
25
32
|
} catch {
|
|
26
33
|
return null;
|
|
27
34
|
}
|
|
28
35
|
}
|
|
29
36
|
|
|
37
|
+
/**
|
|
38
|
+
* R2 rule 12: the manifest file exists but `readManifest` cannot use it (a merge conflict, a
|
|
39
|
+
* file cut short). The run goes on as in a project with no record — whatever differs from the
|
|
40
|
+
* package is copied first — and says so with UNREADABLE. A missing manifest is not unreadable.
|
|
41
|
+
*/
|
|
42
|
+
export async function manifestUnreadable(target) {
|
|
43
|
+
return (await exists(path.join(target, MANIFEST))) && !(await readManifest(target));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Spec R2 §6: `update` before it rewrites, `summary` in its list of copies, `repair` in the repair. */
|
|
47
|
+
export const UNREADABLE = {
|
|
48
|
+
update: 'O registro de arquivos (`_opencrew/manifest.json`) está ilegível. Vou tratar esta atualização como a de um projeto sem registro: todo arquivo diferente do pacote novo é copiado para `.opencrew-backup/` antes de ser substituído. O registro é refeito no fim.',
|
|
49
|
+
summary: 'O registro estava ilegível: guardamos tudo o que diferia do pacote novo. Daqui em diante, só o que você editar.',
|
|
50
|
+
repair: 'O registro de arquivos (`_opencrew/manifest.json`) está ilegível; o reparo não o refaz. Rode `npx @aksp/opencrew@latest update` para refazer.',
|
|
51
|
+
};
|
|
52
|
+
|
|
30
53
|
export async function writeManifest(target, version, files) {
|
|
31
54
|
const sorted = Object.fromEntries(Object.entries(files).sort(([a], [b]) => a.localeCompare(b)));
|
|
32
55
|
await fs.writeFile(path.join(target, MANIFEST), JSON.stringify({ version, files: sorted }, null, 2) + '\n');
|
|
33
56
|
}
|
|
34
57
|
|
|
35
58
|
/**
|
|
36
|
-
* Delivery context shared by one init/update run.
|
|
59
|
+
* Delivery context shared by one init/update/repair run. `files` = what goes into the next
|
|
60
|
+
* manifest; `copied` = files copied to `backupDir`; `blocks` = one result per marked block
|
|
61
|
+
* written by `deliverBlock` (src/lib/blocos.js); `legacy` = one entry per bridge file where
|
|
62
|
+
* the old text of ≤ 1.2.2 was removed or found edited (src/lib/legado.js).
|
|
37
63
|
* @param {string} target project root
|
|
38
|
-
* @param {{files:Record<string,string>}|null} manifest previous manifest (null = none)
|
|
64
|
+
* @param {{files:Record<string,string>}|null} manifest previous manifest (null = none, or unreadable)
|
|
39
65
|
*/
|
|
40
66
|
export function newDelivery(target, manifest) {
|
|
41
67
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
42
|
-
|
|
68
|
+
const backupDir = path.join(target, '.opencrew-backup', stamp);
|
|
69
|
+
return { target, manifest, backupDir, files: {}, copied: [], blocks: [], legacy: [], written: 0 };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Copy the file `key` (path relative to the project root, with `/`) to the backup dir before it
|
|
74
|
+
* is changed. Once per run: a second change to the same file must not replace the copy of how
|
|
75
|
+
* the file was. @returns {Promise<boolean>} true when this call made the copy
|
|
76
|
+
*/
|
|
77
|
+
export async function backupFile(ctx, key) {
|
|
78
|
+
if (ctx.copied.includes(key)) return false;
|
|
79
|
+
const copy = path.join(ctx.backupDir, key);
|
|
80
|
+
await fs.mkdir(path.dirname(copy), { recursive: true });
|
|
81
|
+
await fs.copyFile(path.join(ctx.target, key), copy);
|
|
82
|
+
ctx.copied.push(key);
|
|
83
|
+
return true;
|
|
43
84
|
}
|
|
44
85
|
|
|
45
86
|
/**
|
|
46
87
|
* Put `content` at `dest`. Missing → write. Equal → nothing. Different → only if `overwrite`,
|
|
47
88
|
* copying the current file to the backup dir first when the user edited it.
|
|
89
|
+
* @returns {Promise<{file:string, action:'created'|'updated'|'kept', copied:boolean}>}
|
|
48
90
|
*/
|
|
49
91
|
export async function deliverFile(ctx, dest, content, { overwrite }) {
|
|
50
|
-
const
|
|
51
|
-
const fresh =
|
|
92
|
+
const file = rel(ctx.target, dest);
|
|
93
|
+
const fresh = hashOf(content);
|
|
52
94
|
if (!(await exists(dest))) {
|
|
53
95
|
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
54
96
|
await fs.writeFile(dest, content);
|
|
55
|
-
ctx.files[
|
|
97
|
+
ctx.files[file] = fresh;
|
|
56
98
|
ctx.written += 1;
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
const current = sha(await fs.readFile(dest));
|
|
60
|
-
if (current === fresh) {
|
|
61
|
-
ctx.files[key] = fresh;
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
if (!overwrite) return; // the user's version stays (and is not tracked)
|
|
65
|
-
const edited = ctx.manifest ? ctx.manifest.files[key] !== current : true;
|
|
66
|
-
if (edited) {
|
|
67
|
-
const copy = path.join(ctx.backupDir, key);
|
|
68
|
-
await fs.mkdir(path.dirname(copy), { recursive: true });
|
|
69
|
-
await fs.copyFile(dest, copy);
|
|
70
|
-
ctx.copied.push(key);
|
|
99
|
+
return { file, action: 'created', copied: false };
|
|
71
100
|
}
|
|
101
|
+
const current = hashOf(await fs.readFile(dest));
|
|
102
|
+
if (current === fresh) ctx.files[file] = fresh;
|
|
103
|
+
// Different and no `overwrite`: the user's version stays (and is not tracked).
|
|
104
|
+
if (current === fresh || !overwrite) return { file, action: 'kept', copied: false };
|
|
105
|
+
const edited = ctx.manifest ? ctx.manifest.files[file] !== current : true;
|
|
106
|
+
const copied = edited && (await backupFile(ctx, file));
|
|
72
107
|
await fs.writeFile(dest, content);
|
|
73
|
-
ctx.files[
|
|
108
|
+
ctx.files[file] = fresh;
|
|
74
109
|
ctx.written += 1;
|
|
110
|
+
return { file, action: 'updated', copied };
|
|
75
111
|
}
|
|
76
112
|
|
|
77
|
-
/**
|
|
113
|
+
/**
|
|
114
|
+
* deliverFile for every file under `src`, mirrored into `dest`.
|
|
115
|
+
* @returns {Promise<Array<{file:string, action:string, copied:boolean}>>} one result per file
|
|
116
|
+
*/
|
|
78
117
|
export async function deliverTree(ctx, src, dest, { overwrite, skip = () => false }) {
|
|
118
|
+
const results = [];
|
|
79
119
|
async function walk(dir) {
|
|
80
120
|
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
|
81
121
|
const from = path.join(dir, entry.name);
|
|
82
122
|
const relPath = path.relative(src, from).split(path.sep).join('/');
|
|
83
123
|
if (skip(relPath)) continue;
|
|
84
124
|
if (entry.isDirectory()) await walk(from);
|
|
85
|
-
else await deliverFile(ctx, path.join(dest, relPath), await fs.readFile(from), { overwrite });
|
|
125
|
+
else results.push(await deliverFile(ctx, path.join(dest, relPath), await fs.readFile(from), { overwrite }));
|
|
86
126
|
}
|
|
87
127
|
}
|
|
88
128
|
await walk(src);
|
|
129
|
+
return results;
|
|
89
130
|
}
|