@ancleto/spec 0.6.29 → 0.6.31

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/README.md CHANGED
@@ -148,8 +148,12 @@ ancleto projects # lista (o `ancleto list --projects`)
148
148
  ancleto projects info # estado del proyecto actual
149
149
  ancleto projects scan "D:/Repos D/Proyectos" # descubre y registra los que ya existen
150
150
  ancleto projects prune # saca del registro los borrados/movidos
151
+ ancleto projects update # actualiza los desactualizados (selector interactivo)
152
+ ancleto projects update --all # los actualiza todos sin preguntar
151
153
  ```
152
154
 
155
+ `projects update` detecta qué proyectos quedaron atrás de la versión del CLI y te deja elegir cuáles actualizar (espacio para marcar, `a` para todos, enter para confirmar). Actualiza cada uno en un proceso aislado, así que un proyecto roto no afecta a los demás. Es el equivalente cómodo de `cd <ruta> && ancleto update` para cada uno. En una terminal sin TTY lista los desactualizados y te recuerda usar `--all`.
156
+
153
157
  La lista muestra versión instalada, origen (por proyecto vs agentes globales), tier y agente:
154
158
 
155
159
  ```
@@ -229,6 +233,8 @@ ancleto install [--project <dir>] [--tier <nivel>] [--agent <nombre>] [--no-mcp]
229
233
  # Instala agentes, skills y templates
230
234
 
231
235
  ancleto update # Re-instala la última versión sobre lo existente
236
+ # Parado en un proyecto con .ancletorc opera sobre ESE proyecto;
237
+ # usá --global para forzar el alcance global
232
238
 
233
239
  ancleto upgrade # Re-aplica templates y skills respetando tus personalizaciones
234
240
 
@@ -248,6 +254,9 @@ ancleto projects prune [--dry-run] [--json]
248
254
  # Quita del registro los proyectos borrados o movidos
249
255
  ancleto projects info [ruta] [--json]
250
256
  # Estado de un proyecto (versión, memoria, seed, changes activos)
257
+ ancleto projects update [--all] [--json]
258
+ # Actualiza los desactualizados (selector interactivo);
259
+ # --all sin preguntar. Igual que "cd <ruta> && ancleto update"
251
260
 
252
261
  ancleto doctor # Diagnostica el entorno (Node, node:sqlite, opencode.json)
253
262
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ancleto/spec",
3
- "version": "0.6.29",
3
+ "version": "0.6.31",
4
4
  "description": "Orquestador SDD liviano con subagentes optimizados para costo/tokens",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/index.js CHANGED
@@ -13,7 +13,7 @@ import { memoryDoctor } from '../core/memory/doctor.js'
13
13
  import { serveMemoryMcp } from '../core/memory/mcp-server.js'
14
14
  import { writeDiscoveryMap } from '../core/discovery.js'
15
15
  import { readProjectTier, buildRepomixArgs, tierTokenBudget } from '../core/repomix-tier.js'
16
- import { showBanner, selectOption, stopBanner } from './ui.js'
16
+ import { showBanner, selectOption, selectMultiple, stopBanner } from './ui.js'
17
17
  import { tierModels, gratisModel, envGratisModel, isKnownGratisModel, MUSE_SPARK_MODEL, GRATIS_FALLBACK_MODEL } from '../core/tier-models.js'
18
18
 
19
19
  const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -34,6 +34,8 @@ Uso:
34
34
  ancleto install --tier <nivel> normal | minimo | gratis (wizard interactivo en TTY)
35
35
  ancleto install --agent <nombre> opencode | vscode | antigravity | cursor | roo (wizard si no esta guardado)
36
36
  ancleto update [--project <dir>] Alias de install (re-instala sobre lo existente)
37
+ Parado en un proyecto con .ancletorc opera sobre ese proyecto;
38
+ usa --global para forzar el alcance global
37
39
  ancleto upgrade [--agent <nombre>] Re-aplica templates (LOCKED) y skills sobre el proyecto actual
38
40
  ancleto init [--with-azure] [--agent <nombre>] [--tier <nivel>]
39
41
  Crea .ancletorc en el repositorio actual
@@ -54,6 +56,9 @@ Uso:
54
56
  Quita del registro los proyectos borrados o movidos
55
57
  ancleto projects info [ruta] [--json]
56
58
  Estado de un proyecto (version, memoria, seed, changes activos)
59
+ ancleto projects update [--all] [--json]
60
+ Actualiza los proyectos desactualizados (selector interactivo;
61
+ --all sin preguntar); equivalente a "cd <ruta> && ancleto update"
57
62
  ancleto list --projects [--json] Alias de "ancleto projects list"
58
63
  ancleto memory context [--scope X] [--out file]
59
64
  Imprime/escribe el bloque <ProjectMemoryRules> (reglas activas)
@@ -369,15 +374,81 @@ async function projectsInfo(target, { asJson, currentVersion }) {
369
374
  if (info.outdated) console.log(c.dim(`\n actualizá con: cd "${info.path}" && ancleto update`))
370
375
  }
371
376
 
377
+ // Actualiza proyectos desde el registro. Spawnea `ancleto update` por proyecto
378
+ // (proceso aislado: un fallo no contamina a los demas y la salida queda por proyecto).
379
+ async function projectsUpdate(reg, { asJson, all }) {
380
+ const currentVersion = await packageVersion()
381
+ const rows = await Promise.all(Object.values(reg.projects).map(projectStatus))
382
+ const dead = rows.filter((r) => !r.exists)
383
+ const outdated = rows.filter((r) => r.exists && r.version && r.version !== currentVersion)
384
+ const hintDead = () => {
385
+ if (dead.length) console.log(c.dim(` (${dead.length} entrada(s) muerta(s) en el registro: corre \`ancleto projects prune\`)`))
386
+ }
387
+
388
+ if (outdated.length === 0) {
389
+ if (asJson) console.log(JSON.stringify({ ok: true, cliVersion: currentVersion, updated: [], dead: dead.map((r) => r.path), reason: 'nada desactualizado' }, null, 2))
390
+ else {
391
+ console.log(c.green('ancleto: todos los proyectos activos estan al dia'))
392
+ hintDead()
393
+ }
394
+ return
395
+ }
396
+
397
+ let targets
398
+ if (all) {
399
+ targets = outdated
400
+ } else if (!process.stdout.isTTY) {
401
+ if (asJson) console.log(JSON.stringify({ ok: true, cliVersion: currentVersion, outdated: outdated.map((r) => ({ path: r.path, version: r.version })), dead: dead.map((r) => r.path), action: 'usa --all para actualizar sin interaccion' }, null, 2))
402
+ else {
403
+ console.log(`${c.bold('ancleto: proyectos desactualizados')} ${c.dim(`(${outdated.length})`)}`)
404
+ for (const r of outdated) console.log(` ${c.red(r.version)} → ${c.green(currentVersion)} ${r.path}`)
405
+ console.log(c.dim('\n sin TTY no puedo preguntar: corre `ancleto projects update --all` para actualizarlos todos'))
406
+ hintDead()
407
+ }
408
+ return
409
+ } else {
410
+ if (dead.length) {
411
+ console.log(c.dim(`ancleto: ${dead.length} entrada(s) muerta(s) en el registro (ignoradas; limpialas con \`ancleto projects prune\`)`))
412
+ }
413
+ const labels = outdated.map((r) => `${padVisible(r.path.split('/').filter(Boolean).pop() || r.path, 22)} ${c.red(r.version)} → ${c.green(currentVersion)} ${c.dim(r.path)}`)
414
+ const { cancelled, indices } = await selectMultiple(`Proyectos a actualizar (${outdated.length} desactualizados)`, labels, outdated.map((_, i) => i))
415
+ if (cancelled || indices.length === 0) {
416
+ console.log(c.dim('ancleto: nada seleccionado, no se actualizo ningun proyecto'))
417
+ return
418
+ }
419
+ targets = indices.map((i) => outdated[i])
420
+ }
421
+
422
+ const selfCli = fileURLToPath(import.meta.url)
423
+ const results = []
424
+ console.log('')
425
+ for (const r of targets) {
426
+ process.stdout.write(` ${c.cyan('→')} ${r.path} ... `)
427
+ const res = spawnSync(process.execPath, [selfCli, 'update'], { cwd: r.path, encoding: 'utf8', env: process.env })
428
+ const okRun = res.status === 0
429
+ const out = `${res.stdout || ''}${res.stderr || ''}`.trim()
430
+ console.log(okRun ? c.green('ok') : c.red('fallo'))
431
+ results.push({ path: r.path, from: r.version, to: okRun ? currentVersion : null, ok: okRun, output: out.split('\n').slice(-3).join('\n') })
432
+ if (!okRun) console.log(c.dim(` ${out.split('\n').slice(-1)[0] || 'sin salida'}`))
433
+ }
434
+ const okCount = results.filter((x) => x.ok).length
435
+ console.log('')
436
+ console.log(`ancleto: ${okCount}/${results.length} proyectos actualizados a v${currentVersion}`)
437
+ if (asJson) console.log(JSON.stringify({ ok: okCount === results.length, cliVersion: currentVersion, updated: results }, null, 2))
438
+ }
439
+
372
440
  async function projectsCommand(args) {
373
441
  const sub = args.find((a) => !a.startsWith('-')) || 'list'
374
442
  const flags = args.filter((a) => a.startsWith('-'))
375
443
  const asJson = flags.includes('--json')
376
444
  const dryRun = flags.includes('--dry-run')
445
+ const all = flags.includes('--all')
377
446
  const currentVersion = await packageVersion()
378
447
  switch (sub) {
379
448
  case 'list': {
380
- await projectsList(await readProjectsRegistry(), { asJson, currentVersion })
449
+ const reg = await readProjectsRegistry()
450
+ await projectsList(reg, { asJson, currentVersion })
451
+ if (flags.includes('--update')) await projectsUpdate(reg, { asJson, all })
381
452
  break
382
453
  }
383
454
  case 'scan': {
@@ -396,9 +467,13 @@ async function projectsCommand(args) {
396
467
  await projectsInfo(target, { asJson, currentVersion })
397
468
  break
398
469
  }
470
+ case 'update': {
471
+ await projectsUpdate(await readProjectsRegistry(), { asJson, all })
472
+ break
473
+ }
399
474
  default:
400
475
  console.error(`ancleto: subcomando desconocido: projects ${sub}`)
401
- console.error('ancleto: uso: ancleto projects [list|scan <raiz>|prune|info <ruta>] [--json] [--dry-run]')
476
+ console.error('ancleto: uso: ancleto projects [list|scan <raiz>|prune|info <ruta>|update] [--update] [--all] [--json] [--dry-run]')
402
477
  process.exit(1)
403
478
  }
404
479
  }
@@ -716,22 +791,28 @@ async function copyTemplates(projectDir) {
716
791
 
717
792
  async function install(args) {
718
793
  const pi = args.indexOf('--project')
719
- const project = pi >= 0 ? args[pi + 1] : null
794
+ const explicitProject = pi >= 0 ? args[pi + 1] : null
720
795
  const withMcp = !args.includes('--no-mcp')
721
796
  let mcpMap = withMcp ? buildDefaultMcp({ withEngram: args.includes('--with-engram') }) : {}
722
797
 
723
798
  const agentFlag = scanAgentFlag(args)
724
799
  let tier = scanTierFlag(args)
725
800
 
726
- if (project) {
727
- const dir = resolve(project)
728
- if (!(await exists(dir))) {
729
- console.error(`ancleto: el directorio no existe: ${dir}`)
801
+ let projectDir
802
+ if (explicitProject) {
803
+ projectDir = resolve(explicitProject)
804
+ if (!(await exists(projectDir))) {
805
+ console.error(`ancleto: el directorio no existe: ${projectDir}`)
730
806
  process.exit(1)
731
807
  }
808
+ } else if (!args.includes('--global') && (await exists(join(process.cwd(), '.ancletorc')))) {
809
+ // Estar parado en un proyecto ancleto implica operar sobre ese proyecto.
810
+ // `--global` fuerza el alcance global cuando el repo tambien tiene .ancletorc.
811
+ projectDir = process.cwd()
812
+ } else {
813
+ projectDir = null
732
814
  }
733
815
 
734
- const projectDir = project ? resolve(project) : null
735
816
  const target = projectDir ? join(projectDir, '.opencode') : globalConfigDir()
736
817
  const existingRc = projectDir ? await readAncletorc(projectDir) : null
737
818
  const storedTier = (await exists(tierStatePath(target)))
package/src/cli/ui.js CHANGED
@@ -179,3 +179,79 @@ export async function selectOption(message, options, initial = 0) {
179
179
  if (result === SIGINT) process.exit(0)
180
180
  return result === undefined ? options[start] : result
181
181
  }
182
+
183
+ // Seleccion multiple: flechas para moverse, espacio para marcar/desmarcar,
184
+ // `a` para todos/ninguno, enter para confirmar. Devuelve indices marcados.
185
+ export async function selectMultiple(message, options, preselected = []) {
186
+ const stdin = process.stdin
187
+ const out = process.stdout
188
+ if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
189
+ return { cancelled: false, indices: [...preselected].sort((a, b) => a - b) }
190
+ }
191
+
192
+ const selected = new Set(preselected)
193
+ let index = 0
194
+ let rendered = 0
195
+ const mark = (i) => (selected.has(i) ? `${CYAN}◉${RESET}` : `${BOLD}◯${RESET}`)
196
+ const draw = () => {
197
+ if (rendered > 0) out.write(`\x1b[${rendered}A`)
198
+ let buf = ''
199
+ for (let i = 0; i < options.length; i++) {
200
+ const pointer = i === index ? `${CYAN}❯${RESET}` : ' '
201
+ const label = i === index ? `${BOLD}${options[i]}${RESET}` : options[i]
202
+ buf += `\x1b[2K ${pointer} ${mark(i)} ${label}\n`
203
+ }
204
+ buf += `\x1b[2K ${'\x1b[2m'}espacio marca · a todos · enter confirma${RESET}\n`
205
+ out.write(buf)
206
+ rendered = options.length + 1
207
+ }
208
+
209
+ const SIGINT = Symbol('SIGINT')
210
+ let resolveKey
211
+ const key = new Promise((resolve) => { resolveKey = resolve })
212
+ const onData = (chunk) => {
213
+ const s = chunk.toString('utf8')
214
+ if (s === '\u0003') {
215
+ stopBanner()
216
+ resolveKey(SIGINT)
217
+ } else if (s === '\u001b[A') {
218
+ index = (index - 1 + options.length) % options.length
219
+ draw()
220
+ } else if (s === '\u001b[B') {
221
+ index = (index + 1) % options.length
222
+ draw()
223
+ } else if (s === ' ') {
224
+ if (selected.has(index)) selected.delete(index)
225
+ else selected.add(index)
226
+ draw()
227
+ } else if (s === 'a' || s === 'A') {
228
+ if (selected.size === options.length) selected.clear()
229
+ else for (let i = 0; i < options.length; i++) selected.add(i)
230
+ draw()
231
+ } else if (s === '\r' || s === '\n') {
232
+ resolveKey('confirm')
233
+ }
234
+ }
235
+
236
+ let result
237
+ try {
238
+ out.write(`${CYAN}?${RESET} ${BOLD}${message}${RESET}\n`)
239
+ stdin.setRawMode(true)
240
+ stdin.resume()
241
+ stdin.on('data', onData)
242
+ draw()
243
+ if (activeBanner) activeBanner.below += 2 + options.length
244
+ result = await key
245
+ out.write('\n')
246
+ if (activeBanner) activeBanner.below += 1
247
+ } catch {
248
+ result = undefined
249
+ } finally {
250
+ stdin.removeListener('data', onData)
251
+ try { stdin.setRawMode(false) } catch {}
252
+ stdin.pause()
253
+ }
254
+ if (result === SIGINT) process.exit(0)
255
+ if (result === undefined) return { cancelled: true, indices: [] }
256
+ return { cancelled: false, indices: [...selected].sort((a, b) => a - b) }
257
+ }