@iseoane/agent-depot 0.6.0 → 0.6.2

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 CHANGED
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.2] - 2026-10-13
11
+
12
+ ### Fixed
13
+
14
+ - Pi Package update comparison no longer treats npm's generated dependency-free
15
+ root `package-lock.json` as a local checkout change. Any other lockfile, and
16
+ any tracked modification or additional untracked file, still blocks the
17
+ comparison.
18
+
19
+ ## [0.6.1] - 2026-10-13
20
+
21
+ ### Added
22
+
23
+ - The Profile TUI guides export through a folder picker and editable filename,
24
+ then shows the full destination and a confirmation preview. Import filters for
25
+ JSON files, can show all files, and keeps the picker open with inline read or
26
+ parse errors.
27
+
28
+ ### Changed
29
+
30
+ - `e` exports the complete Profile selection from any checklist row. `Enter`
31
+ expands groups or previews notes without starting export.
32
+ - Profile export and import previews use semantic colors for resource names,
33
+ Hosts, version policies, and fields, including wrapped continuation rows.
34
+
35
+ ### Fixed
36
+
37
+ - Profile import applies the exact previewed subset, including selections after
38
+ unselected entries.
39
+ - Export ignores stale filename checks after edits and refuses existing files
40
+ or symlinks without overwriting them. Folder navigation follows symlinked
41
+ directories and keeps the guidance visible in narrow terminals.
42
+ - Moved bundled-skill tests to `tests/skills/`. `pnpm test:skills` runs each
43
+ suite in its own Python process with bytecode writes disabled, so tests no
44
+ longer alter skill trees and cause source-versus-packed hash checks to report
45
+ false updates.
46
+
10
47
  ## [0.6.0] - 2026-10-12
11
48
 
12
49
  ### Added
package/README.es.md CHANGED
@@ -525,7 +525,7 @@ se recortan la pestaña del encabezado y el título de la nota.
525
525
  | 2 Catalog | Skills y Packages disponibles, además de App recipes aplicables y aprobadas cuya ausencia se ha verificado. La instalación de una App tiene una previsualización y confirmación separadas. Las recetas inválidas o sin aprobar permanecen en Sources; un estado desconocido nunca se trata como ausencia. Los Packages que el Host confirma como instalados se ocultan; los seleccionados o con estado desconocido siguen visibles. Los Skills propiedad de Packages conservan su comportamiento actual. |
526
526
  | 3 Installations | Apps cuya instalación está confirmada, instalaciones de Skills gestionadas (globales de usuario y de proyecto), selecciones de Packages con pertenencia al Host y evidencia de versión mostradas por separado, y Skills globales de usuario no gestionados. Un registro de instalación de App permanece visible aunque caduque la aprobación de la receta, pero sus acciones siguen exigiendo aprobación vigente. |
527
527
  | 4 Updates | Skills gestionados en el ámbito global de usuario y de proyecto, además de Apps y Packages instalados globales de usuario con una versión más reciente disponible. Las Apps desconocidas o no verificadas no se tratan como instaladas. |
528
- | 5 Profile | Exportar o importar elecciones portables de Sources globales de usuario, selecciones individuales de Skills, App recipes y selecciones de Packages. Las réplicas solo identifican recursos cuya instalación se confirmó en el origen. |
528
+ | 5 Profile | Exporta o importa elecciones globales de usuario. Consulta el [flujo de Profile](#flujo-de-profile) para ver la lista y los pasos del selector de archivos. |
529
529
 
530
530
  Teclas por vista (los avisos también aceptan `Esc` para cancelar y `y`/`n` para
531
531
  confirmar):
@@ -536,9 +536,73 @@ confirmar):
536
536
  | Catalog | `Enter`/flechas expanden y contraen; `space` marca el Skill disponible resaltado; `a` marca o desmarca los Skills disponibles visibles de su Source; `i` inicia la instalación de los Skills marcados o del Skill, Package o App aprobada cuya ausencia se verificó y que está resaltada. Confirma la instalación de una App por separado con `y`. `/` filtra y `s` amplía la vista de una Source a todas. Las marcas de otras Sources se conservan y no se instalan Skills ocultos por el filtro. |
537
537
  | Installations | `Enter`/flechas expanden y contraen. En filas globales de usuario, `u` inicia la desinstalación de una App o Skill gestionada, o elimina un Skill no gestionado; los Skills de proyecto no se pueden eliminar. `h` o `i` añade Hosts a una instalación global de usuario, y `A` o `Enter` adopta un Skill no gestionado. `space` marca una hoja y `a` marca todas las hojas visibles; las acciones por lotes omiten instalaciones de proyecto con un motivo. En una fila de App, `u` previsualiza la desinstalación y `h` selecciona setup/teardown; las recetas modificadas requieren una nueva aprobación. En una fila de Package, `i` previsualiza el plan de instalación congelado y lo ejecuta, aprobando antes el recibo si falta; `Enter` solo aprueba la declaración de instalación; `U` actualiza, `u` desinstala y `f` olvida el registro de selección. Las filas de Package no se pueden marcar para acciones por lotes. |
538
538
  | Updates | `space` marca una actualización, `a` marca todas las actualizaciones disponibles y `t`/`p`/`g` selecciona todo, el ámbito de proyecto o el global de usuario. `Enter` previsualiza las filas marcadas; `u` previsualiza el lote seleccionado; ninguna aplica actualizaciones. `r` vuelve a comprobar y las flechas izquierda/derecha pliegan o despliegan el grupo "cannot be checked". En una previsualización, las flechas o PageUp/PageDown avanzan una página visible y `j`/`k` una fila. Revisa todas las filas antes de `y`; `n` cancela. Las filas desconocidas son informativas. Packages y Apps solo se gestionan en el ámbito global de usuario; Packages requieren aprobación de acción antes de ejecutar una actualización. |
539
- | Profile | `e` exportar, `i` importar; introduce una ruta de archivo, `space` alterna opciones, `a` alterna todas, `Enter` previsualiza, `y` aplica; `j`/`k` desplazan las previsualizaciones y los resultados |
540
-
541
- Las previsualizaciones desplazables muestran todas las declaraciones y los términos de ejecución. Usa `j`/`k` para avanzar una fila, o las flechas y `PageUp`/`PageDown` para avanzar una página visible. Confirma solo después de mostrar cada fila física. Si cambias el tamaño del terminal, revisa las filas que aún no se han mostrado.
539
+ | Profile | Pulsa `e` para exportar o `i` para importar. Consulta el [flujo de Profile](#flujo-de-profile) para ver la navegación, la selección, las previsualizaciones y las cancelaciones. |
540
+
541
+ ### Flujo de Profile
542
+
543
+ La vista Profile transfiere elecciones portables globales de usuario. No
544
+ transfiere instalaciones ni aprobaciones. Exportación e importación empiezan en
545
+ la raíz del proyecto, salvo que la aplicación indique otro directorio inicial.
546
+
547
+ #### Exportar
548
+
549
+ 1. Pulsa `e` en la vista Profile para crear y abrir la lista de exportación.
550
+ El grupo **Sources**, contraído al inicio, contiene grupos anidados para
551
+ Sources, Skills, Apps y Packages. **Installed replicas** y **Export notes**
552
+ son grupos separados. Pulsa `Enter` o las flechas para expandir o contraer un
553
+ grupo. `Enter` sobre una nota de exportación muestra sus detalles; nunca
554
+ inicia la exportación.
555
+ 2. Usa `j`/`k` o las flechas para moverte, `space` para alternar una fila
556
+ seleccionable y `a` para seleccionar todo o borrar la selección. Los
557
+ elementos excluidos y las filas informativas no se pueden seleccionar.
558
+ Pulsa `e` para exportar las filas seleccionadas. Si no hay ningún recurso
559
+ portable seleccionado, no se escribe nada. Pulsa `Esc` para salir de la
560
+ lista.
561
+ 3. Elige una carpeta de destino. Muévete con `j`/`k` o las flechas; pulsa
562
+ `Enter` para abrir una carpeta o selecciona **Use this folder** para elegir
563
+ la carpeta actual. Usa Izquierda o Retroceso para subir a la carpeta
564
+ superior. Pulsa `p` para mostrar la ruta completa y otra vez `p` para volver
565
+ al listado. `Esc` vuelve a la lista de exportación.
566
+ 4. Edita el nombre del archivo. El valor predeterminado es
567
+ `agent-depot-profile.json`. `Ctrl+U` borra el nombre y se añade `.json` si
568
+ falta una extensión. El editor muestra la ruta completa de destino. Pulsa
569
+ `Enter` para revisarla. Si la ruta ya existe, incluido un enlace simbólico,
570
+ aparece un error en línea y puedes elegir otro nombre; la exportación nunca
571
+ sobrescribe. `Esc` vuelve al selector de carpetas.
572
+ 5. Revisa la previsualización de exportación, incluidas las exclusiones y
573
+ advertencias. Usa `j`/`k` para avanzar una fila y las flechas o
574
+ `PageUp`/`PageDown` para avanzar una página visible. Pulsa `y` para escribir
575
+ el archivo nuevo. Pulsa `n` o `Esc` para cancelar y volver al editor del
576
+ nombre. Los resultados muestran la ruta final.
577
+
578
+ #### Importar
579
+
580
+ 1. Pulsa `i` en la vista Profile para abrir el selector de archivos. Al
581
+ principio muestra archivos `.json` y carpetas. Pulsa `f` para mostrar todos
582
+ los archivos y otra vez `f` para filtrar de nuevo por `.json`. Navega con
583
+ `j`/`k` o las flechas; pulsa `Enter` para abrir una carpeta o seleccionar un
584
+ archivo, e Izquierda o Retroceso para subir a la carpeta superior. Pulsa `p`
585
+ para mostrar la ruta completa y otra vez `p` para volver. `Esc` vuelve a la
586
+ vista Profile.
587
+ 2. El archivo seleccionado se analiza y previsualiza. Si no se puede leer o
588
+ no es válido, aparece un error en línea y el selector permanece abierto para
589
+ elegir otro archivo. La lista de importación marca cada elemento como
590
+ **add**, **same** (omitido) o **conflict** (omitido, con las diferencias).
591
+ Solo se pueden seleccionar las adiciones. Usa `space` para alternar una
592
+ fila, `a` para seleccionar todas las adiciones o borrar la selección, y
593
+ `Enter` para previsualizar las adiciones seleccionadas. `Esc` vuelve a la
594
+ vista Profile.
595
+ 3. Revisa la previsualización exacta de importación. Usa `j`/`k` para avanzar
596
+ una fila y las flechas o `PageUp`/`PageDown` para una página visible. Pulsa
597
+ `y` para aplicar las adiciones seleccionadas, o `n`/`Esc` para cancelar y
598
+ volver a la lista de importación. Se informa del resultado de cada elemento;
599
+ un fallo no detiene las demás adiciones.
600
+
601
+ Las recetas importadas siguen sin aprobarse. La importación no instala Apps ni
602
+ Packages ni ejecuta comandos del Host. Las réplicas solo describen identidades
603
+ en la máquina de origen; no transfieren aprobaciones ni estado de instalación.
604
+
605
+ Las demás previsualizaciones desplazables de la TUI muestran todas las declaraciones y los términos de ejecución. Usa `j`/`k` para avanzar una fila o las flechas y `PageUp`/`PageDown` para avanzar una página visible. Confirma solo después de mostrar cada fila física. Si cambias el tamaño del terminal, revisa las filas que aún no se han mostrado.
542
606
 
543
607
  Al eliminar una receta App se comprueban su comando de versión aprobado y su
544
608
  seguimiento. Si la App está instalada o sigue registrada, la TUI ofrece el flujo
@@ -612,8 +676,14 @@ Desde un checkout de código fuente, compila y ejecuta la CLI directamente:
612
676
  pnpm install
613
677
  pnpm build
614
678
  node dist/src/cli.js --version
679
+ pnpm test:skills
615
680
  ```
616
681
 
682
+ `pnpm test:skills` ejecuta los tests de la biblioteca estándar de Python
683
+ ubicados en `tests/skills/`. Inicia un proceso Python separado para cada skill
684
+ incluida y define `PYTHONDONTWRITEBYTECODE=1`, por lo que los tests no crean
685
+ archivos de bytecode en los directorios de las skills.
686
+
617
687
  ## Análisis estático
618
688
 
619
689
  [Fallow](https://docs.fallow.tools) 3.30.0 se ejecuta mediante `npx --yes fallow@3.30.0`
@@ -1024,13 +1094,24 @@ La pestaña **5 Import/Export** de la TUI empieza con el grupo padre **Sources**
1024
1094
  contraído. Dentro contiene grupos anidados para elecciones de Source, Skills, Apps y
1025
1095
  Packages; cada recurso muestra la identidad de su Source. **Installed replicas** y las
1026
1096
  notas de exportación son grupos separados. Usa las mismas previsualizaciones del núcleo
1027
- compartido que la CLI. Las réplicas identifican recursos cuya instalación se confirmó
1028
- en la máquina de origen; no incluyen el estado de instalación, las aprobaciones ni el
1029
- contenido completo de la máquina. La importación solo añade; nunca transfiere
1030
- aprobaciones ni instala automáticamente Apps o Packages. Las exclusiones y los conflictos
1031
- no pueden seleccionarse. La exportación solo crea un archivo nuevo; las
1032
- exportaciones vacías no escriben nada (la CLI informa de un error). La importación
1033
- añade las elecciones que faltan sin reemplazar las existentes. Las recipes importadas
1034
- permanecen sin aprobar y la importación nunca instala Apps. Las selecciones de Package
1035
- importadas se registran sin instalar ningún bundle y sin ejecutar ningún comando del
1036
- Host.
1097
+ compartido que la CLI.
1098
+ `e` exporta toda la selección marcada desde cualquier fila; `Enter` solo expande un grupo
1099
+ o previsualiza una nota, así que navegar nunca inicia una escritura. Las réplicas
1100
+ identifican recursos cuya instalación se confirmó en la máquina de origen; no incluyen el
1101
+ estado de instalación, las aprobaciones ni el contenido completo de la máquina. La
1102
+ importación solo añade; nunca transfiere aprobaciones ni instala automáticamente Apps o
1103
+ Packages. Las exclusiones y los conflictos no pueden seleccionarse.
1104
+
1105
+ La exportación y la importación comparten un navegador de directorios que empieza en la
1106
+ raíz del proyecto, lista carpetas (y, al importar, archivos `.json`) y se mueve con
1107
+ `j`/`k` o las flechas. La exportación elige una carpeta y luego rellena
1108
+ `agent-depot-profile.json` en un campo editable que muestra el destino completo;
1109
+ `Ctrl+U` lo borra, un nombre sin extensión recibe `.json`, y un archivo o enlace
1110
+ simbólico existente se rechaza en ese paso para volver a escribirlo. La importación
1111
+ navega hasta un archivo existente; `f` alterna entre archivos `.json` y todos los
1112
+ archivos, y un archivo mal formado informa en línea mientras el navegador sigue abierto.
1113
+ La exportación solo crea un archivo nuevo; las exportaciones vacías no escriben nada (la
1114
+ CLI informa de un error). La importación añade las elecciones que faltan sin reemplazar
1115
+ las existentes. Las recipes importadas permanecen sin aprobar y la importación nunca
1116
+ instala Apps. Las selecciones de Package importadas se registran sin instalar ningún
1117
+ bundle y sin ejecutar ningún comando del Host.
package/README.md CHANGED
@@ -476,7 +476,7 @@ review Profile export notes; at 24 columns, the app header tab and note title cl
476
476
  | 2 Catalog | Available Skills and Packages, plus applicable approved App recipes verified as not installed. App installation has a separate preview and confirmation. Invalid or unapproved recipes remain in Sources; unknown App state is never treated as absence. Host-confirmed installed Packages are hidden; selected-only or unknown-state Packages remain visible. Package-owned Skills keep their existing behavior. |
477
477
  | 3 Installations | Confirmed installed Apps, managed Skill installations (user-global and project), Package selections with Host membership and version evidence shown separately, and unmanaged user-global Skills. App installation records remain visible if recipe approval later expires, but recipe actions still require current approval. |
478
478
  | 4 Updates | Managed Skills in project and user-global scope, plus user-global installed Apps and Packages with a newer version available. Unknown or unverified Apps are not treated as installed. |
479
- | 5 Profile | Export or import portable user-global Source choices, individual Skill selections, App recipes and Package selections. Replica entries report confirmed origin-machine identities only. |
479
+ | 5 Profile | Export or import portable user-global choices. See [Profile workflow](#profile-workflow) for the complete checklist and file-picker steps. |
480
480
 
481
481
  Keys per view (prompts also accept `Esc` to cancel and `y`/`n` to confirm):
482
482
 
@@ -486,9 +486,66 @@ Keys per view (prompts also accept `Esc` to cancel and `y`/`n` to confirm):
486
486
  | Catalog | `Enter`/arrows expand and collapse, `space` marks the highlighted loose Skill, `a` marks or clears the visible loose Skills in its Source, and `i` starts installation for marked Skills or the highlighted Skill, Package, or verified-absent approved App. Confirm an App install separately with `y`. `/` filters and `s` widens a Source view to all Sources. Marks in other Sources remain unchanged; Skills hidden by the filter are not installed. |
487
487
  | Installations | `Enter`/arrows expand and collapse. On user-global rows, `u` starts an App or managed Skill uninstall, or removes an unmanaged Skill; project Skills cannot be removed. `h` or `i` adds Hosts to a user-global Skill installation, and `A` or `Enter` adopts an unmanaged Skill. `space` marks a leaf and `a` marks all visible leaves; bulk actions skip project installations with a reason. On an App row, `u` previews uninstall and `h` selects setup/teardown; changed recipes must be approved again before commands run. On a Package row, `i` previews the frozen install plan and runs it, approving the receipt first when it is missing; `Enter` approves the install declaration only, `U` updates, `u` uninstalls, and `f` forgets the selection record. Package rows are not markable for bulk actions. |
488
488
  | Updates | `space` marks an update, `a` marks all available updates, and `t`/`p`/`g` selects all, project, or user-global scope. `Enter` previews the marked rows; `u` previews the selected batch; neither applies updates. `r` checks again, and left/right arrows fold or unfold the "cannot be checked" group. In a preview, arrows or PageUp/PageDown move by a visible page and `j`/`k` move one row. Review every row before `y`; `n` cancels. Unknown rows are informational. Packages and Apps are user-global only; Packages need action approval before update execution. |
489
- | Profile | `e` export, `i` import; enter a file path, `space` toggle choices, `a` toggle all, `Enter` preview, `y` apply; `j`/`k` scroll previews and results |
490
-
491
- Scrollable previews show every declaration and execution term. Use `j`/`k` to move one row, or the arrows and `PageUp`/`PageDown` to move by a visible page. Confirm only after every physical row has been displayed. Resizing a preview requires reviewing any rows that have not yet been displayed.
489
+ | Profile | Press `e` to export or `i` to import. See [Profile workflow](#profile-workflow) for navigation, selection, previews, and cancellation. |
490
+
491
+ ### Profile workflow
492
+
493
+ The Profile view transfers portable user-global choices. It does not transfer
494
+ installed state or approvals. Export and import begin in the project root, unless
495
+ the application supplies another start directory.
496
+
497
+ #### Export
498
+
499
+ 1. Press `e` in the Profile view to build and open the export checklist. Its
500
+ collapsed **Sources** group contains nested Source, Skill, App, and Package
501
+ choices. **Installed replicas** and **Export notes** are separate groups.
502
+ Expand or collapse a group with `Enter` or the arrow keys. `Enter` on an
503
+ export note opens its details; it never starts an export.
504
+ 2. Use `j`/`k` or the arrows to navigate, `space` to toggle a selectable row,
505
+ and `a` to select all or clear all. Excluded items and informational rows
506
+ cannot be selected. Press `e` to export the selected rows. If no portable
507
+ resource is selected, nothing is written. Press `Esc` to leave the checklist.
508
+ 3. Choose a destination folder. Move with `j`/`k` or the arrows, press `Enter`
509
+ to open a folder, or select **Use this folder** to choose the current one.
510
+ Use Left or Backspace to go to the parent. Press `p` to show the full
511
+ directory path; press `p` again to return to the listing. `Esc` returns to
512
+ the checklist.
513
+ 4. Edit the file name. The default is `agent-depot-profile.json`. `Ctrl+U`
514
+ clears the name, and a name without an extension gets `.json`. The editor
515
+ shows the complete destination path. Press `Enter` to review it. If the path
516
+ already exists, including a symlink, the view reports an inline error and
517
+ lets you choose another name; export never overwrites. `Esc` returns to the
518
+ folder picker.
519
+ 5. Review the export preview, including exclusions and warnings. Use `j`/`k`
520
+ to move one row and the arrows or `PageUp`/`PageDown` to move by a visible
521
+ page. Press `y` to write the new file, `n` or `Esc` to cancel and return to
522
+ the name editor. Results show the final path.
523
+
524
+ #### Import
525
+
526
+ 1. Press `i` in the Profile view to open the file picker. It initially lists
527
+ `.json` files and folders. Press `f` to show every file, `f` again to filter
528
+ back to `.json`. Navigate with `j`/`k` or the arrows, use `Enter` to open a
529
+ folder or select a file, and Left or Backspace to go to the parent. Press
530
+ `p` to show the full directory path and `p` again to return. `Esc` returns
531
+ to the Profile view.
532
+ 2. The selected file is parsed and previewed. If it is invalid or cannot be
533
+ read, an inline error appears and the picker stays open so you can choose
534
+ another file. The import list marks entries as **add**, **same** (skipped), or
535
+ **conflict** (skipped with differences). Only additions can be selected. Use
536
+ `space` to toggle a row, `a` to select all additions or clear them, and
537
+ `Enter` to preview the selected additions. Press `Esc` to return to the
538
+ Profile view.
539
+ 3. Review the exact import preview. Use `j`/`k` for one row and the arrows or
540
+ `PageUp`/`PageDown` for a visible page. Press `y` to apply the selected
541
+ additions, or `n`/`Esc` to cancel and return to the import list. Each result
542
+ is reported; an item failure does not stop the remaining additions.
543
+
544
+ Imported recipes remain unapproved. Import does not install Apps or Packages or
545
+ run Host commands. Replica entries describe identity on the exporting machine
546
+ only. They do not confer approval or installed state.
547
+
548
+ Scrollable previews elsewhere in the TUI show every declaration and execution term. Use `j`/`k` to move one row, or the arrows and `PageUp`/`PageDown` to move by a visible page. Confirm only after every physical row has been displayed. Resizing a preview requires reviewing any rows that have not yet been displayed.
492
549
 
493
550
  Removing an App recipe checks its approved version command and tracked state.
494
551
  If the App is installed or tracked, the TUI offers the existing uninstall flow;
@@ -558,8 +615,14 @@ From a source checkout, build and run the CLI directly:
558
615
  pnpm install
559
616
  pnpm build
560
617
  node dist/src/cli.js --version
618
+ pnpm test:skills
561
619
  ```
562
620
 
621
+ `pnpm test:skills` runs the Python standard-library tests from `tests/skills/`.
622
+ It starts a separate Python process for each bundled skill and sets
623
+ `PYTHONDONTWRITEBYTECODE=1`, so the tests do not create bytecode files in the
624
+ skill directories.
625
+
563
626
  ## Static analysis
564
627
 
565
628
  [Fallow](https://docs.fallow.tools) 3.30.0 runs through `npx --yes fallow@3.30.0`
@@ -934,11 +997,20 @@ rows explain when discovery inclusion differs but is not imported.
934
997
  The **5 Import/Export** TUI tab starts with a collapsed **Sources** parent. It
935
998
  contains nested Source choices, Skills, Apps, and Packages groups; resource rows show
936
999
  their Source identities. **Installed replicas** and export notes are separate groups.
937
- Replica entries identify resources confirmed installed on the exporting machine.
938
- They do not contain installed state, approvals, or full machine contents. Import is
939
- add-only; it never transfers approval or automatically installs Apps or Packages.
940
- Exclusions and conflicts cannot be selected. Export creates
941
- a new file only; empty exports write nothing (CLI reports an error). Import adds missing choices without replacing existing ones.
942
- Imported recipes remain unapproved and Apps are never installed by import.
943
- Imported Package selections are recorded without installing a bundle and without
944
- running a Host command.
1000
+ `e` exports the whole marked selection from any row; `Enter` only expands a group or
1001
+ previews a note, so browsing never starts a write. Replica entries identify resources
1002
+ confirmed installed on the exporting machine. They do not contain installed state,
1003
+ approvals, or full machine contents. Import is add-only; it never transfers approval or
1004
+ automatically installs Apps or Packages. Exclusions and conflicts cannot be selected.
1005
+
1006
+ Export and import share one directory browser that starts at the project root, lists
1007
+ folders (and, for import, `.json` files), and moves with `j`/`k` or the arrows. Export
1008
+ picks a folder, then pre-fills `agent-depot-profile.json` in an editable name field that
1009
+ shows the complete destination; `Ctrl+U` clears it, a name without an extension gains
1010
+ `.json`, and an existing file or symlink is refused at that step so you can retype.
1011
+ Import browses to an existing file; `f` switches between `.json` files and every file,
1012
+ and a malformed file reports inline while the browser stays open. Export creates a new
1013
+ file only; empty exports write nothing (CLI reports an error). Import adds missing
1014
+ choices without replacing existing ones. Imported recipes remain unapproved and Apps
1015
+ are never installed by import. Imported Package selections are recorded without
1016
+ installing a bundle and without running a Host command.
@@ -258,7 +258,9 @@ async function readPiCheckoutEvidence(checkoutPath, sourceIdentity) {
258
258
  const version = isRecord(packageJson) && typeof packageJson.version === "string" && packageJson.version.trim() !== ""
259
259
  ? packageJson.version
260
260
  : undefined;
261
- if (status !== "") {
261
+ const generatedRootLock = status === "?? package-lock.json"
262
+ && await isMatchingRootOnlyNpmLock(checkoutPath, packageJson);
263
+ if (status !== "" && !generatedRootLock) {
262
264
  return {
263
265
  ...(version === undefined ? {} : { version }),
264
266
  issue: `Pi reports this Package installed, but the Pi Git checkout has local modifications. Update assessment is unavailable; inspect the checkout before updating.`,
@@ -276,6 +278,48 @@ async function readPiCheckoutEvidence(checkoutPath, sourceIdentity) {
276
278
  return unavailable(reason);
277
279
  }
278
280
  }
281
+ /** Recognize only npm's dependency-free generated root lock, never tracked edits. */
282
+ async function isMatchingRootOnlyNpmLock(checkoutPath, manifest) {
283
+ try {
284
+ if (!isRecord(manifest))
285
+ return false;
286
+ const lockPath = path.join(checkoutPath, "package-lock.json");
287
+ await assertNoSymlinkPath(lockPath, "Pi generated lockfile");
288
+ const stat = await lstat(lockPath);
289
+ if (!stat.isFile() || stat.size > 64 * 1024)
290
+ return false;
291
+ const lock = JSON.parse(await readFile(lockPath, "utf8"));
292
+ if (!isRecord(lock) || lock.lockfileVersion !== 3 || lock.requires !== true
293
+ || typeof manifest.name !== "string" || typeof manifest.version !== "string"
294
+ || lock.name !== manifest.name || lock.version !== manifest.version
295
+ || Object.keys(lock).some((key) => !["name", "version", "lockfileVersion", "requires", "packages"].includes(key))
296
+ || !isRecord(lock.packages) || Object.keys(lock.packages).length !== 1)
297
+ return false;
298
+ const root = lock.packages[""];
299
+ if (!isRecord(root) || root.name !== manifest.name || root.version !== manifest.version)
300
+ return false;
301
+ // Ordinary dependencies mean this is more than the harmless root-only artifact.
302
+ for (const key of ["dependencies", "devDependencies", "optionalDependencies"]) {
303
+ if (manifest[key] !== undefined && (!isRecord(manifest[key]) || Object.keys(manifest[key]).length !== 0))
304
+ return false;
305
+ }
306
+ const allowed = ["name", "version", "license", "peerDependencies", "peerDependenciesMeta"];
307
+ const equalJson = (left, right) => {
308
+ if (left === right)
309
+ return true;
310
+ if (!isRecord(left) || !isRecord(right))
311
+ return false;
312
+ const keys = Object.keys(left);
313
+ return keys.length === Object.keys(right).length
314
+ && keys.every((key) => Object.hasOwn(right, key) && equalJson(left[key], right[key]));
315
+ };
316
+ return Object.keys(root).every((key) => allowed.includes(key) && equalJson(root[key], manifest[key]))
317
+ && ["peerDependencies", "peerDependenciesMeta"].every((key) => equalJson(root[key], manifest[key]));
318
+ }
319
+ catch {
320
+ return false;
321
+ }
322
+ }
279
323
  async function runPiGit(checkoutPath, args) {
280
324
  const result = await runProcess("git", ["-C", checkoutPath, ...args], {
281
325
  captureStdout: true,
@@ -0,0 +1,87 @@
1
+ import { lstat, readdir, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ /** The portable Profile artifact extension; import shows only these files until `f`. */
4
+ export const PROFILE_EXTENSION = ".json";
5
+ /** The pre-filled export file name; `Ctrl+U` clears it and a missing extension gains `.json`. */
6
+ export const DEFAULT_PROFILE_FILE_NAME = "agent-depot-profile.json";
7
+ /** The directory an export or import browser opens in. Tests inject `startDirectory`. */
8
+ export function pickerStartDirectory(environment) {
9
+ return path.resolve(environment.startDirectory ?? environment.projectRoot ?? process.cwd());
10
+ }
11
+ export function isProfileFileName(name) {
12
+ return path.extname(name).toLowerCase() === PROFILE_EXTENSION;
13
+ }
14
+ /** Directories first, then files, each in code-point order, so the list is stable across platforms. */
15
+ export function sortDirectoryEntries(entries) {
16
+ return [...entries].sort((left, right) => {
17
+ if (left.kind !== right.kind)
18
+ return left.kind === "directory" ? -1 : 1;
19
+ return left.name < right.name ? -1 : left.name > right.name ? 1 : 0;
20
+ });
21
+ }
22
+ /** Read one directory into the picker's listing shape; symlinked directories are followed. */
23
+ export async function readDirectoryListing(directory) {
24
+ const dirents = await readdir(directory, { withFileTypes: true });
25
+ const entries = [];
26
+ for (const dirent of dirents) {
27
+ let isDirectory = dirent.isDirectory();
28
+ if (!isDirectory && dirent.isSymbolicLink()) {
29
+ try {
30
+ isDirectory = (await stat(path.join(directory, dirent.name))).isDirectory();
31
+ }
32
+ catch {
33
+ isDirectory = false;
34
+ }
35
+ }
36
+ entries.push({ name: dirent.name, kind: isDirectory ? "directory" : "file" });
37
+ }
38
+ const parent = path.dirname(directory);
39
+ return { directory, parent: parent === directory ? undefined : parent, entries: sortDirectoryEntries(entries) };
40
+ }
41
+ /** The rows a listing shows: folders for both purposes, files only for `file`, filtered by `f`. */
42
+ export function pickerEntries(listing, purpose, showAllFiles) {
43
+ return listing.entries.filter(entry => {
44
+ if (entry.kind === "directory")
45
+ return true;
46
+ if (purpose === "directory")
47
+ return false;
48
+ return showAllFiles || isProfileFileName(entry.name);
49
+ });
50
+ }
51
+ /**
52
+ * Validate a typed file name and give it a JSON extension when it has none.
53
+ * Separators and the dot directories are refused so the name stays inside the chosen folder.
54
+ */
55
+ export function resolveExportFileName(input) {
56
+ if (input === "")
57
+ return { kind: "invalid", message: "Enter a file name." };
58
+ if (input === "." || input === "..")
59
+ return { kind: "invalid", message: "A file name cannot be . or .." };
60
+ if (input.includes("/") || input.includes("\\"))
61
+ return { kind: "invalid", message: "A file name cannot contain / or \\." };
62
+ if (path.extname(input) === "")
63
+ return { kind: "ok", fileName: `${input}${PROFILE_EXTENSION}`, appendedExtension: true };
64
+ return { kind: "ok", fileName: input, appendedExtension: false };
65
+ }
66
+ /** The full destination path, shown live in the name editor and in the confirmation. */
67
+ export function exportDestination(directory, fileName) {
68
+ return path.join(directory, fileName);
69
+ }
70
+ /** A note when the name keeps an extension other than `.json`; `undefined` when nothing needs explaining. */
71
+ export function fileNameNotice(fileName) {
72
+ const extension = path.extname(fileName);
73
+ if (extension === "" || extension.toLowerCase() === PROFILE_EXTENSION)
74
+ return undefined;
75
+ return `Keeping the ${extension} extension; import lists ${PROFILE_EXTENSION} files by default; press f to show all files.`;
76
+ }
77
+ export async function pathExists(target) {
78
+ try {
79
+ await lstat(target);
80
+ return true;
81
+ }
82
+ catch (error) {
83
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
84
+ return false;
85
+ throw error;
86
+ }
87
+ }
@@ -0,0 +1,142 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import path from "node:path";
3
+ import { Text, useWindowSize } from "ink";
4
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
+ import { pickerEntries, readDirectoryListing, PROFILE_EXTENSION } from "./file-picker-model.js";
6
+ import { useKeys } from "./keys.js";
7
+ import { moveCursor } from "./mode-keys.js";
8
+ import { BorderPanel } from "./panel-parts.js";
9
+ import { clipText, wrapText } from "./text-wrap.js";
10
+ import { theme } from "./theme.js";
11
+ import { useMirrored, useMounted } from "./view-state.js";
12
+ import { computeWindow, useListHeight } from "./window.js";
13
+ /**
14
+ * A picker callback changes the enclosing view's stage. Defer it so the view's own key
15
+ * handler, which runs in the same event, still sees the picker stage and ignores the key.
16
+ */
17
+ function settle(action) {
18
+ queueMicrotask(action);
19
+ }
20
+ /**
21
+ * A keyboard-only directory and file browser. It owns its keys while mounted, so the
22
+ * enclosing view must ignore its own bindings in the picker's stages. Each read carries a
23
+ * request generation, so a slow listing never overwrites a newer directory or an unmount.
24
+ */
25
+ export function FilePicker({ purpose, startDirectory, notice, onCancel, onChoose, listHeight, readDirectory = readDirectoryListing }) {
26
+ const [state, setState] = useState({ status: "loading", directory: startDirectory });
27
+ const [cursor, cursorRef, cursorTo] = useMirrored(0);
28
+ const [showAllFiles, showAllFilesRef, setShowAllFiles] = useMirrored(false);
29
+ const [showFullPath, setShowFullPath] = useState(false);
30
+ const requestId = useRef(0);
31
+ const mounted = useMounted();
32
+ const { columns } = useWindowSize();
33
+ const panelWidth = Math.max((columns || 80) - 4, 1);
34
+ const navigate = useCallback((directory) => {
35
+ const id = ++requestId.current;
36
+ cursorTo(0);
37
+ setState({ status: "loading", directory });
38
+ void readDirectory(directory).then(listing => {
39
+ if (!mounted.current || requestId.current !== id)
40
+ return;
41
+ setState({ status: "ready", directory: listing.directory, parent: listing.parent, entries: listing.entries });
42
+ }, (error) => {
43
+ if (!mounted.current || requestId.current !== id)
44
+ return;
45
+ setState({ status: "error", directory, message: error instanceof Error ? error.message : String(error) });
46
+ });
47
+ }, [cursorTo, mounted, readDirectory]);
48
+ useEffect(() => { navigate(startDirectory); }, [navigate, startDirectory]);
49
+ const entries = state.status === "ready" ? pickerEntries(state, purpose, showAllFiles) : [];
50
+ const rows = useMemo(() => {
51
+ const parent = state.status === "ready" && state.parent !== undefined ? [{ kind: "parent" }] : [];
52
+ const listing = entries.map(entry => ({ kind: "entry", entry }));
53
+ const useFolder = state.status === "ready" && purpose === "directory" ? [{ kind: "use-folder" }] : [];
54
+ return [...useFolder, ...parent, ...listing];
55
+ }, [entries, purpose, state]);
56
+ const choose = (row) => {
57
+ if (!row || state.status !== "ready")
58
+ return;
59
+ if (row.kind === "use-folder") {
60
+ settle(() => onChoose({ kind: "directory", directory: state.directory }));
61
+ return;
62
+ }
63
+ if (row.kind === "parent") {
64
+ if (state.parent !== undefined)
65
+ navigate(state.parent);
66
+ return;
67
+ }
68
+ const target = path.join(state.directory, row.entry.name);
69
+ if (row.entry.kind === "directory")
70
+ navigate(target);
71
+ else
72
+ settle(() => onChoose({ kind: "file", file: target, directory: state.directory }));
73
+ };
74
+ useKeys((input, key) => {
75
+ if (key.escape) {
76
+ settle(onCancel);
77
+ return;
78
+ }
79
+ if (showFullPath) {
80
+ if (input === "p")
81
+ setShowFullPath(false);
82
+ return;
83
+ }
84
+ if (state.status !== "ready") {
85
+ const fallbackParent = path.dirname(state.directory);
86
+ if ((key.backspace || key.leftArrow) && fallbackParent !== state.directory)
87
+ navigate(fallbackParent);
88
+ return;
89
+ }
90
+ if (key.backspace || key.leftArrow) {
91
+ if (state.parent !== undefined)
92
+ navigate(state.parent);
93
+ return;
94
+ }
95
+ if (input === "f" && purpose === "file") {
96
+ setShowAllFiles(!showAllFilesRef.current);
97
+ cursorTo(0);
98
+ return;
99
+ }
100
+ if (input === "p") {
101
+ setShowFullPath(true);
102
+ return;
103
+ }
104
+ const next = moveCursor(cursorRef.current, rows.length, input, key);
105
+ if (next !== undefined) {
106
+ cursorTo(Math.max(next, 0));
107
+ return;
108
+ }
109
+ if (key.return)
110
+ choose(rows[Math.min(cursorRef.current, rows.length - 1)]);
111
+ });
112
+ const directory = state.directory;
113
+ const wrappedDirectory = wrapText(`Directory: ${directory}`, panelWidth);
114
+ const directoryLines = showFullPath || wrappedDirectory.length <= 3
115
+ ? wrappedDirectory
116
+ : [wrappedDirectory[0], ...wrapText(`… ${wrappedDirectory.length - 2} lines · press p for full path`, panelWidth), wrappedDirectory.at(-1)];
117
+ const ordinaryFooterLines = (purpose === "directory"
118
+ ? ["j/k move · Enter open/use · ←", "parent · p full path · Esc back"]
119
+ : ["j/k move · Enter open · ← parent", `f ${showAllFiles ? "json only" : "all files"} · p full path · Esc back`])
120
+ .flatMap(line => wrapText(line, panelWidth));
121
+ const noticeLines = notice === undefined ? [] : wrapText(notice, panelWidth);
122
+ const filterNoteLines = !showFullPath && purpose === "file" && state.status === "ready" && !showAllFiles
123
+ ? wrapText(`Showing ${PROFILE_EXTENSION} only · f shows all files`, panelWidth)
124
+ : [];
125
+ const statusLineCount = !showFullPath && (state.status === "loading" || state.status === "error" || (state.status === "ready" && rows.length === 0)) ? 1 : 0;
126
+ const visibleNoticeLines = showFullPath ? [] : noticeLines;
127
+ const footerLines = showFullPath ? wrapText("p hide path · Esc back", panelWidth) : ordinaryFooterLines;
128
+ const reservesWindowIndicator = !showFullPath && rows.length > 0 && listHeight === undefined ? 1 : 0;
129
+ const reserved = 3 + directoryLines.length + statusLineCount + filterNoteLines.length + visibleNoticeLines.length + footerLines.length + reservesWindowIndicator;
130
+ const available = useListHeight(listHeight, reserved, 1);
131
+ const height = rows.length > available && listHeight !== undefined ? Math.max(available - 1, 1) : available;
132
+ const window = computeWindow(rows.length, cursor, height);
133
+ const listedRows = showFullPath ? [] : rows.slice(window.start, window.end);
134
+ return (_jsxs(BorderPanel, { title: purpose === "directory" ? "Choose export folder" : "Choose import file", children: [directoryLines.map((line, index) => _jsx(Text, { color: theme.value, children: line }, index)), !showFullPath && state.status === "loading" ? _jsx(Text, { color: theme.muted, children: "Loading\u2026" }) : null, !showFullPath && state.status === "error" ? _jsxs(Text, { color: theme.error, children: ["Error: ", clipText(state.message, panelWidth)] }) : null, !showFullPath && state.status === "ready" && rows.length === 0 ? _jsx(Text, { color: theme.muted, children: "Empty folder" }) : null, filterNoteLines.map((line, index) => _jsx(Text, { color: theme.muted, children: line }, `filter-${index}`)), listedRows.map((row, position) => {
135
+ const index = window.start + position;
136
+ const marker = index === cursor ? "> " : " ";
137
+ const label = row.kind === "use-folder" ? `Use this folder: ${directory}`
138
+ : row.kind === "parent" ? ".. (parent folder)"
139
+ : row.entry.kind === "directory" ? `${row.entry.name}/` : row.entry.name;
140
+ return _jsxs(Text, { children: [_jsx(Text, { color: theme.field, children: marker }), _jsx(Text, { color: theme.value, children: clipText(label, Math.max(panelWidth - 2, 1)) })] }, index);
141
+ }), visibleNoticeLines.map((line, index) => _jsx(Text, { color: theme.warning, children: line }, `notice-${index}`)), footerLines.map((line, index) => _jsx(Text, { color: theme.legend, children: line }, `footer-${index}`)), !showFullPath && window.indicator ? _jsx(Text, { color: theme.muted, children: window.indicator }) : null] }));
142
+ }