@aksp/opencrew 1.6.2 → 1.7.0
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 +102 -0
- package/README.md +70 -13
- 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/AGENTS.md +20 -6
- package/templates/_opencrew/.opencrew-version +1 -1
- package/templates/_opencrew/core/best-practices/social-networks-publishing.md +14 -14
- package/templates/_opencrew/core/escritorio/animacao.js +64 -0
- package/templates/_opencrew/core/escritorio/app.js +137 -0
- package/templates/_opencrew/core/escritorio/cena.js +132 -0
- package/templates/_opencrew/core/escritorio/demo.js +79 -0
- package/templates/_opencrew/core/escritorio/escala.js +27 -0
- package/templates/_opencrew/core/escritorio/index.html +166 -0
- package/templates/_opencrew/core/escritorio/modelo-agentes.js +93 -0
- package/templates/_opencrew/core/escritorio/modelo-estado.js +71 -0
- package/templates/_opencrew/core/escritorio/modelo-mesas.js +81 -0
- package/templates/_opencrew/core/escritorio/modelo-pagina.js +95 -0
- package/templates/_opencrew/core/escritorio/modelo-textos.js +65 -0
- package/templates/_opencrew/core/escritorio/modelo-visao.js +91 -0
- package/templates/_opencrew/core/escritorio/modelo.js +29 -0
- package/templates/_opencrew/core/escritorio/painel.js +120 -0
- package/templates/_opencrew/core/escritorio/quadro.js +106 -0
- package/templates/_opencrew/core/escritorio/rota.js +62 -0
- package/templates/_opencrew/core/escritorio/rotulos.js +78 -0
- package/templates/_opencrew/core/escritorio/sprites-mesa.js +122 -0
- package/templates/_opencrew/core/escritorio/sprites-sala.js +92 -0
- package/templates/_opencrew/core/escritorio/sprites.js +187 -0
- package/templates/_opencrew/core/prompts/build.prompt.md +3 -3
- package/templates/_opencrew/core/prompts/export.prompt.md +1 -1
- package/templates/_opencrew/core/prompts/repair.prompt.md +7 -12
- package/templates/_opencrew/core/prompts/sherlock-shared.md +5 -5
- package/templates/_opencrew/core/runner.pipeline.md +76 -139
- 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/escritorio/leitura.mjs +31 -0
- package/templates/_opencrew/core/scripts/escritorio/porta.mjs +98 -0
- package/templates/_opencrew/core/scripts/escritorio/projeto.mjs +29 -0
- package/templates/_opencrew/core/scripts/escritorio/servidor.mjs +78 -0
- package/templates/_opencrew/core/scripts/escritorio.mjs +117 -0
- package/templates/_opencrew/core/scripts/estado/argumentos.mjs +61 -0
- package/templates/_opencrew/core/scripts/estado/arquivo.mjs +53 -0
- package/templates/_opencrew/core/scripts/estado/decisao.mjs +56 -0
- package/templates/_opencrew/core/scripts/estado/elenco.mjs +58 -0
- package/templates/_opencrew/core/scripts/estado/nucleo.mjs +113 -0
- package/templates/_opencrew/core/scripts/estado/preferencia.mjs +24 -0
- package/templates/_opencrew/core/scripts/estado.mjs +96 -0
- package/templates/_opencrew/core/scripts/verificar.mjs +7 -4
- package/templates/_opencrew/core/skills.engine.md +7 -3
- package/templates/gitignore +1 -0
- package/templates/skills/blotato/SKILL.md +39 -10
- package/templates/skills/image-ai-generator/SKILL.md +18 -5
- package/templates/skills/image-ai-generator/scripts/generate.py +52 -10
- package/templates/skills/instagram-publisher/SKILL.md +4 -0
- package/templates/skills/opencrew-skill-creator/references/skill-format.md +1 -0
- package/templates/skills/resend/SKILL.md +52 -13
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Leitura e gravação de `crews/<crew>/state.json`: inteira ou nenhuma.
|
|
2
|
+
// Spec: fase-e1-escritorio-ao-vivo.md, regras 3 e 6 (repositório do OpenCrew).
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
|
|
5
|
+
/** O Windows recusa a troca de nome com um destes códigos enquanto outro processo lê o arquivo. */
|
|
6
|
+
const RECUSAS = ['EPERM', 'EBUSY', 'EACCES'];
|
|
7
|
+
/** Depois da primeira tentativa: até 3 repetições, uma a cada 100 ms (cerca de 300 ms ao todo). */
|
|
8
|
+
const REPETICOES = 3;
|
|
9
|
+
const INTERVALO_MS = 100;
|
|
10
|
+
|
|
11
|
+
const pausa = (ms) => new Promise((seguir) => { setTimeout(seguir, ms); });
|
|
12
|
+
|
|
13
|
+
/** O estado gravado, ou `null` quando o arquivo falta ou está ilegível (pela metade, sem `agents` em lista). */
|
|
14
|
+
export async function lerEstado(arquivo) {
|
|
15
|
+
try {
|
|
16
|
+
const estado = JSON.parse((await fs.readFile(arquivo, 'utf8')).replace(/^\uFEFF/, ''));
|
|
17
|
+
return Array.isArray(estado?.agents) ? estado : null;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function trocarNome(de, para, { renomear = fs.rename, esperar = pausa }) {
|
|
24
|
+
for (let repeticao = 0; ; repeticao++) {
|
|
25
|
+
try {
|
|
26
|
+
return await renomear(de, para);
|
|
27
|
+
} catch (erro) {
|
|
28
|
+
if (!RECUSAS.includes(erro?.code) || repeticao === REPETICOES) throw erro;
|
|
29
|
+
await esperar(INTERVALO_MS);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Grava o estado num temporário da mesma pasta (`state.json.<pid>.tmp`) e troca o nome: quem lê
|
|
36
|
+
* vê o arquivo antigo ou o novo, nunca um pela metade.
|
|
37
|
+
* @param {string} arquivo caminho do `state.json`
|
|
38
|
+
* @param {object} estado
|
|
39
|
+
* @param {{ renomear?: Function, esperar?: Function }} [deps] SÓ PARA TESTE: a troca de nome
|
|
40
|
+
* (`(de, para) => Promise`) e a espera entre as tentativas (`(ms) => Promise`)
|
|
41
|
+
* @returns {Promise<boolean>} `false` quando desistiu: o arquivo fica como estava e o temporário é apagado
|
|
42
|
+
*/
|
|
43
|
+
export async function gravarEstado(arquivo, estado, deps = {}) {
|
|
44
|
+
const temporario = `${arquivo}.${process.pid}.tmp`;
|
|
45
|
+
try {
|
|
46
|
+
await fs.writeFile(temporario, `${JSON.stringify(estado, null, 2)}\n`);
|
|
47
|
+
await trocarNome(temporario, arquivo, deps);
|
|
48
|
+
return true;
|
|
49
|
+
} catch {
|
|
50
|
+
await fs.rm(temporario, { force: true }).catch(() => {});
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// O que fazer com um evento: aplicar, montar o estado de novo antes, ou ignorar. Puro, como o
|
|
2
|
+
// núcleo: recebe o estado lido (ou `null`) e o evento, não toca em disco.
|
|
3
|
+
// Spec: fase-e1-escritorio-ao-vivo.md, regra 5 e §6 (repositório do OpenCrew).
|
|
4
|
+
import { proximoEstado } from './nucleo.mjs';
|
|
5
|
+
|
|
6
|
+
/** Os motivos de `ESTADO:IGNORADO` (o texto depois do travessão). */
|
|
7
|
+
export const MOTIVO = {
|
|
8
|
+
desligado: 'escritório desligado (ligue com /opencrew dashboard)',
|
|
9
|
+
semElenco: 'crew-party.csv ausente ou sem agentes',
|
|
10
|
+
agenteDesconhecido: (id, ids) => `agente "${id}" não está no elenco da crew. Ids válidos: ${ids.join(', ')}`,
|
|
11
|
+
pularSemAgente: (ids) => `o evento pular precisa de --agente. Ids válidos: ${ids.join(', ')}`,
|
|
12
|
+
semEstado: 'sem estado desta execução',
|
|
13
|
+
naoGravou: 'não foi possível gravar o estado',
|
|
14
|
+
inesperado: (erro) => `erro inesperado: ${erro?.message ?? erro}`,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const USAM_AGENTE = ['passo', 'checkpoint', 'pular'];
|
|
18
|
+
/** Eventos que montam o estado quando ele falta; os outros são ignorados sem ele. */
|
|
19
|
+
const MONTAM = ['iniciar', 'passo', 'checkpoint'];
|
|
20
|
+
const ENCERRADA = ['completed', 'failed'];
|
|
21
|
+
|
|
22
|
+
function motivoParaIgnorar(anterior, { tipo, agente, elenco }) {
|
|
23
|
+
const ids = elenco.map((a) => a.id);
|
|
24
|
+
if (!ids.length) return MOTIVO.semElenco;
|
|
25
|
+
const pedeAgente = USAM_AGENTE.includes(tipo);
|
|
26
|
+
if (pedeAgente && agente && !ids.includes(agente)) return MOTIVO.agenteDesconhecido(agente, ids);
|
|
27
|
+
if (tipo === 'pular' && !agente) return MOTIVO.pularSemAgente(ids);
|
|
28
|
+
return anterior || MONTAM.includes(tipo) ? null : MOTIVO.semEstado;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `passo` e `checkpoint` montam o estado de novo quando ele falta ou está ilegível, quando o
|
|
33
|
+
* `passo --n 1` chega sobre uma execução encerrada, e quando o agente do evento está no elenco
|
|
34
|
+
* mas não no estado gravado (elenco editado sem um `iniciar` depois).
|
|
35
|
+
*/
|
|
36
|
+
function pedeRecriar(anterior, { tipo, n, agente }) {
|
|
37
|
+
if (tipo !== 'passo' && tipo !== 'checkpoint') return false;
|
|
38
|
+
if (!anterior) return true;
|
|
39
|
+
if (tipo === 'passo' && n === 1 && ENCERRADA.includes(anterior.status)) return true;
|
|
40
|
+
return Boolean(agente) && !anterior.agents.some((a) => a?.id === agente);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {object|null} anterior o estado lido do `state.json`; `null` se falta ou está ilegível
|
|
45
|
+
* @param {object} evento o evento do núcleo, sempre com `crew`, `elenco` e `agora`
|
|
46
|
+
* @returns {{ ignorado: string } | { estado: object, recriado: boolean }} o motivo para não
|
|
47
|
+
* mexer em nada, ou o estado a gravar (`recriado`: foi montado de novo, como no `iniciar`,
|
|
48
|
+
* herdando só o `step.total`)
|
|
49
|
+
*/
|
|
50
|
+
export function decidir(anterior, evento) {
|
|
51
|
+
const motivo = motivoParaIgnorar(anterior, evento);
|
|
52
|
+
if (motivo) return { ignorado: motivo };
|
|
53
|
+
const recriado = pedeRecriar(anterior, evento);
|
|
54
|
+
const base = recriado ? proximoEstado(null, { ...evento, tipo: 'iniciar', passos: anterior?.step?.total }) : anterior;
|
|
55
|
+
return { estado: proximoEstado(base, evento), recriado };
|
|
56
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// O elenco de uma crew, lido de `crew-party.csv` (cabeçalho id,displayName,title,icon,path,execution;
|
|
2
|
+
// crew antiga pode não ter a coluna `id`).
|
|
3
|
+
// Spec: fase-e1-escritorio-ao-vivo.md, regra 2 (repositório do OpenCrew).
|
|
4
|
+
import { promises as fs } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Texto CSV → linhas, cada uma com os seus campos (sem as aspas e sem espaço nas pontas). Entre
|
|
9
|
+
* aspas o campo pode ter vírgula, quebra de linha e `""` (uma aspa). Uma passada só, caractere a
|
|
10
|
+
* caractere: aspa que não fecha leva o resto do texto para o campo, nunca trava.
|
|
11
|
+
*/
|
|
12
|
+
export function lerCsv(texto) {
|
|
13
|
+
const linhas = [];
|
|
14
|
+
let linha = [];
|
|
15
|
+
let campo = '';
|
|
16
|
+
let aspas = false;
|
|
17
|
+
const fechaCampo = () => { linha.push(campo.trim()); campo = ''; };
|
|
18
|
+
const fechaLinha = () => { fechaCampo(); linhas.push(linha); linha = []; };
|
|
19
|
+
for (let i = 0; i < texto.length; i++) {
|
|
20
|
+
const c = texto[i];
|
|
21
|
+
if (c === '"' && aspas && texto[i + 1] === '"') campo += texto[i++];
|
|
22
|
+
else if (c === '"') aspas = !aspas;
|
|
23
|
+
else if (aspas || !',\r\n'.includes(c)) campo += c;
|
|
24
|
+
else if (c === ',') fechaCampo();
|
|
25
|
+
else if (c === '\n' || texto[i + 1] !== '\n') fechaLinha(); // `\r\n` fecha a linha uma vez só
|
|
26
|
+
}
|
|
27
|
+
if (campo || linha.length) fechaLinha();
|
|
28
|
+
return linhas;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** O id que o nome do arquivo do agente dá: `./agents/x.agent.md` → `x`; outro caminho, nenhum. */
|
|
32
|
+
const idDoCaminho = (caminho = '') => caminho.match(/([^/\\]+)\.agent\.md$/i)?.[1] ?? '';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* O elenco, na ordem do arquivo: `[{ id, name, icon }]`. As colunas `id`, `displayName`, `icon` e
|
|
36
|
+
* `path` são achadas pelo nome no cabeçalho. Linha sem `id` (crew antiga, de antes de o cabeçalho
|
|
37
|
+
* ser fixo) usa o nome do arquivo em `path`. Sem nenhum dos dois, ou com `id` repetido, a linha
|
|
38
|
+
* fica de fora; sem `displayName`, o nome é o `id`.
|
|
39
|
+
*/
|
|
40
|
+
export function elencoDoCsv(texto) {
|
|
41
|
+
const [cabecalho = [], ...linhas] = lerCsv(texto.replace(/^\uFEFF/, ''));
|
|
42
|
+
const colunas = ['id', 'displayname', 'icon', 'path'].map((c) => cabecalho.findIndex((h) => h.toLowerCase() === c));
|
|
43
|
+
const [id, nome, icone, caminho] = colunas;
|
|
44
|
+
const vistos = new Set();
|
|
45
|
+
return linhas
|
|
46
|
+
.map((l) => ({ l, i: l[id] || idDoCaminho(l[caminho]) }))
|
|
47
|
+
.map(({ l, i }) => ({ id: i, name: l[nome] || i, icon: l[icone] ?? '' }))
|
|
48
|
+
.filter((a) => a.id && !vistos.has(a.id) && vistos.add(a.id));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** O elenco da crew; lista vazia quando o `crew-party.csv` falta, não abre ou não tem agentes. */
|
|
52
|
+
export async function lerElenco(pastaDaCrew) {
|
|
53
|
+
try {
|
|
54
|
+
return elencoDoCsv(await fs.readFile(path.join(pastaDaCrew, 'crew-party.csv'), 'utf8'));
|
|
55
|
+
} catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Núcleo puro do estado da execução: (estado, evento) → estado novo. Não lê relógio nem disco:
|
|
2
|
+
// a hora chega em `evento.agora` e o elenco em `evento.elenco`. O estado recebido nunca é alterado.
|
|
3
|
+
// Spec: fase-e1-escritorio-ao-vivo.md, regras 1 e 2 e §4 (repositório do OpenCrew).
|
|
4
|
+
|
|
5
|
+
/** Agente que ainda não entregou. `delivering` só existe em arquivo da 1.6.x. */
|
|
6
|
+
const ATIVOS = ['working', 'checkpoint', 'delivering'];
|
|
7
|
+
/** Quem o `falhar` derruba. */
|
|
8
|
+
const EM_CURSO = ['working', 'checkpoint'];
|
|
9
|
+
|
|
10
|
+
const texto = (v) => (typeof v === 'string' ? v : '');
|
|
11
|
+
const aPartirDe = (minimo, v) => (Number.isInteger(v) && v >= minimo ? v : null);
|
|
12
|
+
|
|
13
|
+
function agenteNormal(a) {
|
|
14
|
+
return { id: texto(a?.id), name: texto(a?.name), icon: texto(a?.icon), status: texto(a?.status) || 'idle', label: texto(a?.label) };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** O estado lido, no formato de hoje: agentes sem `desk` e com `label`, `step` completo. */
|
|
18
|
+
function normalizar(e) {
|
|
19
|
+
const { step, handoff } = e;
|
|
20
|
+
return {
|
|
21
|
+
...e,
|
|
22
|
+
crew: texto(e.crew),
|
|
23
|
+
status: texto(e.status),
|
|
24
|
+
step: { current: aPartirDe(0, step?.current) ?? 0, total: aPartirDe(1, step?.total), label: texto(step?.label) },
|
|
25
|
+
agents: (Array.isArray(e.agents) ? e.agents : []).map(agenteNormal),
|
|
26
|
+
handoff: handoff && typeof handoff === 'object' ? handoff : null,
|
|
27
|
+
startedAt: e.startedAt ?? null,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Só os campos da §4, na ordem dela; o que é de um status só não sobrevive a ele. */
|
|
32
|
+
function fechar(e, agora) {
|
|
33
|
+
return {
|
|
34
|
+
crew: e.crew,
|
|
35
|
+
status: e.status,
|
|
36
|
+
step: e.step,
|
|
37
|
+
agents: e.agents,
|
|
38
|
+
handoff: e.handoff,
|
|
39
|
+
...(e.status === 'failed' ? { motivo: texto(e.motivo) } : {}),
|
|
40
|
+
startedAt: e.startedAt,
|
|
41
|
+
updatedAt: agora,
|
|
42
|
+
...(e.status === 'completed' ? { completedAt: e.completedAt } : {}),
|
|
43
|
+
...(e.status === 'failed' ? { failedAt: e.failedAt } : {}),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const trocar = (agents, quem, status) => agents.map((a) => (quem(a) ? { ...a, status } : a));
|
|
48
|
+
|
|
49
|
+
/** `--n` grava `step.current`; `--rotulo` grava `step.label` (sem ele: vazio). */
|
|
50
|
+
const comPasso = (step, { n, rotulo }) => ({ ...step, current: aPartirDe(1, n) ?? step.current, label: texto(rotulo) });
|
|
51
|
+
|
|
52
|
+
function iniciar(_anterior, { crew, elenco, passos, agora }) {
|
|
53
|
+
return {
|
|
54
|
+
crew: texto(crew),
|
|
55
|
+
status: 'running',
|
|
56
|
+
step: { current: 0, total: aPartirDe(1, passos), label: '' },
|
|
57
|
+
agents: elenco.map((a) => agenteNormal({ ...a, status: 'idle', label: '' })),
|
|
58
|
+
handoff: null,
|
|
59
|
+
startedAt: agora,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function passo(estado, evento) {
|
|
64
|
+
const base = { ...estado, status: 'running', step: comPasso(estado.step, evento) };
|
|
65
|
+
const alvo = evento.agente ? estado.agents.find((a) => a.id === evento.agente) : null;
|
|
66
|
+
if (!alvo) return base;
|
|
67
|
+
const outros = estado.agents.filter((a) => a !== alvo && ATIVOS.includes(a.status));
|
|
68
|
+
const agents = estado.agents.map((a) => {
|
|
69
|
+
if (a === alvo) return { ...a, status: 'working', label: texto(evento.rotulo) };
|
|
70
|
+
return outros.includes(a) ? { ...a, status: 'done' } : a;
|
|
71
|
+
});
|
|
72
|
+
const bastao = { from: outros[0]?.id, to: alvo.id, message: texto(evento.mensagem), completedAt: evento.agora };
|
|
73
|
+
return { ...base, agents, handoff: outros.length ? bastao : estado.handoff };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function checkpoint(estado, evento) {
|
|
77
|
+
const agents = evento.agente ? trocar(estado.agents, (a) => a.id === evento.agente, 'checkpoint') : estado.agents;
|
|
78
|
+
return { ...estado, status: 'checkpoint', step: comPasso(estado.step, evento), agents };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function pular(estado, { agente }) {
|
|
82
|
+
return { ...estado, agents: agente ? trocar(estado.agents, (a) => a.id === agente, 'skipped') : estado.agents };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function concluir(estado, { agora }) {
|
|
86
|
+
return { ...estado, status: 'completed', completedAt: agora, agents: trocar(estado.agents, (a) => a.status !== 'skipped', 'done') };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function falhar(estado, { motivo, agora }) {
|
|
90
|
+
const agents = trocar(estado.agents, (a) => EM_CURSO.includes(a.status), 'failed');
|
|
91
|
+
return { ...estado, status: 'failed', failedAt: agora, motivo: texto(motivo), agents };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const TRANSICOES = { iniciar, passo, checkpoint, pular, concluir, falhar };
|
|
95
|
+
|
|
96
|
+
/** Os seis eventos da regra 2, na ordem em que o runner os usa. */
|
|
97
|
+
export const EVENTOS = Object.keys(TRANSICOES);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* O estado depois do evento.
|
|
101
|
+
* @param {object|null} estado o estado atual, como está no `state.json` (da 1.6.x também); o
|
|
102
|
+
* `iniciar` não o lê
|
|
103
|
+
* @param {object} evento `{ tipo, agora, … }` · `tipo`: um dos `EVENTOS` · `agora`: a hora, em ISO
|
|
104
|
+
* · no `iniciar`: `crew`, `elenco` (`[{ id, name, icon }]`) e `passos` · nos outros, conforme o
|
|
105
|
+
* evento: `n`, `agente`, `rotulo`, `mensagem`, `motivo`. Agente que não está no estado vale como
|
|
106
|
+
* evento sem agente.
|
|
107
|
+
* @returns {object} o estado novo, no formato da §4 da spec (`step.total` vazio é `null`)
|
|
108
|
+
*/
|
|
109
|
+
export function proximoEstado(estado, evento) {
|
|
110
|
+
if (!Object.hasOwn(TRANSICOES, evento.tipo)) throw new Error(`Evento desconhecido: ${evento.tipo}`);
|
|
111
|
+
const atual = evento.tipo === 'iniciar' ? null : normalizar(estado);
|
|
112
|
+
return fechar(TRANSICOES[evento.tipo](atual, evento), evento.agora);
|
|
113
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// O escritório está ligado? A preferência mora em `_opencrew/_memory/preferences.md`.
|
|
2
|
+
// Spec: fase-e1-escritorio-ao-vivo.md, regra 9 (repositório do OpenCrew).
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
// A linha da preferência: `- **Dashboard:** <valor>` (a que o onboarding grava) ou
|
|
7
|
+
// `Dashboard: <valor>`, sem diferenciar maiúsculas. Comentário e texto corrido não contam.
|
|
8
|
+
const LINHA = /^[ \t]*(?:[-*+][ \t]+)?(?:\*\*)?dashboard(?:\*\*)?:(?:\*\*)?[ \t]*(\S*)/im;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Pura: o texto de `preferences.md` liga o escritório? Vale a primeira linha da preferência. A marca
|
|
12
|
+
* de ordem de bytes que o PowerShell do Windows e alguns editores gravam no início do arquivo não
|
|
13
|
+
* faz parte da linha.
|
|
14
|
+
*/
|
|
15
|
+
export const ligadoNoTexto = (texto) => texto.replace(/^\uFEFF/, '').match(LINHA)?.[1].toLowerCase() === 'enabled';
|
|
16
|
+
|
|
17
|
+
/** Desligado por padrão: sem o arquivo, sem a linha ou com outro valor, a resposta é `false`. */
|
|
18
|
+
export async function escritorioLigado(raiz) {
|
|
19
|
+
try {
|
|
20
|
+
return ligadoNoTexto(await fs.readFile(path.join(raiz, '_opencrew', '_memory', 'preferences.md'), 'utf8'));
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Estado da execução de uma crew, para o escritório ao vivo: grava `crews/<crew>/state.json`,
|
|
3
|
+
// e só ele. Quem grava é este script, um comando por evento — a IA não monta o JSON.
|
|
4
|
+
// Uso (na pasta do projeto): node _opencrew/core/scripts/estado.mjs <crew> <evento> [opções]
|
|
5
|
+
// iniciar [--passos N]
|
|
6
|
+
// passo [--n K] [--agente <id>] [--rotulo "<texto>"] [--mensagem "<texto>"]
|
|
7
|
+
// checkpoint [--n K] [--agente <id>] [--rotulo "<texto>"]
|
|
8
|
+
// pular --agente <id>
|
|
9
|
+
// concluir
|
|
10
|
+
// falhar [--motivo "<texto>"]
|
|
11
|
+
// <crew> é o nome da pasta em `crews/`. Só grava com `Dashboard: enabled` em
|
|
12
|
+
// `_opencrew/_memory/preferences.md`.
|
|
13
|
+
// Última linha da saída (o runner lê esta linha): ESTADO:OK, ESTADO:OK — estado recriado ou
|
|
14
|
+
// ESTADO:IGNORADO — <motivo>. Código de saída: 0 sempre que a linha ESTADO: sai · 1 = erro de uso
|
|
15
|
+
// (crew ou evento faltando, evento fora da lista, pasta sem `_opencrew/`, crew inexistente ou
|
|
16
|
+
// fora de `crews/`); com código 1 não há linha ESTADO: e nada é escrito.
|
|
17
|
+
// Spec: fase-e1-escritorio-ao-vivo.md (repositório do OpenCrew).
|
|
18
|
+
import { statSync } from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { MSG, dentroDoProjeto, ehPrincipal } from './comum.mjs';
|
|
21
|
+
import { USO, lerArgs, limparTexto } from './estado/argumentos.mjs';
|
|
22
|
+
import { EVENTOS } from './estado/nucleo.mjs';
|
|
23
|
+
import { MOTIVO, decidir } from './estado/decisao.mjs';
|
|
24
|
+
import { lerElenco } from './estado/elenco.mjs';
|
|
25
|
+
import { lerEstado, gravarEstado } from './estado/arquivo.mjs';
|
|
26
|
+
import { escritorioLigado } from './estado/preferencia.mjs';
|
|
27
|
+
|
|
28
|
+
const OK = 'ESTADO:OK';
|
|
29
|
+
const RECRIADO = 'ESTADO:OK — estado recriado';
|
|
30
|
+
/** A resposta é sempre uma linha só, mesmo que o motivo traga um id ou um erro com quebra. */
|
|
31
|
+
const ignorado = (motivo) => `ESTADO:IGNORADO — ${motivo}`.replace(/\s*[\r\n]+\s*/g, ' ');
|
|
32
|
+
|
|
33
|
+
const LISTA = EVENTOS.join(', ');
|
|
34
|
+
|
|
35
|
+
function ehPasta(caminho) {
|
|
36
|
+
try {
|
|
37
|
+
return statSync(caminho).isDirectory();
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Onde fica a crew, ou o erro de uso. A crew é uma pasta direta de `crews/` (`crews/<nome>`
|
|
45
|
+
* também vale): só assim o único arquivo escrito é `crews/<crew>/state.json`.
|
|
46
|
+
* @returns {{ erro: string } | { pasta: string, crew: string }}
|
|
47
|
+
*/
|
|
48
|
+
function localizar(raiz, { crew, evento }) {
|
|
49
|
+
if (!crew) return { erro: 'Falta o nome da crew.' };
|
|
50
|
+
if (!evento) return { erro: `Falta o evento. Eventos: ${LISTA}.` };
|
|
51
|
+
if (!EVENTOS.includes(evento)) return { erro: `Evento desconhecido: ${limparTexto(evento)}. Eventos: ${LISTA}.` };
|
|
52
|
+
if (!ehPasta(path.join(raiz, '_opencrew'))) return { erro: MSG.semRaiz };
|
|
53
|
+
const base = path.resolve(raiz, 'crews');
|
|
54
|
+
const nome = crew.replace(/^crews[\\/]+/, '');
|
|
55
|
+
if (!dentroDoProjeto(base, nome)) return { erro: MSG.foraDoProjeto(limparTexto(crew)) };
|
|
56
|
+
const pasta = path.resolve(base, nome);
|
|
57
|
+
if (path.dirname(pasta) !== base || !ehPasta(pasta)) return { erro: MSG.crewNaoEncontrada(limparTexto(crew)) };
|
|
58
|
+
return { pasta, crew: path.basename(pasta) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Confere a preferência, lê o elenco e o estado, decide e grava. Devolve a linha `ESTADO:`. */
|
|
62
|
+
async function responder({ raiz, pasta, crew }, args, deps) {
|
|
63
|
+
if (!(await escritorioLigado(raiz))) return ignorado(MOTIVO.desligado);
|
|
64
|
+
const agora = (deps.agora ?? (() => new Date().toISOString()))();
|
|
65
|
+
const evento = { tipo: args.evento, ...args.opcoes, crew, elenco: await lerElenco(pasta), agora };
|
|
66
|
+
const arquivo = path.join(pasta, 'state.json');
|
|
67
|
+
const decisao = decidir(await lerEstado(arquivo), evento);
|
|
68
|
+
if (decisao.ignorado) return ignorado(decisao.ignorado);
|
|
69
|
+
if (!(await gravarEstado(arquivo, decisao.estado, deps))) return ignorado(MOTIVO.naoGravou);
|
|
70
|
+
return decisao.recriado ? RECRIADO : OK;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {string[]} argv
|
|
75
|
+
* @param {object} [deps] `cwd` (a pasta do projeto) e `escrever`; SÓ PARA TESTE: `agora` (a hora,
|
|
76
|
+
* em ISO), `renomear` e `esperar` (a troca de nome e a espera da gravação)
|
|
77
|
+
* @returns {Promise<number>} 0 = a linha `ESTADO:` saiu · 1 = erro de uso (a linha de uso e o
|
|
78
|
+
* motivo, sem linha `ESTADO:`, nada escrito)
|
|
79
|
+
*/
|
|
80
|
+
export async function main(argv, deps = {}) {
|
|
81
|
+
const { cwd = process.cwd(), escrever = (s) => process.stdout.write(`${s}\n`) } = deps;
|
|
82
|
+
const args = lerArgs(argv);
|
|
83
|
+
const local = localizar(cwd, args);
|
|
84
|
+
if (local.erro) {
|
|
85
|
+
escrever(USO);
|
|
86
|
+
escrever(local.erro);
|
|
87
|
+
return 1;
|
|
88
|
+
}
|
|
89
|
+
// O escritório nunca para a execução: o que der errado daqui em diante vira ESTADO:IGNORADO.
|
|
90
|
+
escrever(await responder({ raiz: cwd, ...local }, args, deps).catch((erro) => ignorado(MOTIVO.inesperado(erro))));
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (ehPrincipal(import.meta.url)) {
|
|
95
|
+
main(process.argv.slice(2)).then((code) => { process.exitCode = code; });
|
|
96
|
+
}
|
|
@@ -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.
|