@aksp/opencrew 1.6.1 → 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 +66 -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/regras.mjs +32 -1
- 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
package/src/lib/mcp.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// The project's .mcp.json (R2 rules 8 to 11). The Playwright server OpenCrew ships is delivered
|
|
2
|
+
// ONCE, and the manifest records it (`.mcp.json` → sha256 of the text, like any delivered file):
|
|
3
|
+
// `init` records when it creates the file; `update` without the record creates the file or adds
|
|
4
|
+
// the server — whichever is missing — and records. With the record, `update` never creates the
|
|
5
|
+
// file nor puts the server back: removing it is the user's choice. Every rewrite copies the
|
|
6
|
+
// whole file to .opencrew-backup/ first and keeps its indentation and line ending. The pinned
|
|
7
|
+
// version is never changed, and a file out of the expected format is left as it is.
|
|
8
|
+
import { promises as fs } from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { exists } from './fsx.js';
|
|
11
|
+
import { backupFile, hashOf, isRecord } from './manifest.js';
|
|
12
|
+
|
|
13
|
+
/** Path of the file, relative to the project root: also its key in the manifest. */
|
|
14
|
+
export const MCP_FILE = '.mcp.json';
|
|
15
|
+
|
|
16
|
+
// The entry that is OpenCrew's own is the one that uses its Playwright config.
|
|
17
|
+
const CONFIG = '_opencrew/config/playwright.config.json';
|
|
18
|
+
const OUTPUT_DIR = ['--output-dir', '_opencrew/logs/playwright'];
|
|
19
|
+
const SKILLS = 'as skills image-creator e image-fetcher';
|
|
20
|
+
|
|
21
|
+
const hasOutputDir = (args) => args.some((a) => a === '--output-dir' || String(a).startsWith('--output-dir='));
|
|
22
|
+
const lacksOutputDir = (args) => Array.isArray(args) && args.some((a) => String(a).includes(CONFIG)) && !hasOutputDir(args);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `init`: write the template when the project has no .mcp.json, and record the delivery. An
|
|
26
|
+
* existing file is the user's: it stays as it is and nothing is recorded.
|
|
27
|
+
* @returns {Promise<boolean>} true when the file was created
|
|
28
|
+
*/
|
|
29
|
+
export async function createMcp(ctx, template) {
|
|
30
|
+
const dest = path.join(ctx.target, MCP_FILE);
|
|
31
|
+
if (await exists(dest)) return false;
|
|
32
|
+
await fs.writeFile(dest, template);
|
|
33
|
+
ctx.files[MCP_FILE] = hashOf(template);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Indentation of the first indented line of `text`; 2 spaces when no line is indented. */
|
|
38
|
+
function indentOf(text) {
|
|
39
|
+
for (let nl = text.indexOf('\n'); nl !== -1; nl = text.indexOf('\n', nl + 1)) {
|
|
40
|
+
let end = nl + 1;
|
|
41
|
+
while (text[end] === ' ' || text[end] === '\t') end += 1;
|
|
42
|
+
if (end > nl + 1 && end < text.length && text[end] !== '\r' && text[end] !== '\n') return text.slice(nl + 1, end);
|
|
43
|
+
}
|
|
44
|
+
return ' ';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const BOM = String.fromCharCode(0xfeff); // some editors on Windows save JSON with it
|
|
48
|
+
|
|
49
|
+
/** `data` as JSON in the indentation, line ending and final line break of `text`. */
|
|
50
|
+
function formatLike(text, data) {
|
|
51
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
52
|
+
const json = (text.startsWith(BOM) ? BOM : '') + JSON.stringify(data, null, indentOf(text)).replace(/\n/g, eol);
|
|
53
|
+
return text.endsWith('\n') ? json + eol : json;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What `update` does to a .mcp.json whose content is `text`. `delivered` = the manifest has the
|
|
58
|
+
* record. `data` (what to write) comes only with the two actions that change the file.
|
|
59
|
+
*/
|
|
60
|
+
function planMcp(text, template, delivered) {
|
|
61
|
+
let data;
|
|
62
|
+
try { data = JSON.parse(text.startsWith(BOM) ? text.slice(1) : text); } catch { return { action: 'not-json' }; }
|
|
63
|
+
// An object with no `mcpServers` counts as one with no server; any other shape is left alone.
|
|
64
|
+
const servers = isRecord(data) && data.mcpServers === undefined ? {} : data?.mcpServers;
|
|
65
|
+
if (!isRecord(servers)) return { action: 'bad-format' };
|
|
66
|
+
if (!Object.hasOwn(servers, 'playwright')) {
|
|
67
|
+
if (delivered) return { action: 'no-server' };
|
|
68
|
+
data.mcpServers = { ...servers, playwright: JSON.parse(template).mcpServers.playwright };
|
|
69
|
+
return { action: 'added', data };
|
|
70
|
+
}
|
|
71
|
+
const args = servers.playwright?.args;
|
|
72
|
+
if (!lacksOutputDir(args)) return { action: 'kept' };
|
|
73
|
+
args.push(...OUTPUT_DIR);
|
|
74
|
+
return { action: 'output-dir', data };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* `update`: deliver the Playwright server once and send the output of the OpenCrew entry to
|
|
79
|
+
* _opencrew/logs/playwright/. The record always goes on to the next manifest (`ctx.files`).
|
|
80
|
+
* - `created` / `added`: no record — the missing file was written, or the missing server added;
|
|
81
|
+
* - `missing` / `no-server`: recorded before — not created, not put back;
|
|
82
|
+
* - `output-dir`: the OpenCrew entry had neither `--output-dir` nor `--output-dir=…`;
|
|
83
|
+
* - `not-json` / `bad-format`: the file is left alone, and no record is made (nothing was
|
|
84
|
+
* delivered: once the file is fixed, the server can still arrive);
|
|
85
|
+
* - `kept`: nothing to do — without a record, this is what gets recorded.
|
|
86
|
+
* @param {string} template text of templates/.mcp.json
|
|
87
|
+
* @returns {Promise<{action:string, copied:boolean}>} `copied` = the file was copied before the rewrite
|
|
88
|
+
*/
|
|
89
|
+
export async function updateMcp(ctx, template) {
|
|
90
|
+
const dest = path.join(ctx.target, MCP_FILE);
|
|
91
|
+
const record = ctx.manifest?.files?.[MCP_FILE];
|
|
92
|
+
if (record) ctx.files[MCP_FILE] = record;
|
|
93
|
+
if (!(await exists(dest))) {
|
|
94
|
+
if (!record) await createMcp(ctx, template);
|
|
95
|
+
return { action: record ? 'missing' : 'created', copied: false };
|
|
96
|
+
}
|
|
97
|
+
const text = await fs.readFile(dest, 'utf8');
|
|
98
|
+
const plan = planMcp(text, template, Boolean(record));
|
|
99
|
+
if (!plan.data) {
|
|
100
|
+
if (plan.action === 'kept' && !record) ctx.files[MCP_FILE] = hashOf(text); // nothing was missing
|
|
101
|
+
return { action: plan.action, copied: false };
|
|
102
|
+
}
|
|
103
|
+
const copied = await backupFile(ctx, MCP_FILE);
|
|
104
|
+
const rewritten = formatLike(text, plan.data);
|
|
105
|
+
await fs.writeFile(dest, rewritten);
|
|
106
|
+
ctx.files[MCP_FILE] = hashOf(rewritten);
|
|
107
|
+
return { action: plan.action, copied };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Spec R2 §6. `copy` = where the copy of the file is, for the two actions that rewrite it.
|
|
111
|
+
const MESSAGES = {
|
|
112
|
+
created: () => `\`.mcp.json\` criado com o servidor Playwright do OpenCrew (${SKILLS} precisam dele). Se você apagou esse arquivo de propósito, pode apagar de novo: o \`update\` não recria mais.`,
|
|
113
|
+
added: (copy) => `Servidor Playwright acrescentado ao \`.mcp.json\` ${copy}. Se você o removeu de propósito, pode remover de novo: o \`update\` não repõe mais.`,
|
|
114
|
+
'output-dir': (copy) => `\`.mcp.json\`: a saída do Playwright agora vai para \`_opencrew/logs/playwright/\` ${copy}.`,
|
|
115
|
+
missing: () => `O \`.mcp.json\` não existe e não foi recriado. Sem o servidor Playwright, ${SKILLS} não funcionam.`,
|
|
116
|
+
'no-server': () => `O \`.mcp.json\` está sem o servidor Playwright do OpenCrew e ficou como está. Sem ele, ${SKILLS} não funcionam.`,
|
|
117
|
+
'bad-format': () => 'O `.mcp.json` não tem o formato esperado (um objeto com `mcpServers`) e não foi alterado. Confira o arquivo.',
|
|
118
|
+
'not-json': () => '.mcp.json não é um JSON válido — não alterado. Confira o arquivo.',
|
|
119
|
+
};
|
|
120
|
+
const DONE = new Set(['created', 'added', 'output-dir']);
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The line `update` prints for an action of `updateMcp`; null when there is nothing to say.
|
|
124
|
+
* @returns {{text:string, warn:boolean}|null} `warn` = a warning, not something that was done
|
|
125
|
+
*/
|
|
126
|
+
export function mcpMessage(ctx, action) {
|
|
127
|
+
if (!MESSAGES[action]) return null;
|
|
128
|
+
const dir = path.relative(ctx.target, ctx.backupDir).split(path.sep).join('/');
|
|
129
|
+
const text = MESSAGES[action](`(cópia do arquivo anterior em \`${dir}/${MCP_FILE}\`)`);
|
|
130
|
+
return { text, warn: !DONE.has(action) };
|
|
131
|
+
}
|
package/src/lib/migrations.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// What `update` does beyond refreshing _opencrew/core and the catalog skills, so that every
|
|
2
|
-
// improvement reaches people who already use OpenCrew (AGENTS.md rule 14). The
|
|
3
|
-
//
|
|
2
|
+
// improvement reaches people who already use OpenCrew (AGENTS.md rule 14). The helpers of
|
|
3
|
+
// `init --repair-bridges` live here too, with the version guard the two commands share.
|
|
4
|
+
// The IDE detection itself is in ./deteccao.js.
|
|
4
5
|
import { promises as fs } from 'node:fs';
|
|
5
6
|
import path from 'node:path';
|
|
6
|
-
import { exists
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { exists } from './fsx.js';
|
|
8
|
+
import { allIdeIds } from './ides.js';
|
|
9
|
+
import { writeManifest } from './manifest.js';
|
|
10
|
+
import { copyLine } from './blocos.js';
|
|
11
|
+
import { detectInstalledIdes } from './deteccao.js';
|
|
9
12
|
|
|
10
13
|
/** Semver compare (no pre-release tags): >0 if a > b, <0 if a < b, 0 if equal. */
|
|
11
14
|
export function compareVersions(a, b) {
|
|
@@ -18,33 +21,27 @@ export function compareVersions(a, b) {
|
|
|
18
21
|
return 0;
|
|
19
22
|
}
|
|
20
23
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
return
|
|
25
|
-
})();
|
|
26
|
-
|
|
27
|
-
async function hasOpencrew(file) {
|
|
28
|
-
return (await exists(file)) && /opencrew/i.test(await fs.readFile(file, 'utf8'));
|
|
24
|
+
/** Version stamped in the workspace by the last init or update that finished; null = no stamp. */
|
|
25
|
+
export async function installedVersion(target) {
|
|
26
|
+
const stamp = path.join(target, '_opencrew', '.opencrew-version');
|
|
27
|
+
return (await exists(stamp)) ? (await fs.readFile(stamp, 'utf8')).trim() : null;
|
|
29
28
|
}
|
|
30
29
|
|
|
30
|
+
const RUN_UPDATE = 'Nada foi alterado. Rode `npx @aksp/opencrew@latest update`: ele já atualiza as pontes. Para a ponte de uma IDE nova, repita este comando depois.';
|
|
31
|
+
|
|
31
32
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
33
|
+
* Spec R2, rule 6: `init --repair-bridges` only rewrites with the package at the version
|
|
34
|
+
* stamped in the workspace. An older package would bring old bridges back; a newer one would
|
|
35
|
+
* point them to files the project does not have yet. No stamp (interrupted install): it runs.
|
|
36
|
+
* @returns {Promise<string|null>} the message that stops the repair, or null to go on
|
|
34
37
|
*/
|
|
35
|
-
export async function
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
found.push(ide);
|
|
43
|
-
break;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
return found;
|
|
38
|
+
export async function repairVersionGuard(target, version) {
|
|
39
|
+
const installed = await installedVersion(target);
|
|
40
|
+
const diff = installed ? compareVersions(installed, version) : 0;
|
|
41
|
+
if (!diff) return null;
|
|
42
|
+
return diff > 0
|
|
43
|
+
? `Você tem a v${installed} instalada e este pacote é a v${version} (mais antigo). ${RUN_UPDATE}`
|
|
44
|
+
: `Este projeto está na v${installed} e este pacote é a v${version} (mais novo). O reparo não atualiza o projeto. ${RUN_UPDATE}`;
|
|
48
45
|
}
|
|
49
46
|
|
|
50
47
|
/**
|
|
@@ -79,57 +76,35 @@ export function backupSummary(ctx) {
|
|
|
79
76
|
const dir = path.relative(ctx.target, ctx.backupDir).split(path.sep).join('/');
|
|
80
77
|
return [
|
|
81
78
|
`${ctx.copied.length} cópia(s) de segurança feita(s) antes de regravar:`,
|
|
82
|
-
...ctx.copied.map((file) => ` ${dir}/${file}`),
|
|
79
|
+
...ctx.copied.map((file) => ` ${copyLine(ctx, file, `${dir}/${file}`)}`),
|
|
83
80
|
];
|
|
84
81
|
}
|
|
85
82
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const done = new Set();
|
|
89
|
-
for (const ide of ides) {
|
|
90
|
-
for (const f of ide.files) {
|
|
91
|
-
if (done.has(f.path)) continue;
|
|
92
|
-
done.add(f.path);
|
|
93
|
-
const file = path.join(ctx.target, f.path);
|
|
94
|
-
if (f.content.startsWith('---')) await deliverFile(ctx, file, f.content, { overwrite: true });
|
|
95
|
-
else await writeBridgeFile(file, f.content);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
}
|
|
83
|
+
// The bridges themselves are written by `deliverBridges` (./blocos.js), for init, update and
|
|
84
|
+
// repair; the project's .mcp.json is handled in ./mcp.js.
|
|
99
85
|
|
|
100
|
-
|
|
86
|
+
// ── What an old OpenSquad install left behind (R2 rule 16): only reported, never touched ──
|
|
101
87
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
current = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
116
|
-
} catch {
|
|
117
|
-
return 'invalid';
|
|
118
|
-
}
|
|
119
|
-
current.mcpServers ??= {};
|
|
120
|
-
const pw = current.mcpServers.playwright;
|
|
121
|
-
if (!pw) current.mcpServers.playwright = template.mcpServers.playwright;
|
|
122
|
-
else if (Array.isArray(pw.args) && !pw.args.includes('--output-dir')) pw.args.push(...OUTPUT_DIR);
|
|
123
|
-
else return 'unchanged';
|
|
124
|
-
await fs.writeFile(file, JSON.stringify(current, null, 2) + '\n');
|
|
125
|
-
return 'updated';
|
|
126
|
-
}
|
|
88
|
+
const OLD_DIR = '_opensquad/';
|
|
89
|
+
// Folders where a `.md` that cites `_opensquad/` is reported, down to two subfolders.
|
|
90
|
+
const LEFTOVER_ROOTS = ['.gemini/skills', '.claude/skills', '.agents/skills', '.agent/workflows', '.agent/rules'];
|
|
91
|
+
// Bridges of the OpenSquad reported by their exact path (checked with `opensquad` 0.1.15).
|
|
92
|
+
const LEFTOVER_BRIDGES = [
|
|
93
|
+
'.cursor/rules/opensquad.mdc',
|
|
94
|
+
'.cursor/commands/opensquad.md',
|
|
95
|
+
'.opencode/commands/opensquad.md',
|
|
96
|
+
'.qwen/skills/opensquad/SKILL.md',
|
|
97
|
+
'.trae/rules/opensquad.md',
|
|
98
|
+
'.github/prompts/opensquad.prompt.md',
|
|
99
|
+
'.agents/skills/opensquad/SKILL.md',
|
|
100
|
+
];
|
|
127
101
|
|
|
128
|
-
|
|
102
|
+
/** A folder `opensquad/` or a file `opensquad.*` in the path: a bridge of the OpenSquad. */
|
|
103
|
+
const namedOpensquad = (file) => file.split('/').some((part, i, parts) => (
|
|
104
|
+
i < parts.length - 1 ? part === 'opensquad' : part.startsWith('opensquad.')));
|
|
129
105
|
|
|
130
|
-
/**
|
|
131
|
-
|
|
132
|
-
if (await exists(path.join(target, '_opensquad'))) return [];
|
|
106
|
+
/** The `.md` files under `root` (two subfolders deep) that cite `_opensquad/`. */
|
|
107
|
+
async function citingOldDir(target, root) {
|
|
133
108
|
const found = [];
|
|
134
109
|
async function walk(dir, depth) {
|
|
135
110
|
let entries;
|
|
@@ -137,11 +112,39 @@ export async function findLegacyBridges(target) {
|
|
|
137
112
|
for (const e of entries) {
|
|
138
113
|
const p = path.join(dir, e.name);
|
|
139
114
|
if (e.isDirectory() && depth < 2) await walk(p, depth + 1);
|
|
140
|
-
else if (e.isFile() && e.name.endsWith('.md') &&
|
|
115
|
+
else if (e.isFile() && e.name.endsWith('.md') && (await fs.readFile(p, 'utf8')).includes(OLD_DIR)) {
|
|
141
116
|
found.push(path.relative(target, p).split(path.sep).join('/'));
|
|
142
117
|
}
|
|
143
118
|
}
|
|
144
119
|
}
|
|
145
|
-
|
|
120
|
+
await walk(path.join(target, root), 0);
|
|
146
121
|
return found;
|
|
147
122
|
}
|
|
123
|
+
|
|
124
|
+
/** The file of `_opensquad/` the `playwright` server of .mcp.json points to; null when none. */
|
|
125
|
+
async function oldPlaywrightConfig(target) {
|
|
126
|
+
let args;
|
|
127
|
+
try {
|
|
128
|
+
args = JSON.parse(await fs.readFile(path.join(target, '.mcp.json'), 'utf8')).mcpServers.playwright.args;
|
|
129
|
+
} catch { return null; }
|
|
130
|
+
if (!Array.isArray(args)) return null;
|
|
131
|
+
const values = args.map((a) => String(a)).map((a) => (a.startsWith('--') && a.includes('=') ? a.slice(a.indexOf('=') + 1) : a));
|
|
132
|
+
return values.find((v) => v.replace(/\\/g, '/').replace(/^\.\//, '').startsWith(OLD_DIR)) ?? null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Leftovers of an OpenSquad install that is no longer there. With `_opensquad` at the project
|
|
137
|
+
* root nothing is reported (the system is still installed). Nothing is deleted nor changed.
|
|
138
|
+
* @returns {Promise<{files: Array<{file:string, bridge:boolean}>, config: string|null}>}
|
|
139
|
+
* `files`, sorted by path: `bridge` = recognised by its name; false = it only cites
|
|
140
|
+
* `_opensquad/` and may be the user's. `config` = the path of `_opensquad/` (so, missing)
|
|
141
|
+
* that the `playwright` server of .mcp.json uses.
|
|
142
|
+
*/
|
|
143
|
+
export async function findLeftovers(target) {
|
|
144
|
+
if (await exists(path.join(target, '_opensquad'))) return { files: [], config: null };
|
|
145
|
+
const found = new Set();
|
|
146
|
+
for (const root of LEFTOVER_ROOTS) for (const file of await citingOldDir(target, root)) found.add(file);
|
|
147
|
+
for (const file of LEFTOVER_BRIDGES) if (await exists(path.join(target, file))) found.add(file);
|
|
148
|
+
const files = [...found].sort().map((file) => ({ file, bridge: namedOpensquad(file) }));
|
|
149
|
+
return { files, config: await oldPlaywrightConfig(target) };
|
|
150
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Node floor (spec R2, rule 27): the lowest Node the package accepts, read from the
|
|
2
|
+
// `engines.node` of package.json, and the decision to stop when the machine is below it.
|
|
3
|
+
// Lives outside cli.js so the rule can be tested without touching `process.versions`.
|
|
4
|
+
|
|
5
|
+
/** a < b, comparing major.minor.patch as numbers (no prerelease tags). */
|
|
6
|
+
function lt(a, b) {
|
|
7
|
+
const pa = a.split('.').map(Number);
|
|
8
|
+
const pb = b.split('.').map(Number);
|
|
9
|
+
for (let i = 0; i < 3; i++) {
|
|
10
|
+
const na = pa[i] || 0;
|
|
11
|
+
const nb = pb[i] || 0;
|
|
12
|
+
if (na !== nb) return na < nb;
|
|
13
|
+
}
|
|
14
|
+
return false; // equal
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Lowest version an engines.node range accepts: the first version of each `||` alternative,
|
|
19
|
+
* then the smallest of them. Handles ">=20.17.0", "^20.5", ">=18.0.0 || >=20.0.0", "20.0.0".
|
|
20
|
+
* @returns {string | null} null when the range names no version
|
|
21
|
+
*/
|
|
22
|
+
export function minNodeVersion(range) {
|
|
23
|
+
let lowest = null;
|
|
24
|
+
for (const part of String(range ?? '').split('||')) {
|
|
25
|
+
const v = part.match(/\d+(?:\.\d+){0,2}/)?.[0];
|
|
26
|
+
if (v && (!lowest || lt(v, lowest))) lowest = v;
|
|
27
|
+
}
|
|
28
|
+
return lowest;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The two lines to show when `current` is below the floor of `range`; null when this Node
|
|
33
|
+
* may run the CLI (also when the package declares no floor).
|
|
34
|
+
* @returns {[string, string] | null}
|
|
35
|
+
*/
|
|
36
|
+
export function nodeBelowFloor(range, current = process.versions.node) {
|
|
37
|
+
const floor = minNodeVersion(range);
|
|
38
|
+
if (!floor || !lt(current, floor)) return null;
|
|
39
|
+
return [
|
|
40
|
+
`O OpenCrew precisa do Node.js ${floor} ou mais novo. Esta máquina está com o v${current}.`,
|
|
41
|
+
'Instale a versão LTS em https://nodejs.org/ e rode o comando de novo. Nada foi alterado nesta pasta.',
|
|
42
|
+
];
|
|
43
|
+
}
|
package/src/lib/prompts.js
CHANGED
|
@@ -3,7 +3,30 @@
|
|
|
3
3
|
import { IDES, allIdeIds } from './ides.js';
|
|
4
4
|
import { warn } from './ui.js';
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
const loadCheckbox = () => import('@inquirer/checkbox');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Load the list's library. Only the import is guarded (spec R2, rule 28): on a Node where it
|
|
10
|
+
* does not load, the user gets the way out; Ctrl+C on the open list still passes through.
|
|
11
|
+
*/
|
|
12
|
+
async function openIdeList(load) {
|
|
13
|
+
try {
|
|
14
|
+
return await load();
|
|
15
|
+
} catch (e) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
`Não consegui abrir a lista de IDEs neste Node (v${process.versions.node}).\n`
|
|
18
|
+
+ 'Atualize o Node em https://nodejs.org/ ou escolha as IDEs no próprio comando: '
|
|
19
|
+
+ 'npx @aksp/opencrew init --ide=claude-code (ou --all para todas). Nada foi alterado nesta pasta.',
|
|
20
|
+
{ cause: e },
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {{ preselected?: string[] }} [opts]
|
|
27
|
+
* @param {{ load?: () => Promise<object> }} [deps] injectable for tests
|
|
28
|
+
*/
|
|
29
|
+
export async function pickIdes({ preselected } = {}, { load = loadCheckbox } = {}) {
|
|
7
30
|
const validIds = new Set(allIdeIds());
|
|
8
31
|
const isTTY = process.stdin.isTTY && process.stdout.isTTY;
|
|
9
32
|
|
|
@@ -23,7 +46,7 @@ export async function pickIdes({ preselected } = {}) {
|
|
|
23
46
|
warn(`Non-interactive terminal — configuring IDEs: ${chosen.join(', ')}`);
|
|
24
47
|
return chosen;
|
|
25
48
|
}
|
|
26
|
-
const { default: checkbox, Separator } = await
|
|
49
|
+
const { default: checkbox, Separator } = await openIdeList(load);
|
|
27
50
|
const answer = await checkbox({
|
|
28
51
|
message: 'Which AI IDEs do you use? (space to toggle, enter to confirm)',
|
|
29
52
|
instructions: false,
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// What `update` says (spec R2, rules 13 to 16; texts in §6). Each step of the command returns
|
|
2
|
+
// what it created, rewrote or kept, and the lines here are built ONLY from that: no line states
|
|
3
|
+
// something the step did not do, and a step that changed nothing gets no line. These sentences
|
|
4
|
+
// are in PT-BR; the rest of the CLI is not yet (→ U5).
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { copyLine } from './blocos.js';
|
|
7
|
+
import { legacyLines } from './legado.js';
|
|
8
|
+
import { mcpMessage } from './mcp.js';
|
|
9
|
+
import { UNREADABLE } from './manifest.js';
|
|
10
|
+
import { log, info, ok, warn } from './ui.js';
|
|
11
|
+
|
|
12
|
+
/** Last line of `update`: only what the code guarantees (a deleted crew template comes back). */
|
|
13
|
+
export const UNTOUCHED = 'Não foram alterados: as crews que você criou, `_opencrew/_memory/`, `_opencrew/best-practices.local/` e `.env`.';
|
|
14
|
+
|
|
15
|
+
/** `init` in a workspace that is already installed (rule 15). */
|
|
16
|
+
export const ALREADY_INSTALLED = 'Para atualizar, rode `npx @aksp/opencrew@latest update`. Não apague `_opencrew/` para reinstalar: a pasta guarda a sua memória (`_opencrew/_memory/`) e as suas best-practices (`_opencrew/best-practices.local/`).';
|
|
17
|
+
|
|
18
|
+
const NO_BRIDGES = 'Nenhuma ponte de IDE encontrada: nada a atualizar. Para criar a ponte de uma IDE: `npx @aksp/opencrew@latest init --repair-bridges --ide=<id>`.';
|
|
19
|
+
const LEAK_REMOVED = 'CLAUDE.md: removi a seção de STATUS.md que as versões 1.4.0 e 1.4.1 gravaram por engano.';
|
|
20
|
+
const FIRST_PROTECTED = 'Primeira atualização com proteção: sem registro anterior, guardamos tudo o que diferia. Daqui em diante, só o que você editar.';
|
|
21
|
+
// By action of `deliverBlock`; `kept` has no line.
|
|
22
|
+
const BLOCK = {
|
|
23
|
+
'AGENTS.md': {
|
|
24
|
+
created: 'AGENTS.md criado com o bloco do OpenCrew.',
|
|
25
|
+
added: 'AGENTS.md: bloco do OpenCrew acrescentado no topo; o seu texto foi mantido.',
|
|
26
|
+
updated: 'AGENTS.md: bloco do OpenCrew atualizado.',
|
|
27
|
+
},
|
|
28
|
+
'.gitignore': {
|
|
29
|
+
created: '`.gitignore` criado com o bloco do OpenCrew.',
|
|
30
|
+
added: '`.gitignore`: bloco do OpenCrew acrescentado no fim; as suas linhas foram mantidas.',
|
|
31
|
+
updated: '`.gitignore`: bloco do OpenCrew atualizado.',
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
const LEFTOVER = {
|
|
35
|
+
header: (n) => `${n} resto(s) de uma instalação antiga do OpenSquad (a pasta \`_opensquad/\` não existe neste projeto). Nada foi apagado:`,
|
|
36
|
+
bridge: (file) => `${file} — ponte do OpenSquad; o OpenCrew não usa este arquivo.`,
|
|
37
|
+
cites: (file) => `${file} — cita \`_opensquad/\`; confira antes de apagar, pode ser um arquivo seu.`,
|
|
38
|
+
end: 'Se você não usa mais o OpenSquad, pode apagar as pontes listadas. Este aviso volta a cada `update` enquanto os arquivos existirem.',
|
|
39
|
+
config: (file) => `O servidor \`playwright\` do \`.mcp.json\` aponta para \`${file}\`, que não existe neste projeto (resto do OpenSquad). Não alterei o arquivo.`,
|
|
40
|
+
};
|
|
41
|
+
const MCP_REWRITTEN = new Set(['added', 'output-dir']); // actions of `updateMcp` that change the file
|
|
42
|
+
const MAX_COPIES = 15;
|
|
43
|
+
|
|
44
|
+
/** Print the lines a function of this module returned: `[printer, text]` each. */
|
|
45
|
+
export function say(lines) {
|
|
46
|
+
for (const [print, text] of lines) print(text);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Rule 14: the files `update` wrote in `crews/` because they were missing (`results` of deliverTree). */
|
|
50
|
+
export function recreatedLines(results) {
|
|
51
|
+
const files = results.filter((r) => r.action === 'created').map((r) => r.file);
|
|
52
|
+
if (!files.length) return [];
|
|
53
|
+
return [[info, `${files.length} arquivo(s) que faltava(m) em \`crews/\` foram entregues de novo: ${files.join(', ')}.`]];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const blockLines = (results) => results
|
|
57
|
+
.map((r) => BLOCK[r?.file]?.[r.action])
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.map((text) => [ok, text]);
|
|
60
|
+
|
|
61
|
+
/** One line per case that happened: IDEs with a bridge rewritten, files created, or neither. */
|
|
62
|
+
function bridgeLines(ides, bridges, legacy = []) {
|
|
63
|
+
if (!ides.length) return [[info, NO_BRIDGES]];
|
|
64
|
+
const cleaned = new Set(legacy.filter((l) => l.action === 'removed').map((l) => l.file));
|
|
65
|
+
const rewritten = bridges.filter((b) => b.action === 'updated' || b.action === 'added' || cleaned.has(b.file));
|
|
66
|
+
const labels = [...new Set(rewritten.map((b) => b.ide.label))];
|
|
67
|
+
const created = bridges.filter((b) => b.action === 'created' && !b.shared).map((b) => b.file);
|
|
68
|
+
const lines = [];
|
|
69
|
+
if (labels.length) lines.push([ok, `Pontes atualizadas: ${labels.join(', ')}.`]);
|
|
70
|
+
if (created.length) lines.push([ok, `Pontes criadas: ${created.join(', ')}.`]);
|
|
71
|
+
return lines.length ? lines : [[ok, 'Pontes das IDEs já estavam em dia.']];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function mcpLines(ctx, mcp) {
|
|
75
|
+
const said = mcpMessage(ctx, mcp.action);
|
|
76
|
+
return said ? [[said.warn ? warn : ok, said.text]] : [];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Rule 16. `mcp` = what this run did to .mcp.json: the line only says "não alterei" if so. */
|
|
80
|
+
function leftoverLines({ files, config }, mcp) {
|
|
81
|
+
const lines = [];
|
|
82
|
+
if (files.length) {
|
|
83
|
+
lines.push([warn, LEFTOVER.header(files.length)]);
|
|
84
|
+
for (const { file, bridge } of files) lines.push([log, ` ${(bridge ? LEFTOVER.bridge : LEFTOVER.cites)(file)}`]);
|
|
85
|
+
lines.push([info, LEFTOVER.end]);
|
|
86
|
+
}
|
|
87
|
+
if (config && !MCP_REWRITTEN.has(mcp.action)) lines.push([warn, LEFTOVER.config(config)]);
|
|
88
|
+
return lines;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The copies of this run. `unreadable` = the manifest existed and could not be used. */
|
|
92
|
+
function copyLines(ctx, unreadable) {
|
|
93
|
+
const n = ctx.copied.length;
|
|
94
|
+
if (!n) return [];
|
|
95
|
+
const dir = path.relative(ctx.target, ctx.backupDir).split(path.sep).join('/');
|
|
96
|
+
const header = ctx.manifest
|
|
97
|
+
? `${n} arquivo(s) foram copiados para ${dir}/ antes de serem substituídos (editados por você, ou sem registro de entrega):`
|
|
98
|
+
: `${n} arquivo(s) diferentes do pacote novo foram copiados para ${dir}/ antes de serem substituídos:`;
|
|
99
|
+
const lines = [[warn, header], ...ctx.copied.slice(0, MAX_COPIES).map((f) => [log, ` ${copyLine(ctx, f)}`])];
|
|
100
|
+
if (n > MAX_COPIES) lines.push([log, ` … e mais ${n - MAX_COPIES}`]);
|
|
101
|
+
if (unreadable) lines.push([info, UNREADABLE.summary]);
|
|
102
|
+
else if (!ctx.manifest) lines.push([info, FIRST_PROTECTED]);
|
|
103
|
+
return lines;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The summary of an `update`, in the order the steps ran.
|
|
108
|
+
* @param {object} ctx delivery context (src/lib/manifest.js)
|
|
109
|
+
* @param {object} done what each step returned: `agents` and `gitignore` (deliverBlock; `agents`
|
|
110
|
+
* is null when a pre-1.3 AGENTS.md was migrated), `ides` (detected), `bridges` (deliverBridges),
|
|
111
|
+
* `leak` (the STATUS.md section left CLAUDE.md's block), `mcp` (updateMcp), `leftovers`
|
|
112
|
+
* (findLeftovers) and `unreadable` (manifest)
|
|
113
|
+
* @returns {Array<[Function, string]>} lines for `say`
|
|
114
|
+
*/
|
|
115
|
+
export function updateSummary(ctx, done) {
|
|
116
|
+
return [
|
|
117
|
+
...blockLines([done.agents, done.gitignore]),
|
|
118
|
+
...bridgeLines(done.ides, done.bridges, ctx.legacy),
|
|
119
|
+
...(done.leak ? [[ok, LEAK_REMOVED]] : []),
|
|
120
|
+
...legacyLines(ctx).map((line) => [warn, line]),
|
|
121
|
+
...mcpLines(ctx, done.mcp),
|
|
122
|
+
...leftoverLines(done.leftovers, done.mcp),
|
|
123
|
+
...copyLines(ctx, done.unreadable),
|
|
124
|
+
];
|
|
125
|
+
}
|
package/templates/.mcp.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_comment": "
|
|
2
|
+
"_comment": "Versão do Playwright MCP fixada por estabilidade. Para trocar, edite a versão abaixo e reinicie a IDE. O `update` do OpenCrew não altera a versão que está neste arquivo.",
|
|
3
3
|
"mcpServers": {
|
|
4
4
|
"playwright": {
|
|
5
5
|
"command": "npx",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.6.
|
|
1
|
+
1.6.3
|