@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
|
@@ -6,27 +6,32 @@
|
|
|
6
6
|
// Rode a partir da pasta do projeto (a que tem `_opencrew/`); a crew fica dentro dela.
|
|
7
7
|
// Última linha da saída: FONTES:OK ou FONTES:PENDENTE (o runner lê esta linha).
|
|
8
8
|
// Código de saída: 0 = conferiu (OK ou PENDENTE) · 1 = erro de uso (opção faltando, pasta sem
|
|
9
|
-
// `_opencrew/`, crew fora do projeto ou inexistente)
|
|
10
|
-
//
|
|
11
|
-
//
|
|
9
|
+
// `_opencrew/`, crew fora do projeto ou inexistente) ou arquivo da crew que não dá para ler
|
|
10
|
+
// ("Não consegui conferir: …"); com código 1 não há linha FONTES:.
|
|
11
|
+
// Caminho de rede e endereço de site citados não são testados (alerta "não conferi"); o script
|
|
12
|
+
// nunca acessa a rede, e o --corrigir nunca grava fora da pasta real da crew.
|
|
13
|
+
// Specs: specs/fase-u2-crew-que-conhece-o-projeto.md, specs/fase-r1-reparos-1-6-1.md e
|
|
14
|
+
// specs/fase-r2-update-e-envio-seguros.md, regras 23 a 26 (repositório do OpenCrew).
|
|
15
|
+
// Módulos em conferir-fontes/: coleta, busca e relatório.
|
|
12
16
|
import { readFile, writeFile, copyFile } from 'node:fs/promises';
|
|
13
17
|
import { existsSync } from 'node:fs';
|
|
14
18
|
import path from 'node:path';
|
|
15
|
-
import { erroDeUso, dentroDoProjeto, ehPrincipal } from './comum.mjs';
|
|
19
|
+
import { erroDeUso, dentroDoProjeto, relativoAoProjeto, realDentroDe, ehPrincipal } from './comum.mjs';
|
|
16
20
|
import { coletar, trocarCitacao } from './conferir-fontes/coleta.mjs';
|
|
17
21
|
import {
|
|
18
|
-
LIMITE_DA_BUSCA,
|
|
22
|
+
LIMITE_DA_BUSCA, ehAbsoluto, ehRedeOuSite, pareceSite, temBarraFinal, resolver, indexar, candidatosPorNome, nomesDaPastaEsperada,
|
|
19
23
|
} from './conferir-fontes/busca.mjs';
|
|
20
24
|
import { formatar, MSG } from './conferir-fontes/relatorio.mjs';
|
|
21
25
|
|
|
22
26
|
export { formatar };
|
|
23
27
|
|
|
24
28
|
/**
|
|
25
|
-
* Caminho absoluto que existe dentro do projeto: o mesmo caminho, relativo à raiz
|
|
26
|
-
*
|
|
29
|
+
* Caminho absoluto que existe dentro do projeto: o mesmo caminho, relativo à raiz — pelo lugar
|
|
30
|
+
* real, quando foi escrito por um link que leva ao projeto. A própria raiz não tem caminho
|
|
31
|
+
* relativo a sugerir.
|
|
27
32
|
*/
|
|
28
33
|
function sugestaoRelativa(raiz, ref, achado) {
|
|
29
|
-
const relativo = dentroDoProjeto(raiz, achado) ?
|
|
34
|
+
const relativo = dentroDoProjeto(raiz, achado) ? relativoAoProjeto(raiz, achado) : '';
|
|
30
35
|
return relativo ? relativo + (temBarraFinal(ref) ? '/' : '') : null;
|
|
31
36
|
}
|
|
32
37
|
|
|
@@ -43,27 +48,43 @@ async function procurar(item, { raiz, crew, indice, destino }) {
|
|
|
43
48
|
if (!item.candidatos.length) item.pasta = await nomesDaPastaEsperada(raiz, crew, item.ref);
|
|
44
49
|
}
|
|
45
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Estado de uma citação. Caminho de rede ou endereço de site não é testado: vira o alerta
|
|
53
|
+
* "não conferido" (regra 25). O resto é procurado no disco: o que falta é pendência, e o caminho
|
|
54
|
+
* absoluto que existe, o alerta de não portátil. `ctx.indice` é montado só na primeira falta.
|
|
55
|
+
*/
|
|
56
|
+
async function classificar(item, destino, ctx) {
|
|
57
|
+
const { raiz, crew } = ctx;
|
|
58
|
+
if (ehRedeOuSite(item.ref)) {
|
|
59
|
+
item.estado = 'nao-conferido';
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const achado = resolver(raiz, crew, item.ref, item.citadoEm);
|
|
63
|
+
if (!achado) {
|
|
64
|
+
ctx.indice ??= await indexar(raiz, ctx.limite);
|
|
65
|
+
if (pareceSite(ctx, item) && !candidatosPorNome(ctx.indice, item.ref).length) item.estado = 'nao-conferido';
|
|
66
|
+
else await procurar(item, { raiz, crew, indice: ctx.indice, destino });
|
|
67
|
+
} else if (ehAbsoluto(item.ref)) {
|
|
68
|
+
item.estado = 'nao-portatil';
|
|
69
|
+
item.sugestao = sugestaoRelativa(raiz, item.ref, achado);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
46
73
|
/**
|
|
47
74
|
* Confere os caminhos que a crew cita. `limite` é o máximo de itens do projeto vistos na busca
|
|
48
|
-
* por nome; quando a busca para nele, `buscaParcial` é true.
|
|
75
|
+
* por nome; quando a busca para nele, `buscaParcial` é true. Estados: ok · faltando (pendência) ·
|
|
76
|
+
* nao-portatil e nao-conferido (alertas; não mudam o status).
|
|
49
77
|
*/
|
|
50
78
|
export async function conferir({ raiz, crew, limite = LIMITE_DA_BUSCA }) {
|
|
51
|
-
|
|
79
|
+
const ctx = { raiz, crew, limite, indice: null };
|
|
52
80
|
const refs = [];
|
|
53
81
|
for (const [ref, { arquivos, destino }] of await coletar(raiz, crew)) {
|
|
54
82
|
const item = { ref, citadoEm: [...arquivos], estado: 'ok', sugestao: null, candidatos: [], pasta: [] };
|
|
55
|
-
|
|
56
|
-
if (!achado) {
|
|
57
|
-
indice ??= await indexar(raiz, limite);
|
|
58
|
-
await procurar(item, { raiz, crew, indice, destino });
|
|
59
|
-
} else if (ehAbsoluto(ref)) {
|
|
60
|
-
item.estado = 'nao-portatil';
|
|
61
|
-
item.sugestao = sugestaoRelativa(raiz, ref, achado);
|
|
62
|
-
}
|
|
83
|
+
await classificar(item, destino, ctx);
|
|
63
84
|
refs.push(item);
|
|
64
85
|
}
|
|
65
86
|
const status = refs.some((i) => i.estado === 'faltando') ? 'PENDENTE' : 'OK';
|
|
66
|
-
return { crew, raiz, refs, status, buscaParcial: Boolean(indice?.parcial), limite };
|
|
87
|
+
return { crew, raiz, refs, status, buscaParcial: Boolean(ctx.indice?.parcial), limite };
|
|
67
88
|
}
|
|
68
89
|
|
|
69
90
|
async function copiaDeSeguranca(arquivo) {
|
|
@@ -71,38 +92,69 @@ async function copiaDeSeguranca(arquivo) {
|
|
|
71
92
|
await copyFile(arquivo, bak);
|
|
72
93
|
}
|
|
73
94
|
|
|
95
|
+
/** Regrava a citação num arquivo; a cópia .bak é feita uma vez por arquivo. */
|
|
96
|
+
async function regravar(arquivo, item, tocados) {
|
|
97
|
+
const texto = await readFile(arquivo, 'utf8');
|
|
98
|
+
if (!tocados.has(arquivo)) {
|
|
99
|
+
await copiaDeSeguranca(arquivo);
|
|
100
|
+
tocados.add(arquivo);
|
|
101
|
+
}
|
|
102
|
+
await writeFile(arquivo, trocarCitacao(texto, item, path.basename(arquivo) === 'crew.yaml'));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Guarda de escrita do --corrigir (regra 24): só se grava em arquivo cujo lugar real fica dentro
|
|
107
|
+
* da pasta real da crew, e só se ela fica dentro do projeto. A pasta do arquivo passa pela mesma
|
|
108
|
+
* prova: é nela que a cópia .bak é gravada. Link físico não é reconhecido.
|
|
109
|
+
* @returns {((arquivo: string) => boolean)|null} null: a crew inteira é um link para fora do projeto
|
|
110
|
+
*/
|
|
111
|
+
function guardaDeEscrita(raiz, crew) {
|
|
112
|
+
const pasta = path.resolve(raiz, crew);
|
|
113
|
+
if (!realDentroDe(raiz, pasta)) return null;
|
|
114
|
+
return (arquivo) => [arquivo, path.dirname(arquivo)].every((lugar) => realDentroDe(pasta, lugar));
|
|
115
|
+
}
|
|
116
|
+
|
|
74
117
|
/**
|
|
75
118
|
* Troca, nos arquivos da crew, cada caminho com sugestão única — só a citação que a coleta leu,
|
|
76
|
-
* nunca um pedaço de outro texto.
|
|
119
|
+
* nunca um pedaço de outro texto. O que a guarda de escrita barra não muda: `avisar` recebe uma
|
|
120
|
+
* linha por arquivo pulado, ou uma só quando a crew inteira é um link para fora do projeto.
|
|
121
|
+
* @returns quantos caminhos foram gravados, em ao menos um arquivo
|
|
77
122
|
*/
|
|
78
|
-
export async function corrigir({ resultado }) {
|
|
123
|
+
export async function corrigir({ resultado, avisar = () => {} }) {
|
|
124
|
+
const { raiz, crew } = resultado;
|
|
79
125
|
const comSugestao = resultado.refs.filter((i) => i.sugestao && i.estado !== 'ok');
|
|
126
|
+
const podeGravar = comSugestao.length ? guardaDeEscrita(raiz, crew) : null;
|
|
127
|
+
if (!podeGravar) {
|
|
128
|
+
if (comSugestao.length) avisar(MSG.crewLigadaParaFora(crew));
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
80
131
|
const tocados = new Set();
|
|
132
|
+
const pulados = new Set();
|
|
133
|
+
let gravados = 0;
|
|
81
134
|
for (const item of comSugestao) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
tocados.add(arquivo);
|
|
87
|
-
}
|
|
88
|
-
await writeFile(arquivo, trocarCitacao(texto, item, path.basename(arquivo) === 'crew.yaml'));
|
|
89
|
-
}
|
|
135
|
+
const dentro = item.citadoEm.filter((arquivo) => podeGravar(arquivo));
|
|
136
|
+
for (const arquivo of item.citadoEm) if (!dentro.includes(arquivo)) pulados.add(arquivo);
|
|
137
|
+
for (const arquivo of dentro) await regravar(arquivo, item, tocados);
|
|
138
|
+
if (dentro.length) gravados += 1;
|
|
90
139
|
}
|
|
91
|
-
|
|
140
|
+
for (const arquivo of pulados) avisar(MSG.linkParaFora(relativoAoProjeto(raiz, arquivo)));
|
|
141
|
+
return gravados;
|
|
92
142
|
}
|
|
93
143
|
|
|
94
|
-
/** --corrigir: troca o que tem sugestão única e
|
|
144
|
+
/** --corrigir: troca o que tem sugestão única, diz o que pulou e quantas pendências ficam sem correção. */
|
|
95
145
|
async function corrigirEAvisar(r, escrever) {
|
|
96
|
-
const
|
|
146
|
+
const pulados = [];
|
|
147
|
+
const n = await corrigir({ resultado: r, avisar: (linha) => pulados.push(linha) });
|
|
97
148
|
let atual = r;
|
|
98
149
|
if (n) {
|
|
99
150
|
escrever(MSG.corrigidos(n));
|
|
100
151
|
atual = await conferir({ raiz: r.raiz, crew: r.crew });
|
|
101
152
|
escrever(formatar(atual));
|
|
102
153
|
}
|
|
154
|
+
for (const linha of pulados) escrever(linha);
|
|
103
155
|
const semSugestao = atual.refs.filter((i) => i.estado === 'faltando' && !i.sugestao).length;
|
|
104
156
|
if (semSugestao) escrever(MSG.semCorrecaoAutomatica(semSugestao));
|
|
105
|
-
else if (!n) escrever(MSG.nadaACorrigir);
|
|
157
|
+
else if (!n && !pulados.length) escrever(MSG.nadaACorrigir);
|
|
106
158
|
return atual;
|
|
107
159
|
}
|
|
108
160
|
|
|
@@ -114,7 +166,7 @@ function lerCrew(argv) {
|
|
|
114
166
|
return valor && !valor.startsWith('--') ? valor : null;
|
|
115
167
|
}
|
|
116
168
|
|
|
117
|
-
/** @returns {Promise<number>} 0 = conferiu (OK ou PENDENTE) · 1 = erro de uso */
|
|
169
|
+
/** @returns {Promise<number>} 0 = conferiu (OK ou PENDENTE) · 1 = erro de uso, ou arquivo da crew que não dá para ler */
|
|
118
170
|
export async function main(argv, { cwd = process.cwd(), escrever = (s) => process.stdout.write(`${s}\n`) } = {}) {
|
|
119
171
|
const crew = lerCrew(argv);
|
|
120
172
|
const erro = erroDeUso({ raiz: cwd, faltando: crew ? [] : ['--crew'], crew });
|
|
@@ -123,11 +175,17 @@ export async function main(argv, { cwd = process.cwd(), escrever = (s) => proces
|
|
|
123
175
|
if (!crew) escrever(USO);
|
|
124
176
|
return 1;
|
|
125
177
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
178
|
+
try {
|
|
179
|
+
let r = await conferir({ raiz: cwd, crew });
|
|
180
|
+
escrever(formatar(r));
|
|
181
|
+
if (argv.includes('--corrigir')) r = await corrigirEAvisar(r, escrever);
|
|
182
|
+
escrever(`FONTES:${r.status}`);
|
|
183
|
+
return 0;
|
|
184
|
+
} catch (erroDeLeitura) {
|
|
185
|
+
// Link quebrado, pasta com nome de arquivo, arquivo sem permissão: uma linha, sem linha FONTES:.
|
|
186
|
+
escrever(MSG.naoConferi(erroDeLeitura.message));
|
|
187
|
+
return 1;
|
|
188
|
+
}
|
|
131
189
|
}
|
|
132
190
|
|
|
133
191
|
if (ehPrincipal(import.meta.url)) {
|
|
@@ -13,10 +13,41 @@ export const FALTA_INFO = 'Falta informação sua';
|
|
|
13
13
|
export const NAO_MEDIDO = 'Não medido';
|
|
14
14
|
export const NAO_VERIFICADO = 'Não verificado';
|
|
15
15
|
|
|
16
|
+
const JANELA = 1024;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Quantos grafemas o texto tem, contados em janelas e sem guardar os segmentos. No Node 20 cada
|
|
20
|
+
* segmento carrega uma cópia da entrada: com o texto inteiro de uma vez a memória cresce ao
|
|
21
|
+
* quadrado, e 200 mil caracteres derrubavam o processo. O último grafema de uma janela pode
|
|
22
|
+
* continuar na seguinte, por isso fica para ela; a janela nunca corta um par substituto.
|
|
23
|
+
*/
|
|
24
|
+
export function grafemas(texto, janela = JANELA) {
|
|
25
|
+
let total = 0;
|
|
26
|
+
let tamanho = janela;
|
|
27
|
+
for (let inicio = 0; inicio < texto.length;) {
|
|
28
|
+
let fim = Math.min(inicio + tamanho, texto.length);
|
|
29
|
+
if (fim < texto.length && texto.codePointAt(fim - 1) > 0xffff) fim++;
|
|
30
|
+
let quantos = 0;
|
|
31
|
+
let ultimo = 0;
|
|
32
|
+
for (const { index } of segmentador.segment(texto.slice(inicio, fim))) {
|
|
33
|
+
quantos++;
|
|
34
|
+
ultimo = index;
|
|
35
|
+
}
|
|
36
|
+
const acabou = fim === texto.length;
|
|
37
|
+
if (!acabou && quantos === 1) tamanho *= 2; // um grafema maior que a janela: tenta de novo
|
|
38
|
+
else {
|
|
39
|
+
total += acabou ? quantos : quantos - 1;
|
|
40
|
+
inicio = acabou ? fim : inicio + ultimo;
|
|
41
|
+
tamanho = janela;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return total;
|
|
45
|
+
}
|
|
46
|
+
|
|
16
47
|
/** Caracteres visíveis: sem marcadores de negrito/itálico; emoji conta 1; quebra de linha conta. */
|
|
17
48
|
export function contar(texto) {
|
|
18
49
|
const limpo = String(texto).replace(/\*\*|__/g, '').replace(/\*([^*\n]+)\*/g, '$1').trim();
|
|
19
|
-
return
|
|
50
|
+
return grafemas(limpo);
|
|
20
51
|
}
|
|
21
52
|
|
|
22
53
|
export const item = (nome, medido, limite, nivel, detalhe = '') => ({ item: nome, medido, limite, nivel, detalhe });
|
|
@@ -11,10 +11,11 @@
|
|
|
11
11
|
// obrigatória faltando, pasta sem `_opencrew/`, crew inexistente, crew ou caminho fora do
|
|
12
12
|
// projeto, nenhum caminho da lista existe) ou erro que impediu a verificação inteira; com
|
|
13
13
|
// código 1 não há linha VERIFICACAO:.
|
|
14
|
-
// Specs: fase-u1-revisor-com-dentes.md
|
|
14
|
+
// Specs: fase-u1-revisor-com-dentes.md, fase-r1-reparos-1-6-1.md e fase-r2-update-e-envio-seguros.md
|
|
15
|
+
// (regra 23: "dentro do projeto" pelo texto ou pelo lugar real), no repositório do OpenCrew.
|
|
15
16
|
import { existsSync } from 'node:fs';
|
|
16
17
|
import path from 'node:path';
|
|
17
|
-
import { erroDeUso, ehPrincipal } from './comum.mjs';
|
|
18
|
+
import { erroDeUso, ehPrincipal, relativoAoProjeto } from './comum.mjs';
|
|
18
19
|
import { USO, lerArgs, lerItemDaLista } from './verificar/argumentos.mjs';
|
|
19
20
|
import { lerItem } from './verificar/arquivos.mjs';
|
|
20
21
|
import { lerLimites, lerDominioDoSite, semFrontmatter } from './verificar/leitura.mjs';
|
|
@@ -102,9 +103,11 @@ function semRepetidas(raiz, entradas) {
|
|
|
102
103
|
});
|
|
103
104
|
}
|
|
104
105
|
|
|
105
|
-
/**
|
|
106
|
+
/** No relatório, o absoluto de dentro do projeto (também por link, junção ou nome curto) sai como o relativo. */
|
|
106
107
|
function nomeNoRelatorio(raiz, arquivo) {
|
|
107
|
-
|
|
108
|
+
const relativo = relativoAoProjeto(raiz, arquivo);
|
|
109
|
+
const comoEscrito = !path.isAbsolute(arquivo) && path.resolve(raiz, relativo) === path.resolve(raiz, arquivo);
|
|
110
|
+
return comoEscrito ? arquivo : relativo;
|
|
108
111
|
}
|
|
109
112
|
|
|
110
113
|
function resumir(arquivos, naoTexto, notas) {
|
|
@@ -54,6 +54,7 @@ Frontmatter fields:
|
|
|
54
54
|
- `dependencies`: Array of npm/pip packages to install
|
|
55
55
|
- `env` (array): List of required environment variable names
|
|
56
56
|
- `categories` (array): Classification tags (e.g., scraping, design, analytics)
|
|
57
|
+
- `side_effects` (string, optional): `irreversible` for a skill that publishes or sends — read by Operation 6
|
|
57
58
|
|
|
58
59
|
Body: Markdown instructions injected into agent context at runtime.
|
|
59
60
|
|
|
@@ -382,7 +383,7 @@ For each skill declared in an agent's `.agent.md` frontmatter `skills:` field:
|
|
|
382
383
|
1. **Skip native skills**: `web_search` and `web_fetch` do not need instruction injection —
|
|
383
384
|
they are handled natively.
|
|
384
385
|
|
|
385
|
-
2. **Read each skill's SKILL.md** frontmatter only: Extract `name`, `description`, and `
|
|
386
|
+
2. **Read each skill's SKILL.md** frontmatter only: Extract `name`, `description`, `type` and `side_effects` fields.
|
|
386
387
|
|
|
387
388
|
3. **Build the Tier 1 index** and append after all agent instructions:
|
|
388
389
|
```
|
|
@@ -394,8 +395,9 @@ For each skill declared in an agent's `.agent.md` frontmatter `skills:` field:
|
|
|
394
395
|
you are invoking and the system will load its full instructions.
|
|
395
396
|
|
|
396
397
|
- {skill-id}: {description from frontmatter} (type: {type})
|
|
397
|
-
- {skill-id}: {description from frontmatter} (type: {type})
|
|
398
|
+
- {skill-id}: {description from frontmatter} (type: {type}) — irreversível: carregue as instruções desta skill e peça a confirmação antes de usar
|
|
398
399
|
```
|
|
400
|
+
The second form is for every skill with `side_effects: irreversible`.
|
|
399
401
|
|
|
400
402
|
4. **Tier 2 loading** — When the step's instructions explicitly reference a skill
|
|
401
403
|
(e.g., the step file says "use image-creator to render the slides"), OR when the
|
|
@@ -412,7 +414,9 @@ For each skill declared in an agent's `.agent.md` frontmatter `skills:` field:
|
|
|
412
414
|
5. **Step-level skill hints**: If the step's frontmatter contains a `skills_needed:` field
|
|
413
415
|
(e.g., `skills_needed: [image-creator]`), load Tier 2 for those skills immediately
|
|
414
416
|
without waiting for the agent to request them. This allows the Architect to pre-declare
|
|
415
|
-
which skills a step will need.
|
|
417
|
+
which skills a step will need. A skill with `side_effects: irreversible` always gets Tier 2
|
|
418
|
+
before its first use, even when no step names it: an MCP tool can be called without the body,
|
|
419
|
+
and the confirmation rules live there.
|
|
416
420
|
|
|
417
421
|
6. **Missing skill handling**: If a skill listed in an agent's frontmatter was not resolved
|
|
418
422
|
during Operation 5, skip it silently — the user was already warned during resolution.
|
package/templates/gitignore
CHANGED
|
@@ -20,6 +20,7 @@ mcp:
|
|
|
20
20
|
url: "https://mcp.blotato.com/mcp"
|
|
21
21
|
headers:
|
|
22
22
|
blotato-api-key: BLOTATO_API_KEY
|
|
23
|
+
side_effects: irreversible
|
|
23
24
|
env:
|
|
24
25
|
- BLOTATO_API_KEY
|
|
25
26
|
categories: [social-media, automation, publishing, scheduling]
|
|
@@ -35,19 +36,47 @@ Use Blotato when you need to publish or schedule social media posts across multi
|
|
|
35
36
|
|
|
36
37
|
You have access to Blotato for social media publishing.
|
|
37
38
|
|
|
38
|
-
###
|
|
39
|
+
### Workflow
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
Publishing is **irreversible**: a post cannot be taken back once it is live. The rule below is
|
|
42
|
+
about the **action**, whatever the tool is called on the server: before ANY call that publishes,
|
|
43
|
+
schedules or deletes, follow this order. The messages to the user are in PT-BR, as written here.
|
|
44
|
+
|
|
45
|
+
1. List the connected accounts (`blotato_list_accounts`, read-only) to get the account IDs and
|
|
46
|
+
platforms. Send nothing to Blotato yet, not even media.
|
|
47
|
+
2. **Preview (prévia)** — show the user exactly this, filled in:
|
|
48
|
+
```
|
|
49
|
+
Vou publicar isto:
|
|
50
|
+
Contas: {conta} ({rede}), …
|
|
51
|
+
Quando: agora, ou agendado para {data e hora}
|
|
52
|
+
Texto ({N} caracteres): {texto}
|
|
53
|
+
Mídia: {arquivos}, ou nenhuma
|
|
54
|
+
Para publicar, responda com a palavra publicar. Qualquer outra resposta cancela.
|
|
55
|
+
```
|
|
56
|
+
(`Quando`: write `agora` or `agendado para …`. `Mídia`: the file names, or `nenhuma`.)
|
|
57
|
+
3. Wait for the word **publicar**. Any other answer — including silence, "ok" or "sim" — cancels:
|
|
58
|
+
say "Nada foi publicado." and stop.
|
|
59
|
+
4. Only after the word: upload the media, if any (`blotato_upload_media`), then make **one single
|
|
60
|
+
call** that publishes or schedules (`blotato_create_post`; if the server takes one account per
|
|
61
|
+
call, once per account of the preview and never twice for the same account).
|
|
62
|
+
5. On success: check the result (`blotato_get_post_status`) and save the post URL and ID to the
|
|
63
|
+
step output file immediately.
|
|
64
|
+
6. On failure, timeout or missing answer: do NOT repeat the call again, in this step or in a retry.
|
|
65
|
+
Tell the user: "⚠️ Não recebi a confirmação do Blotato. A publicação pode já ter saído. Confira
|
|
66
|
+
no painel antes de tentar de novo. Não vou repetir sozinho."
|
|
67
|
+
7. One confirmation is worth one publication. If this step runs again in the same run (a retry, or
|
|
68
|
+
back from a rejected review), show the preview again, after this line: "Este passo já tentou
|
|
69
|
+
publicar nesta execução. Confira se saiu antes de confirmar de novo." — and wait for the word.
|
|
70
|
+
8. A call that **deletes** (a post, a scheduled post, media) follows the same order with its own
|
|
71
|
+
word: "Vou apagar isto: {o que será apagado}. Para apagar, responda com a palavra apagar.
|
|
72
|
+
Qualquer outra resposta cancela." Any other answer: "Nada foi apagado."
|
|
44
73
|
|
|
45
74
|
### Best practices
|
|
46
75
|
|
|
47
|
-
- Always call `blotato_list_accounts` first to get valid account IDs
|
|
48
76
|
- For scheduled posts, use ISO 8601 format for datetime
|
|
49
|
-
- After
|
|
50
|
-
|
|
77
|
+
- After the publishing call, read `blotato_get_post_status` until status is "published" or
|
|
78
|
+
"scheduled" — reading the status is safe; calling `blotato_create_post` again is not
|
|
79
|
+
- If status is "failed", report the error details to the user and let them decide
|
|
51
80
|
|
|
52
81
|
### Requirements
|
|
53
82
|
|
|
@@ -57,7 +86,7 @@ You have access to Blotato for social media publishing.
|
|
|
57
86
|
## Available operations
|
|
58
87
|
|
|
59
88
|
- **List Accounts** -- Retrieve connected social media accounts and their platform types
|
|
60
|
-
- **Upload Media** -- Upload images and videos for use in posts
|
|
61
|
-
- **Create Post** -- Publish or schedule a post to one or more platforms
|
|
89
|
+
- **Upload Media** -- Upload images and videos for use in posts (only after the word)
|
|
90
|
+
- **Create Post** -- Publish or schedule a post to one or more platforms (only after the word)
|
|
62
91
|
- **Get Post Status** -- Monitor publishing status (published, scheduled, failed)
|
|
63
92
|
- **Multi-platform Publishing** -- Post the same content across Instagram, LinkedIn, Twitter/X, TikTok, YouTube simultaneously
|
|
@@ -15,7 +15,7 @@ version: "1.0.0"
|
|
|
15
15
|
script:
|
|
16
16
|
path: scripts/generate.py
|
|
17
17
|
runtime: python
|
|
18
|
-
invoke: "python3 {skill_path}/scripts/generate.py --prompt \"{
|
|
18
|
+
invoke: "python3 {skill_path}/scripts/generate.py --prompt-file \"{prompt_file}\" --output \"{output}\" --mode \"{mode}\""
|
|
19
19
|
env:
|
|
20
20
|
- OPENROUTER_API_KEY
|
|
21
21
|
categories: [assets, images, ai, generation]
|
|
@@ -56,11 +56,22 @@ Use the Image Generator when you need to create visual assets from text prompts.
|
|
|
56
56
|
`python3` (macOS/Linux). **On Windows** use `py -3` instead (or `python` if the `py` launcher is
|
|
57
57
|
not installed).
|
|
58
58
|
|
|
59
|
+
**The prompt goes in a file.** Write it with your file-writing tool, in UTF-8, next to the image
|
|
60
|
+
(`crews/{crew}/output/{run_id}/assets/image-name.prompt.txt`). Never put the prompt inside a shell
|
|
61
|
+
command: write it to a file and pass `--prompt-file`. The shell rewrites `$`, quotes and backticks
|
|
62
|
+
in a typed text (a price like "R$50" reaches the model wrong, and the image is still charged). The
|
|
63
|
+
old `--prompt` option is still accepted, for crews created before; do not use it.
|
|
64
|
+
|
|
65
|
+
**File names.** Every path in the command (`--prompt-file`, `--output`, `--reference`, `--batch`)
|
|
66
|
+
follows the safe-name rule (nome seguro) of `_opencrew/core/runner.pipeline.md` — letters, digits,
|
|
67
|
+
space and `. _ - / \ : ( )`, between double quotes. With any other character do not run the
|
|
68
|
+
command: ask the user to rename the file.
|
|
69
|
+
|
|
59
70
|
### Single image generation
|
|
60
71
|
|
|
61
72
|
```bash
|
|
62
73
|
python3 {skill_path}/scripts/generate.py \
|
|
63
|
-
--prompt "
|
|
74
|
+
--prompt-file "crews/{crew}/output/{run_id}/assets/image-name.prompt.txt" \
|
|
64
75
|
--output "crews/{crew}/output/{run_id}/assets/image-name.jpg" \
|
|
65
76
|
--mode test
|
|
66
77
|
```
|
|
@@ -71,7 +82,7 @@ Use `--reference` to send a local image to the model as visual context. The mode
|
|
|
71
82
|
|
|
72
83
|
```bash
|
|
73
84
|
python3 {skill_path}/scripts/generate.py \
|
|
74
|
-
--prompt "
|
|
85
|
+
--prompt-file "crews/{crew}/output/{run_id}/assets/banner.prompt.txt" \
|
|
75
86
|
--output "crews/{crew}/output/{run_id}/assets/banner.jpg" \
|
|
76
87
|
--reference "crews/{crew}/assets/logo.png" \
|
|
77
88
|
--mode production
|
|
@@ -87,7 +98,8 @@ python3 {skill_path}/scripts/generate.py \
|
|
|
87
98
|
--mode production
|
|
88
99
|
```
|
|
89
100
|
|
|
90
|
-
|
|
101
|
+
Write the batch JSON file with your file-writing tool, in UTF-8 (the prompts inside it never go
|
|
102
|
+
through the shell). It should contain:
|
|
91
103
|
```json
|
|
92
104
|
[
|
|
93
105
|
{"prompt": "Description of image 1", "output": "path/to/image1.jpg"},
|
|
@@ -115,13 +127,14 @@ Each item can optionally include a `"reference": "path/to/ref.png"` field.
|
|
|
115
127
|
|
|
116
128
|
## Available operations
|
|
117
129
|
|
|
118
|
-
- **Single generation** — Generate one image from a text prompt
|
|
130
|
+
- **Single generation** — Generate one image from a text prompt saved in a file
|
|
119
131
|
- **Batch generation** — Generate multiple images from a JSON batch file
|
|
120
132
|
- **Mode selection** — Choose between test (cheap) and production (high-quality) models
|
|
121
133
|
- **Reference image** — Send a logo/mascot/brand asset as visual context for the generation
|
|
122
134
|
|
|
123
135
|
## Error handling
|
|
124
136
|
|
|
137
|
+
- If the prompt file is missing or empty, or the batch file cannot be read (not UTF-8, invalid JSON), the script says so in one line and exits with code 1. Show the message to the user; nothing was generated or charged.
|
|
125
138
|
- If `OPENROUTER_API_KEY` is not set, the script exits with an error message. Set it in your `.env` file or environment.
|
|
126
139
|
- If the API returns an error, the script prints the error code and body, then exits with code 1.
|
|
127
140
|
- If no image is found in the API response, the script reports which model was used and exits with code 1.
|
|
@@ -4,14 +4,16 @@ Image Generator — opencrew Skill
|
|
|
4
4
|
Generates images via Openrouter API using AI image models.
|
|
5
5
|
|
|
6
6
|
Usage:
|
|
7
|
-
# Single image
|
|
8
|
-
python3 generate.py --prompt "
|
|
7
|
+
# Single image (the prompt is read from a UTF-8 text file, never typed in the command)
|
|
8
|
+
python3 generate.py --prompt-file "path/to/prompt.txt" --output "path/to/image.jpg" --mode test
|
|
9
9
|
|
|
10
10
|
# Single image with reference (logo/mascot)
|
|
11
|
-
python3 generate.py --prompt "
|
|
11
|
+
python3 generate.py --prompt-file "path/to/prompt.txt" --output "path/to/image.jpg" --reference "path/to/logo.png" --mode production
|
|
12
12
|
|
|
13
|
-
# Batch (JSON file with list of {prompt, output} objects)
|
|
13
|
+
# Batch (UTF-8 JSON file with list of {prompt, output} objects)
|
|
14
14
|
python3 generate.py --batch "path/to/batch.json" --mode production
|
|
15
|
+
|
|
16
|
+
--prompt "text" is still accepted (legacy): the shell may rewrite $, quotes and backticks in it.
|
|
15
17
|
"""
|
|
16
18
|
|
|
17
19
|
import argparse
|
|
@@ -32,6 +34,42 @@ MODELS = {
|
|
|
32
34
|
API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
|
33
35
|
|
|
34
36
|
|
|
37
|
+
def fail(message):
|
|
38
|
+
"""Tell the user what went wrong and exit with code 1, without a traceback."""
|
|
39
|
+
print(message, file=sys.stderr)
|
|
40
|
+
sys.exit(1)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def read_prompt_file(path):
|
|
44
|
+
"""Read the prompt from a UTF-8 text file (BOM accepted); it never goes through the shell."""
|
|
45
|
+
if not os.path.isfile(path):
|
|
46
|
+
fail(f"Arquivo de prompt não encontrado: {path}")
|
|
47
|
+
try:
|
|
48
|
+
with open(path, "r", encoding="utf-8-sig") as f:
|
|
49
|
+
prompt = f.read().strip()
|
|
50
|
+
except (OSError, ValueError) as e:
|
|
51
|
+
fail(f"Não consegui ler o arquivo de prompt {path}: {e}. Grave o arquivo em UTF-8.")
|
|
52
|
+
if not prompt:
|
|
53
|
+
fail(f"O arquivo de prompt está vazio: {path}")
|
|
54
|
+
return prompt
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def read_batch(path):
|
|
58
|
+
"""Read the batch list from a UTF-8 JSON file (BOM accepted)."""
|
|
59
|
+
try:
|
|
60
|
+
with open(path, "r", encoding="utf-8-sig") as f:
|
|
61
|
+
batch = json.load(f)
|
|
62
|
+
except (OSError, ValueError) as e:
|
|
63
|
+
fail(f"Não consegui ler o lote {path}: {e}. Grave o arquivo em UTF-8.")
|
|
64
|
+
ok = isinstance(batch, list) and all(
|
|
65
|
+
isinstance(i, dict) and all(isinstance(i.get(k), str) and i[k].strip() for k in ("prompt", "output"))
|
|
66
|
+
for i in batch
|
|
67
|
+
)
|
|
68
|
+
if not ok:
|
|
69
|
+
fail(f'O lote {path} tem de ser uma lista de itens com "prompt" e "output". Nada foi gerado.')
|
|
70
|
+
return batch
|
|
71
|
+
|
|
72
|
+
|
|
35
73
|
def load_api_key():
|
|
36
74
|
"""Load OPENROUTER_API_KEY from environment."""
|
|
37
75
|
key = os.environ.get("OPENROUTER_API_KEY")
|
|
@@ -130,7 +168,8 @@ def generate_image(prompt, output_path, mode, api_key, reference_image=None):
|
|
|
130
168
|
|
|
131
169
|
def main():
|
|
132
170
|
parser = argparse.ArgumentParser(description="Generate images via Openrouter API")
|
|
133
|
-
parser.add_argument("--prompt", help="
|
|
171
|
+
parser.add_argument("--prompt-file", help="UTF-8 text file with the prompt for single image generation")
|
|
172
|
+
parser.add_argument("--prompt", help="Legacy: prompt typed in the command (use --prompt-file)")
|
|
134
173
|
parser.add_argument("--output", help="Output file path for single image")
|
|
135
174
|
parser.add_argument("--batch", help="Path to JSON batch file")
|
|
136
175
|
parser.add_argument("--mode", choices=["test", "production"], default="test",
|
|
@@ -138,8 +177,13 @@ def main():
|
|
|
138
177
|
parser.add_argument("--reference", help="Path to reference image to include in the prompt")
|
|
139
178
|
args = parser.parse_args()
|
|
140
179
|
|
|
141
|
-
if
|
|
142
|
-
|
|
180
|
+
if args.prompt_file and args.batch:
|
|
181
|
+
fail("Use só um: --prompt-file ou --batch.")
|
|
182
|
+
if not (args.prompt_file or args.prompt or args.batch):
|
|
183
|
+
parser.error("Either --prompt-file or --batch is required")
|
|
184
|
+
# The input files are read first: a bad file stops here, before the key and any API call.
|
|
185
|
+
items = read_batch(args.batch) if args.batch else None
|
|
186
|
+
prompt = read_prompt_file(args.prompt_file) if args.prompt_file else args.prompt
|
|
143
187
|
|
|
144
188
|
api_key = load_api_key()
|
|
145
189
|
model = MODELS[args.mode]
|
|
@@ -147,8 +191,6 @@ def main():
|
|
|
147
191
|
|
|
148
192
|
if args.batch:
|
|
149
193
|
# Batch mode
|
|
150
|
-
with open(args.batch, "r") as f:
|
|
151
|
-
items = json.load(f)
|
|
152
194
|
print(f"Generating {len(items)} images...\n")
|
|
153
195
|
success = 0
|
|
154
196
|
for i, item in enumerate(items, 1):
|
|
@@ -167,7 +209,7 @@ def main():
|
|
|
167
209
|
if not args.output:
|
|
168
210
|
parser.error("--output is required for single image generation")
|
|
169
211
|
print(f"Generating: {os.path.basename(args.output)}...")
|
|
170
|
-
ok = generate_image(
|
|
212
|
+
ok = generate_image(prompt, args.output, args.mode, api_key, reference_image=args.reference)
|
|
171
213
|
sys.exit(0 if ok else 1)
|
|
172
214
|
|
|
173
215
|
|
|
@@ -74,6 +74,10 @@ and is **never** retried automatically.
|
|
|
74
74
|
|
|
75
75
|
- Images: JPEG only (`.jpg`/`.jpeg`), 2-10 per carousel, inside `crews/*/output/` — the
|
|
76
76
|
script refuses anything else before uploading
|
|
77
|
+
- File names: the image paths and the caption file follow the safe-name rule (nome seguro) of
|
|
78
|
+
`_opencrew/core/runner.pipeline.md` — letters, digits, space and `. _ - / \ : ( )`. With any
|
|
79
|
+
other character (a comma included: it splits the `--images` list) do not run the command: ask
|
|
80
|
+
the user to rename the file
|
|
77
81
|
- Images are hosted on imgBB for 24h only (enough for Instagram to fetch them)
|
|
78
82
|
- Caption: max 2200 characters
|
|
79
83
|
- Requires Instagram Business account (not Personal or Creator)
|
|
@@ -14,6 +14,7 @@ Every opencrew skill consists of a `SKILL.md` file with YAML frontmatter and a M
|
|
|
14
14
|
| `version` | Yes | Semver version string (e.g., `1.0.0`) |
|
|
15
15
|
| `categories` | No | Classification tags array (e.g., `["social-media", "content"]`) |
|
|
16
16
|
| `env` | No | Required environment variable names array |
|
|
17
|
+
| `side_effects` | No | `irreversible` for a skill that publishes or sends (a post, an e-mail). Its body must then show a preview, wait for a confirmation word and make one single call, never repeated after a failure. A skill that only costs money does not use it |
|
|
17
18
|
|
|
18
19
|
### Type: mcp
|
|
19
20
|
|