create-arriero 0.0.0-stage → 0.1.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +29 -3
  3. package/index.mjs +261 -0
  4. package/package.json +27 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 picito
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,29 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # create-arriero
2
+
3
+ Asistente que arma el flujo gobernado de Arriero en un proyecto nuevo o existente: escribe la configuración, el
4
+ registro de lecciones, los roles de agentes y los git hooks.
5
+
6
+ ## Instalación
7
+
8
+ ```
9
+ npm create arriero@latest
10
+ # o
11
+ npx create-arriero
12
+ ```
13
+
14
+ Después, en el repo de docs del proyecto, la CLI queda como dependencia de desarrollo:
15
+
16
+ ```
17
+ npm install -D arriero
18
+ ```
19
+
20
+ ## Requisitos
21
+
22
+ - Node 20 o superior.
23
+ - git.
24
+ - La CLI de [OpenSpec](https://github.com/Fission-AI/OpenSpec).
25
+
26
+ ## Más información
27
+
28
+ El detalle del flujo, la configuración (`arriero.config.json`) y los comandos está en el README del repositorio de
29
+ Arriero.
package/index.mjs ADDED
@@ -0,0 +1,261 @@
1
+ #!/usr/bin/env node
2
+ // Asistente: arma el flujo gobernado de arriero en un proyecto nuevo o existente.
3
+ // Escribe arriero.config.json en el repo de docs, crea el registro de lecciones e instala los git hooks
4
+ // en los repos de código (o en el propio repo, en un proyecto de un solo repo). No toca nada sin confirmación.
5
+ //
6
+ // Sin terminal (CI, un agente), `--si` responde cada pregunta con su flag o con el valor por defecto:
7
+ // npx create-arriero --si --docs ../demo-docs --repos demo-api,demo-web --herramientas claude
8
+ // npx create-arriero --si --unico --docs . (proyecto de un solo repo: docs y código juntos)
9
+ // flags: --docs --proyecto --repos --integracion --produccion --registro --herramientas --unico --sin-carril --reemplazar
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { parseArgs } from 'node:util';
13
+ import { execSync } from 'node:child_process';
14
+ import * as p from '@clack/prompts';
15
+ import {
16
+ ARCHIVO, DEFECTOS, conDefectos, validarConfig, cargarContexto, git, instalarHooks, PLANTILLA_REGISTRO,
17
+ STACKS, HERRAMIENTAS, detectarStack, leerManifiestos, planAgentes, escribirAgentes,
18
+ } from 'arriero';
19
+
20
+ const salir = (v) => { if (p.isCancel(v)) { p.cancel('Sin cambios.'); process.exit(0); } return v; };
21
+ const lista = (t) => t.split(',').map((x) => x.trim()).filter(Boolean);
22
+
23
+ const { values: F } = parseArgs({
24
+ options: Object.fromEntries([
25
+ ...['docs', 'proyecto', 'repos', 'integracion', 'produccion', 'registro', 'herramientas'].map((k) => [k, { type: 'string' }]),
26
+ ...['si', 'unico', 'sin-carril', 'reemplazar'].map((k) => [k, { type: 'boolean' }]),
27
+ ]),
28
+ });
29
+
30
+ /**
31
+ * Las preguntas del asistente. Con `--si` no pregunta: usa el valor del flag (si lo hay) o el inicial,
32
+ * y lo valida igual que en la terminal.
33
+ */
34
+ const preguntar = {
35
+ async text(o, flag) {
36
+ if (!F.si) return p.text(o);
37
+ const v = flag ?? o.initialValue ?? '';
38
+ const error = o.validate?.(v);
39
+ if (error) { p.cancel(`${o.message}: ${error} ("${v}")`); process.exit(1); }
40
+ p.log.step(`${o.message}: ${v || '—'}`);
41
+ return v;
42
+ },
43
+ async confirm(o, flag) {
44
+ if (!F.si) return p.confirm(o);
45
+ const v = flag ?? o.initialValue ?? true;
46
+ p.log.step(`${o.message} ${v ? 'sí' : 'no'}`);
47
+ return v;
48
+ },
49
+ async select(o, flag) {
50
+ if (!F.si) return p.select(o);
51
+ const v = flag ?? o.initialValue ?? o.options[0].value;
52
+ p.log.step(`${o.message}: ${o.options.find((x) => x.value === v)?.label ?? v}`);
53
+ return v;
54
+ },
55
+ async multiselect(o, flag) {
56
+ if (!F.si) return p.multiselect(o);
57
+ const v = flag ?? o.initialValues ?? [];
58
+ p.log.step(`${o.message}: ${v.join(', ') || '—'}`);
59
+ return v;
60
+ },
61
+ };
62
+
63
+ /** Comandos de verificación sugeridos a partir de los scripts del package.json del repo. */
64
+ function verificarSugerido(dir) {
65
+ try {
66
+ const { scripts = {} } = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
67
+ return ['lint', 'test', 'build'].filter((s) => scripts[s]).map((s) => ({ nombre: s, comando: s === 'test' ? 'npm test --silent' : `npm run ${s}` }));
68
+ } catch { return []; }
69
+ }
70
+
71
+ /** Comandos de verificación y tecnologías de un repo con código (`dir`), preguntados con su `nombre`. */
72
+ async function datosDeCodigo(dir, nombre) {
73
+ const comandos = salir(await preguntar.text({
74
+ message: `Comandos que ${nombre} debe pasar al verificar (separados por coma; vacío = ninguno)`,
75
+ initialValue: verificarSugerido(dir).map((v) => v.comando).join(', '),
76
+ }));
77
+ const detectado = detectarStack(leerManifiestos(dir));
78
+ const stack = salir(await preguntar.multiselect({
79
+ message: `Tecnologías de ${nombre} (detectadas: ${detectado.join(', ') || 'ninguna'}). Arman sus roles; ajusta si hace falta`,
80
+ options: STACKS.map((s) => ({ value: s.id, label: s.nombre, hint: s.capa ?? undefined })),
81
+ initialValues: detectado,
82
+ required: false,
83
+ }));
84
+ return { stack, verificar: lista(comandos || '').map((comando) => ({ nombre: comando.replace(/^npm (run )?/, '').replace(/ --silent$/, ''), comando })) };
85
+ }
86
+
87
+ // Instrucciones y permisos de los agentes de IA, de cualquier herramienta: un agente no se afloja sus propias reglas.
88
+ const AGENTES = { regla: 'instrucciones y permisos de los agentes', rutas: ['AGENTS.md', '**/AGENTS.md', 'agents/**', 'CLAUDE.md', '.claude/**', 'GEMINI.md', '.gemini/**', '.cursor/**', '.cursorrules', '.windsurf/**', '.clinerules', '.github/copilot-instructions.md'] };
89
+ const PROHIBIDO_CODIGO = [{ regla: 'dependencias, entorno y CI', rutas: ['package.json', 'package-lock.json', '.env*', '.githooks/**', '.github/**'] }, AGENTES];
90
+ // Rutas de riesgo de cada repo de código: nunca entran por el carril rápido ni las cubre la preautorización.
91
+ const RIESGO_CODIGO = [
92
+ { regla: 'base de datos', rutas: ['**/migrations/**', '**/migraciones/**', '**/*.sql', '**/schema.prisma'] },
93
+ { regla: 'autenticación y permisos', rutas: ['**/auth/**', '**/autenticacion/**'] },
94
+ { regla: 'pagos', rutas: ['**/pagos/**', '**/payments/**', '**/billing/**', '**/facturacion/**'] },
95
+ ];
96
+ const PROHIBIDO_DOCS = [{ regla: 'flujo, decisiones y reglas', rutas: [ARCHIVO, 'openspec/specs/**', 'releases/**'] }, AGENTES];
97
+
98
+ async function main() {
99
+ p.intro('create-arriero · flujo gobernado para agentes de IA');
100
+ if (F.unico && F.repos) { p.cancel('--repos no aplica en un proyecto de un solo repo'); process.exit(1); }
101
+
102
+ const docs = path.resolve(salir(await preguntar.text({
103
+ message: 'Carpeta del repo de docs, o del repo único (donde viven openspec/ y arriero.config.json)',
104
+ initialValue: process.cwd(),
105
+ validate: (v) => (v && fs.existsSync(v) ? undefined : 'No existe esa carpeta'),
106
+ }, F.docs)));
107
+ const raiz = path.dirname(docs);
108
+ if (!git(docs, 'rev-parse --git-dir')) {
109
+ if (!salir(await preguntar.confirm({ message: `${docs} no es un repo git. ¿Lo inicializo (git init)?` }))) return p.cancel('El repo de docs tiene que ser un repo git.');
110
+ execSync('git init -q', { cwd: docs });
111
+ }
112
+ const previo = fs.existsSync(path.join(docs, ARCHIVO));
113
+ if (previo && !salir(await preguntar.confirm({ message: `Ya existe ${ARCHIVO}. ¿Lo reemplazo?`, initialValue: false }, F.reemplazar))) return p.cancel('Sin cambios.');
114
+
115
+ const hermanas = fs.readdirSync(raiz, { withFileTypes: true })
116
+ .filter((e) => e.isDirectory() && e.name !== path.basename(docs) && fs.existsSync(path.join(raiz, e.name, '.git')))
117
+ .map((e) => e.name);
118
+ // Lo habitual son varios repos (docs, BE, FE): es el valor inicial aunque aún no haya hermanas, porque el repo de
119
+ // docs suele crearse antes que los de código. El modo único se elige a propósito.
120
+ const unico = salir(await preguntar.select({
121
+ message: '¿Cómo está organizado el proyecto?',
122
+ options: [
123
+ { value: 'varios', label: `Varios repos: docs y repos de código hermanos en ${raiz}, p. ej. BE y FE` },
124
+ { value: 'unico', label: 'Un solo repo: docs y código juntos' },
125
+ ],
126
+ initialValue: F.unico ? 'unico' : 'varios',
127
+ }, F.unico ? 'unico' : undefined)) === 'unico';
128
+
129
+ const proyecto = salir(await preguntar.text({ message: 'Nombre del proyecto', initialValue: path.basename(docs).replace(/[-_]?docs$/i, '') || path.basename(docs) }, F.proyecto));
130
+ const claveDocs = salir(await preguntar.text({
131
+ message: unico ? 'Clave del repo (mayúsculas)' : 'Clave del repo de docs (mayúsculas)',
132
+ initialValue: unico ? 'APP' : 'DOCS',
133
+ validate: (v) => (/^[A-Z][A-Z0-9_]*$/.test(v) ? undefined : 'Mayúsculas, números o _'),
134
+ }));
135
+ const repos = { [claveDocs]: unico
136
+ ? { tipo: 'unico', carpetas: [path.basename(docs)], ...await datosDeCodigo(docs, path.basename(docs)) }
137
+ : { tipo: 'docs', carpetas: [path.basename(docs)] } };
138
+
139
+ const noEsta = F.repos ? lista(F.repos).filter((r) => !hermanas.includes(r)) : [];
140
+ if (noEsta.length) { p.cancel(`--repos: ${noEsta.join(', ')} no es un repo git hermano en ${raiz}`); process.exit(1); }
141
+ const elegidas = !unico && hermanas.length
142
+ ? salir(await preguntar.multiselect({ message: `Repos de código (carpetas hermanas en ${raiz})`, options: hermanas.map((h) => ({ value: h, label: h })), required: false }, F.repos && lista(F.repos)))
143
+ : [];
144
+ for (const carpeta of elegidas) {
145
+ const sugerida = carpeta.replace(/^.*[-_]/, '').toUpperCase().replace(/[^A-Z0-9_]/g, '').slice(0, 12) || 'APP';
146
+ const clave = salir(await preguntar.text({
147
+ message: `Clave de ${carpeta}`,
148
+ initialValue: repos[sugerida] ? `${sugerida}2` : sugerida,
149
+ validate: (v) => (!/^[A-Z][A-Z0-9_]*$/.test(v) ? 'Mayúsculas, números o _' : repos[v] ? 'Clave repetida' : undefined),
150
+ }));
151
+ repos[clave] = { carpetas: [carpeta], ...await datosDeCodigo(path.join(raiz, carpeta), carpeta) };
152
+ }
153
+
154
+ const integracion = salir(await preguntar.text({ message: 'Rama de integración (a la que entra cada slice aprobado)', initialValue: DEFECTOS.ramas.integracion }, F.integracion));
155
+ const produccion = lista(salir(await preguntar.text({ message: 'Ramas de producción (separadas por coma)', initialValue: DEFECTOS.ramas.produccion.join(', ') }, F.produccion)));
156
+ // El carril rápido aún no está disponible en un proyecto de un solo repo.
157
+ const conCarril = !unico && salir(await preguntar.confirm({ message: '¿Activar el carril rápido para cambios pequeños (textos, estilos)?', initialValue: true }, F['sin-carril'] ? false : undefined));
158
+ const registro = salir(await preguntar.text({ message: 'Registro de lecciones (ruta dentro del repo de docs)', initialValue: DEFECTOS.lecciones.registro }, F.registro));
159
+ const herramientas = salir(await preguntar.multiselect({
160
+ message: 'Herramientas de IA del equipo que necesitan adaptador (AGENTS.md ya lo leen Codex, Cursor, Copilot y otras)',
161
+ options: Object.entries(HERRAMIENTAS).map(([id, h]) => ({ value: id, label: h.nombre })),
162
+ required: false,
163
+ }, F.herramientas && lista(F.herramientas)));
164
+
165
+ const cfg = {
166
+ $schema: './node_modules/arriero/arriero.config.schema.json',
167
+ proyecto,
168
+ repos,
169
+ ramas: { integracion, produccion, prefijos: DEFECTOS.ramas.prefijos },
170
+ lecciones: { registro },
171
+ agentes: { herramientas },
172
+ // En modo único el repo único es el de código.
173
+ riesgo: Object.fromEntries(Object.keys(repos).filter((k) => unico || k !== claveDocs).map((k) => [k, RIESGO_CODIGO])),
174
+ ...(conCarril && {
175
+ carrilRapido: {
176
+ limites: DEFECTOS.limitesCarril,
177
+ prohibido: Object.fromEntries(Object.keys(repos).map((k) => [k, k === claveDocs ? PROHIBIDO_DOCS : PROHIBIDO_CODIGO])),
178
+ },
179
+ }),
180
+ };
181
+ const errores = validarConfig(conDefectos(cfg));
182
+ if (errores.length) return p.cancel(`La configuración no es válida:\n - ${errores.join('\n - ')}`);
183
+
184
+ const codigo = Object.keys(repos).filter((k) => k !== claveDocs);
185
+ p.note(`${Object.entries(repos).map(([k, r]) => `${k.padEnd(6)} ${r.carpetas[0]}${r.tipo === 'docs' ? ' (docs)' : `${r.tipo === 'unico' ? ' (un solo repo)' : ''} · ${r.stack.join(', ') || 'sin stack'}`}`).join('\n')}\n\nIntegración: ${integracion} · Producción: ${produccion.join(', ')}\nCarril rápido: ${conCarril ? 'sí' : 'no'} · Lecciones: ${registro}\nAdaptadores: ${herramientas.map((h) => HERRAMIENTAS[h].nombre).join(', ') || 'solo AGENTS.md'}`, 'Resumen');
186
+ const confirmacion = unico
187
+ ? `¿Escribo ${ARCHIVO} y los roles de agentes, e instalo los git hooks en este repo? (no reemplazo archivos que ya existan)`
188
+ : `¿Escribo ${ARCHIVO} y los roles de agentes${codigo.length ? ', e instalo los git hooks en los repos de código' : ''}? (no reemplazo archivos que ya existan)`;
189
+ if (!salir(await preguntar.confirm({ message: confirmacion }))) return p.cancel('Sin cambios.');
190
+
191
+ fs.writeFileSync(path.join(docs, ARCHIVO), JSON.stringify(cfg, null, 2) + '\n');
192
+ p.log.success(`${ARCHIVO} escrito`);
193
+ const fRegistro = path.join(docs, registro);
194
+ if (!fs.existsSync(fRegistro)) {
195
+ fs.mkdirSync(path.dirname(fRegistro), { recursive: true });
196
+ fs.writeFileSync(fRegistro, PLANTILLA_REGISTRO);
197
+ p.log.success(`${registro} creado`);
198
+ }
199
+
200
+ // En modo único los hooks van en el propio repo (ARRIERO_DOCS=".").
201
+ const destinos = unico
202
+ ? [{ dir: docs, nombre: path.basename(docs) }]
203
+ : codigo.map((k) => ({ dir: path.join(raiz, repos[k].carpetas[0]), nombre: repos[k].carpetas[0] }));
204
+ for (const { dir, nombre } of destinos) {
205
+ const { instalados, saltados, otroHooksPath, errores } = unico ? instalarHooks(dir, docs, integracion) : instalarHooks(dir, docs);
206
+ if (otroHooksPath) { p.log.warn(`${nombre}: core.hooksPath ya es "${otroHooksPath}"; no instalé los hooks. Llama a "arriero hook <modo>" desde los tuyos.`); continue; }
207
+ if (instalados.length) p.log.success(errores.length
208
+ ? `${nombre}: hooks escritos en .githooks (${instalados.join(', ')})`
209
+ : `${nombre}: hooks instalados (${instalados.join(', ')}) y en el stage con bit de ejecución; .gitattributes con LF para .githooks`);
210
+ for (const e of errores) p.log.error(`${nombre}: ${e}`);
211
+ if (saltados.length) p.log.warn(`${nombre}: ya tenía ${saltados.join(', ')} en .githooks; no los reemplacé. Agrega "arriero hook <modo>" a mano.`);
212
+ }
213
+
214
+ const { creados, existentes } = escribirAgentes(planAgentes(cargarContexto({ desde: docs, env: {} })));
215
+ p.log.success(`Agentes: ${creados.length} archivos creados (AGENTS.md, agents/*.md${herramientas.length ? ' y adaptadores' : ''})`);
216
+ if (existentes.length) p.log.info(`Ya existían y no los toqué: ${existentes.join(', ')}`);
217
+
218
+ const q = (s) => (/\s/.test(s) ? `"${s}"` : s); // rutas con espacios, para copiar y pegar
219
+ const adaptadores = herramientas.map((h) => HERRAMIENTAS[h].raiz.archivo);
220
+ const instalacion = ['.githooks', '.gitattributes', 'AGENTS.md', 'agents', ...adaptadores, ...(herramientas.includes('claude') ? ['.claude'] : [])];
221
+ const sinVerificar = `git commit --no-verify -m "chore: instalar arriero" # única excepción: los hooks rechazan commits en ${integracion}`;
222
+
223
+ if (unico) {
224
+ const prefijo = DEFECTOS.ramas.prefijos[0];
225
+ // Sin commits, HEAD ya puede ser la rama de integración (git init -b develop): ahí no hace falta cambiar de rama.
226
+ const enIntegracion = git(docs, 'symbolic-ref --short HEAD') === integracion;
227
+ const existe = git(docs, `rev-parse --verify --quiet refs/heads/${integracion}`) !== null;
228
+ const pasos = [
229
+ `cd ${q(docs)}`,
230
+ 'npm install -D arriero # los hooks lo buscan en node_modules de este repo',
231
+ ...(git(docs, 'check-ignore -q node_modules') === null ? ['agregar node_modules/ al .gitignore'] : []),
232
+ ...(fs.existsSync(path.join(docs, 'openspec')) ? [] : ['openspec init # el flujo usa OpenSpec para las propuestas']),
233
+ ...(enIntegracion ? [] : [`git switch ${existe ? '' : '-c '}${integracion}`]),
234
+ 'completar "## Propio del proyecto" en AGENTS.md y en cada agents/*.md',
235
+ `git add ${[ARCHIVO, q(registro), ...instalacion].join(' ')}`,
236
+ sinVerificar,
237
+ `git switch -c ${prefijo}/<cambio> ${integracion} # cada slice vive en su rama`,
238
+ 'arriero # guía del día a día',
239
+ ];
240
+ p.note(pasos.join('\n'), 'Siguientes pasos');
241
+ return p.outro('Listo. Regla del camino: nada se programa sin una propuesta aprobada.');
242
+ }
243
+
244
+ const pasos = [
245
+ `cd ${q(docs)}`,
246
+ 'npm install -D arriero # los hooks lo buscan en node_modules del repo de docs',
247
+ ...(fs.existsSync(path.join(docs, 'openspec')) ? [] : ['openspec init # el flujo usa OpenSpec para las propuestas']),
248
+ 'completar "## Propio del proyecto" en cada agents/*.md',
249
+ // Con claude, docs lleva .claude/settings.json del orquestador (acceso a los repos de código y permisos del loop).
250
+ `git add ${[ARCHIVO, q(registro), 'AGENTS.md', ...adaptadores, ...(herramientas.includes('claude') ? ['.claude'] : [])].join(' ')}`,
251
+ 'git commit -m "chore: instalar arriero"',
252
+ // Rutas relativas al repo de docs: los repos de código son sus hermanos, así que cada línea vale tras la anterior.
253
+ ...codigo.map((k) => `cd ${q(`../${repos[k].carpetas[0]}`)} && git add ${instalacion.join(' ')} && ${sinVerificar}`),
254
+ ...(codigo.length ? [`cd ${q(`../${path.basename(docs)}`)}`] : []),
255
+ 'arriero # guía del día a día',
256
+ ];
257
+ p.note(pasos.join('\n'), 'Siguientes pasos');
258
+ p.outro('Listo. Regla del camino: nada se programa sin una propuesta aprobada.');
259
+ }
260
+
261
+ await main();
package/package.json CHANGED
@@ -1,6 +1,29 @@
1
1
  {
2
2
  "name": "create-arriero",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Asistente que arma el flujo gobernado de Arriero en un proyecto nuevo o existente.",
5
+ "keywords": [
6
+ "agents",
7
+ "ai",
8
+ "workflow",
9
+ "openspec",
10
+ "git-hooks",
11
+ "create"
12
+ ],
13
+ "author": "picito <csanchezrive@gmail.com>",
14
+ "type": "module",
15
+ "bin": {
16
+ "create-arriero": "index.mjs"
17
+ },
18
+ "files": [
19
+ "index.mjs"
20
+ ],
21
+ "engines": {
22
+ "node": ">=20"
23
+ },
24
+ "license": "MIT",
25
+ "dependencies": {
26
+ "@clack/prompts": "^1.8.1",
27
+ "arriero": "0.1.0"
28
+ }
29
+ }