@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +20 -8
  3. package/package.json +2 -2
  4. package/src/cli.js +15 -38
  5. package/src/commands/init.js +41 -44
  6. package/src/commands/update.js +78 -75
  7. package/src/lib/blocos.js +148 -0
  8. package/src/lib/deteccao.js +69 -0
  9. package/src/lib/fsx.js +1 -55
  10. package/src/lib/ides.js +4 -0
  11. package/src/lib/legado.js +142 -0
  12. package/src/lib/manifest.js +67 -26
  13. package/src/lib/mcp.js +131 -0
  14. package/src/lib/migrations.js +77 -74
  15. package/src/lib/node-version.js +43 -0
  16. package/src/lib/prompts.js +25 -2
  17. package/src/lib/resumo.js +125 -0
  18. package/templates/.mcp.json +1 -1
  19. package/templates/_opencrew/.opencrew-version +1 -1
  20. package/templates/_opencrew/core/best-practices/social-networks-publishing.md +14 -14
  21. package/templates/_opencrew/core/prompts/export.prompt.md +1 -1
  22. package/templates/_opencrew/core/prompts/sherlock-shared.md +5 -5
  23. package/templates/_opencrew/core/runner.pipeline.md +32 -12
  24. package/templates/_opencrew/core/scripts/comum.mjs +49 -4
  25. package/templates/_opencrew/core/scripts/conferir-fontes/busca.mjs +42 -3
  26. package/templates/_opencrew/core/scripts/conferir-fontes/relatorio.mjs +18 -6
  27. package/templates/_opencrew/core/scripts/conferir-fontes.mjs +97 -39
  28. package/templates/_opencrew/core/scripts/verificar.mjs +7 -4
  29. package/templates/_opencrew/core/skills.engine.md +7 -3
  30. package/templates/gitignore +1 -0
  31. package/templates/skills/blotato/SKILL.md +39 -10
  32. package/templates/skills/image-ai-generator/SKILL.md +18 -5
  33. package/templates/skills/image-ai-generator/scripts/generate.py +52 -10
  34. package/templates/skills/instagram-publisher/SKILL.md +4 -0
  35. package/templates/skills/opencrew-skill-creator/references/skill-format.md +1 -0
  36. 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), sem linha FONTES: e sem escrever nada.
10
- // Specs: specs/fase-u2-crew-que-conhece-o-projeto.md e specs/fase-r1-reparos-1-6-1.md
11
- // (repositório do OpenCrew). Módulos em conferir-fontes/: coleta, busca e relatório.
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, barra, ehAbsoluto, temBarraFinal, resolver, indexar, candidatosPorNome, nomesDaPastaEsperada,
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. A própria raiz
26
- * não tem caminho relativo a sugerir.
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) ? barra(path.relative(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
- let indice = null;
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
- const achado = resolver(raiz, crew, ref, item.citadoEm);
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. @returns quantos caminhos
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
- for (const arquivo of item.citadoEm) {
83
- const texto = await readFile(arquivo, 'utf8');
84
- if (!tocados.has(arquivo)) {
85
- await copiaDeSeguranca(arquivo);
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
- return comSugestao.length;
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 diz quantas pendências ficam sem correção. */
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 n = await corrigir({ resultado: r });
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
- let r = await conferir({ raiz: cwd, crew });
127
- escrever(formatar(r));
128
- if (argv.includes('--corrigir')) r = await corrigirEAvisar(r, escrever);
129
- escrever(`FONTES:${r.status}`);
130
- return 0;
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)) {
@@ -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 e fase-r1-reparos-1-6-1.md (repositório do OpenCrew).
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
- /** Caminho absoluto de dentro do projeto aparece no relatório como o relativo. */
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
- return path.isAbsolute(arquivo) ? path.relative(raiz, arquivo).split(path.sep).join('/') : arquivo;
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 `type` fields.
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.
@@ -8,4 +8,5 @@ _opencrew/_memory/company.md
8
8
  _opencrew/_memory/preferences.md
9
9
  _opencrew/_browser_profile/
10
10
  _opencrew/logs/
11
+ .opencrew-backup/
11
12
  .claude/settings.local.json
@@ -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
- ### Key workflow
39
+ ### Workflow
39
40
 
40
- 1. Use `blotato_list_accounts` to get account IDs and platforms
41
- 2. If post includes images or videos, upload them with `blotato_upload_media` first and use the returned media IDs in `blotato_create_post`
42
- 3. Use `blotato_create_post` to publish or schedule
43
- 4. Use `blotato_get_post_status` to confirm success
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 posting, poll `blotato_get_post_status` until status is "published" or "scheduled"
50
- - If status is "failed", report the error details to the user
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 \"{prompt}\" --output \"{output}\" --mode \"{mode}\""
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 "A detailed description of the image to generate" \
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 "A social media banner featuring the company logo prominently in the center" \
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
- The batch JSON file should contain:
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 "description" --output "path/to/image.jpg" --mode test
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 "description" --output "path/to/image.jpg" --reference "path/to/logo.png" --mode production
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="Text prompt for single image generation")
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 not args.prompt and not args.batch:
142
- parser.error("Either --prompt or --batch is required")
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(args.prompt, args.output, args.mode, api_key, reference_image=args.reference)
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