Imported from Jeremaya25/prdrive (
AGENTS.md). Install upstream withnpx skills add Jeremaya25/prdrive. Copyright stays with the author.
AGENTS.md
Guidance for coding agents working in this repository. This file loads every session, so it holds only what applies everywhere; area detail lives in docs/agents/reference/ (map below). Read the matching doc before editing an area.
What this is
prdrive: portable two-way sync between any rclone remote and a removable drive (or a host folder, through a resident agent), driven by a bundled rclone. Pure Python stdlib, 3.11+ (tomllib): no build, no dependencies, no package manifest. Tests are plain scripts in tests/, no framework; nothing touches a real device or the network.
The code knows no server: the connection lives in a profile.Profile (asked by the wizard, imported from the user's rclone.conf, or embedded in the compiled .exe). The remote stores configuration only (the pair catalogue), never the program.
Area docs (docs/agents/reference/)
Each opens with the files it covers. Claude Code also gets a one-line pointer to each doc when it reads or edits those files (path-scoped rules in .claude/rules/, one per doc); tests/test_reglas_claude.py keeps docs, rules and this table in sync.
| Doc | Read before touching |
|---|---|
engine.md |
sync.py, model.py, bisync.py, config_file.py: config → command, bisync prefix/baseline/filters, logs, progress, TOML writer |
conflicts-versions.md |
conflicts, results.py, historial.py, versions = true / .prversions/ |
catalogue.md |
catalog.py, catalog_editor, pair_editor, flags_editor, flags_guia, remote_picker |
fleet.md |
fleet.py, tk_fleet.py |
service.md |
runsync.py, ui/prefs.py, state/ locks, failure pop-up |
penwatch.md |
penwatch.py |
ui.md |
ui/ in general: frontends, theme and sizing, working()/background reads, «Ajustes» |
repair.md |
revision.py, ui/repair.py, «Reparación» |
drive-name-icon.md |
autorun.py, volumen.py, the device's name |
pairing-qr.md |
pairing.py, qr.py, tk_qr.py, capture protection |
provisioning.md |
prdrive-install.py, install/, wizard, platforms/runtimes, build_installer.py, Windows ARM |
veracrypt.md |
install/crypto.py, veracrypt_bin.py, traveler.py |
vestibule.md |
vestibulo.py, Abrir/Expulsar PRDRIVE, Linux open/close without VeraCrypt |
updating.md |
update.py, components.py, --update*, VERSION/release, downloads |
agent.md |
agente.py, install/agente.py, equipo.py, avisos.py, dbus.py |
agent-scheduling.md |
planificador.py, red.py, huella.py, avisos_carpeta.py, moderacion.py, watch = true |
agent-window.md |
window ↔ agent: «Pausar/Reanudar», mailboxes, ui/watch.py, agent self-update |
host-root.md |
raiz_equipo.py, tk_equipo.py, encrypted host root, «En este equipo» wizard |
tray.md |
ui/bandeja*.py |
llavero.md |
llavero.py, kdbx.py, keepassxc.py, registro.py, keepassxc_bin.py, llavero_editor, [keychain]: KeePassXC on the device, its code-built pair, «Abrir llavero» |
commands-testing.md |
full CLI list, test harness, registry of test-replaceable indirection points |
Specs and real-hardware test plans/results: docs/superpowers/{specs,pruebas}/ (the area docs cite them). Those cite sections of the former monolithic AGENTS.md by title: each area doc quotes its former titles on its «Formerly» line.
Layout
sync.py, runsync.py and penwatch.py are located by fixed path by the volume-root launchers and the watcher: do not move them. agente.py and pregunta.py are copied to the HOST, never to a device.
prdrive/ the checkout; on a provisioned device it is `.prdrive/`
├── sync.py engine: build the rclone command, run it, report
├── runsync.py window + periodic service; shells out to sync.py
├── penwatch.py mount watcher (self-contained)
├── agente.py resident agent: penwatch's successor on a host
├── pregunta.py the agent's «¿Atender esta unidad?» window as a tiny entry (agente.py is recompiled on every use)
├── prdrive-install.py wizard launcher (what gets compiled) · build_installer.py (PyInstaller)
├── generar_flags_rclone.py regenerates `common/flags_rclone.json` (the pinned rclone's flags) when `RCLONE_VERSION` moves
├── generar_guia_flags.py regenerates `docs/guia/flags-de-rclone.md` from the flags guide's catalogue
├── VERSION the version, in ONE place; ships to the device
├── common/ config and rclone; no Tk
│ model (TOML → frozen `Mode`/`Pair`/`Config`, the flag cascade with its origin) · orden (the rclone command in tagged pieces: `sync.build_command()` and the flags preview) · bisync (rclone bisync internals) · conflicts · results (last_run.json) · historial (pass journal)
│ revision (the ONE diagnosis) · frenos (the delete brakes in a log: `sync.py` and `revision` both read it) · progress · config_file (reads AND writes TOML) · catalog · fleet · update · components (stamps vs pins, no network)
│ pins (pinned rclone/Python/VeraCrypt + platforms) · pairing (rclone.conf, QR payload) · vestibulo · autorun · explorador (a VeraCrypt letter's name and icon, HKCU) · store (JSON state, `pid_alive()`, atomic writes, `hide()`) · prioridad (unattended passes yield the CPU and disk)
│ agent side: planificador (PURE scheduler) · huella · avisos_carpeta (inotify) · equipo (host dir, mailbox) · moderacion · red · dbus · avisos · expulsar (unmount a removable drive) · nombres (a drive's default name)
│ keychain: llavero (its pass and its watch) · kdbx (is a base whole) · keepassxc (KeePassXC on this host) · registro (HKCU)
├── ui/ asking the user, showing results
│ __init__ (`Choice`, `Frontend`, `start()`…) · theme · icons · qr · prefs · segundo_plano · cifrado · console · tk (TkFrontend, `modal()`/`mostrar()`/`working()`) · tk_*.py (draw only; `tk_principal` the main window, `tk_tabla` the one-canvas table)
│ decision halves, no Tk: principal (main window) · instantanea (the shared device read) · pair_editor · repair · catalog_editor · remote_picker · conflict_editor · flags_editor · flags_ayuda (autocomplete, value types, warnings) · flags_guia + flags_catalogo (the searchable flags guide) · watch · versions_editor · volumen · llavero_editor · lecturas_asistente (the wizard's reads: the examinations, the probe, the python and verification checks)
│ tray: bandeja (PURE) · bandeja_windows · bandeja_linux · tk_agente («¿Atender esta unidad?», a child of the agent)
├── install/ what the installer knows; no Tk, no device needed
│ profile · rclone_bin · runtime_bin · veracrypt_bin · descarga (retries, SHA256SUMS) · platforms · components · remote (ephemeral rclone.conf, catalogue)
│ device (volumes) · crypto (VeraCrypt, BitLocker) · traveler · vestibulo · agente · raiz_equipo · deploy (copy code, runtimes, launchers, config)
└── tests/ plain scripts; `run_all.py` runs each in its own process · `rendimiento/` is the CI timing check (not collected by `run_all.py`)
On a provisioned device the code lives in .prdrive/ at the volume root (hidden by the dot on POSIX, by deploy.hide() on Windows). model.APP_DIR = Path(__file__).parent.parent and DEVICE_ROOT its parent: nothing depends on the folder name or drive letter. The control file is inside .prdrive/ (.prdrive/PRDRIVE), so identifying the drive needs only a root-relative path and it cannot be deleted without deleting the program.
Dependency rules: do not cross them
- Heavy
commonmodules are imported inside the function that uses them inui/__init__.py,ui/tk.py,common/update.pyandrunsync.py, and a new lazy import in the main window goes inui.tk.PRECARGA(ui.md,tests/test_imports_perezosos.py). tk_*modules only draw. Every decision and disk touch lives inpair_editor/catalog_editor/flags_editor/watch/install/, which import no Tk and are tested headlessly.import tkintergoes inside functions, never at module top:ui/is imported by headless paths (--auto, the service), and the failure must surface when a window opens soui.start()can fall back to the console menu.theme.py/icons.pyown every colour, font and glyph (atk_*module never writes a hex value); distances go throughtheme.medida(), never a bare integer.penwatch.pyimports neither package: it is copied to the host and must keep working with the device unplugged. The agent imports penwatch, never the reverse.install/importscommon/(model.BASE_FLAGS,model.flags_to_args,config_file.save,store.pid_alive: what the installer writes must be byte-for-byte whatsync.pyreads) but notui/outsidetk_install, and must work with no device anywhere. Icons go throughinstall.pintar()→install.pintar_iconos(set byprdrive-install.pytoui.icons.pintar; unset, nothing is painted).tests/test_agente_endurecido.pywalksinstall/foruiimports.prdrive-install.pyis a launcher (arguments in,ui/tk_install.pyout), except--update: the self-update applier, inline and windowless, run from the downloaded copy of the project.runsync.pynever importssync.py(it shells out tomodel.SYNC_PY <pair>);sync.pynever importsui/(that is why the ONE diagnosis lives incommon/revision.py).
Constants duplicated on purpose (edit every copy; tests/test_* guard them):
deploy.APP_SUBDIR;STRUCT_MARKER/CONTROL_FILE(.prdrive/PRDRIVE) inpenwatch.py,install/device.pyandfleet.control_file(): three copies becauseinstall/does not travel to the device (test_install_device,test_fleet).RUNTIME_STAMP/RUNTIME_SUBDIR;penwatch.runtime_keys_for()vsinstall/platforms.candidates()vs the fallback chain hard-coded inrunsync.bat(test_penwatch_runtime).penwatch.UI_LOCK_REL/DAEMON_LOCK_REL/HOSTvsmodel.ui_lock()/model.daemon_lock()andprefs.HOST(test_instancia_unica). The two lock paths are functions inmodel.pybecause tests moveSTATE_DIRat runtime;runsyncandui/repair.pyboth go through them, so penwatch's copy is the only one.penwatch.CONTAINER_FILE/VESTIBULE_MARKER/OPEN_SCRIPTandTRAVELER_DIR/TRAVELER_EXE/TRAVELER_PORTABLE/TRAVELER_ARCHSvscommon/vestibulo.py(TRAVELER*), andpenwatch.UDISKS_TCRYPT_CONFvsinstall/vestibulo.TCRYPT_CONF(test_penwatch_vestibulo).install/importscommon/vestibulodirectly, andAbrir/Expulsar PRDRIVE.batare generated from theTRAVELER*names.
Commands and verification
python sync.py [pares...] # all pairs, or only these. --list (read-only) · --doctor · --resync · -y · --keep-logs
python sync.py --dry-run # simulate; MANDATORY before any *-mirror run
python runsync.py # UI (Tk, console fallback) + periodic service; --auto, --auto --once
python tests/run_all.py --cambios -j auto # while working: the tests of what the diff against main touches, and of what imports or launches it; never a window
python tests/run_all.py -j auto # every test that opens no window; --ventanas adds them (CI does); or run one script directly
The full list (penwatch.py, agente.py, prdrive-install.py, build_installer.py) is in commands-testing.md.
- Verification while working is
tests/run_all.py --cambios -j auto, then--doctor,--dry-run. Tk tests never open a window locally unless named or--ventanas: CI runs all of them. A new test goes in thepathsof its area's rule (commands-testing.md). Nothing to lint; CI (.github/workflows/tests.yml) runs every test (run_all.py --ventanas) on every PR, on Linux under xvfb and on Windows, with the device's pinned runtime (Tk 9), plus one Linux pass onsetup-python3.11 with no display that holds the Python floor of everything that is not a window;rendimiento.ymltimes the windows againstmain(commands-testing.md). Tk tests skip without a display or withouttkinter, so green without Tk has tested no window. The suite passes on Windows and Linux: a check about the other system forcesIS_WINor prints(saltado) …. - Tk 9 is the only supported Tk (owner's decision, 09/10/2026): no Tk 8.6 test leg, and new code writes no Tk 8.6 workaround. The light install needs a host Python with Tk 9, and a device still on the 3.13 runtime (Tk 8.6 on Windows) is unsupported until it updates from «Ajustes → Actualizaciones». The Tk 8.6 paths that exist stay so such a device can still open its window and reach «Actualizar…»: do not delete them outside the stage-4 cleanup (
ui.md«Which Tk»). runsync.pywith no args always stops a previously started service first.- Windows dev machine: the Bash tool is sandboxed. It redirects writes under
%LOCALAPPDATA%(apenwatch installfrom there registers a task pointing at nothing) and hangstasklist | find. Use PowerShell for both.
Engine rules (detail: engine.md)
- Parse once, at the boundary:
model.parse_config()→ frozenMode/Pair/Config; nothing downstream re-reads TOML keys. Validation raisesmodel.ConfigError, neversys.exit(the UI shares the model);install.InstallErrorlikewise. - A new rclone flag = edit the TOML, never code. Flags merge
BASE_FLAGS<Mode.flags<[defaults.flags]<[pair.flags]; the script owns--config,--log-file,--dry-run,--workdir,--resync,--force. - rclone always runs with
cwd = model.APP_DIR:rclone.confuses paths relative to it (key_file,known_hosts_file). sync_config.tomlis per-device: generated from the catalogue at provisioning, then maintained by the pairs screen; still hand-editable (a pair that differs from the catalogue is reported «modificada aquí», not corrected). Nothing on the device travels to the remote: no pair mirrors.prdrive/(enforced for bisync and for copy/sync without a matchinginclude:REGLA_SIN_PROGRAMA).
Safety invariants: do not weaken
- If a bisync baseline exists but the local path does not,
_bisync_preflight()aborts with rc 2 instead of creating the folder (an empty local side reads as "everything was deleted"). The same for the local folder of anup-mirror(Mode.borra_en_remoto: the source of async; created empty, it empties the remote, while rclone refuses a missing source):_pasar_pareja()aborts with rc 2 and creates nothing (#151). Only the other pairs (a bisync without a baseline,up,down,down-mirror) get their local dir created. max-deletedefaults: 25 bisync / 50 mirror, and they are not the same unit. bisync's is a global rclone flag it intercepts and reinterprets:Options.applyContext()(cmd/bisync/cmd.go) reads--max-delete, clamps it to 0..100 and setsci.MaxDelete = -1sofs/operationsnever treats it as a count;excessDeletes()(cmd/bisync/deltas.go) then aborts whendeleted / oldCountexceeds that percentage of the previous listing.*-mirror's 50 is the ordinarysyncflag: a plain count of files. Don't "fix" either number by comparing it to the other. Skipping either brake is--force(sync.py NAME… --force, ONE pass): a person's confirmed click in «Reparación» or a command line, never a TOML flag and never an unattended path (engine.md,repair.md). One exception, decided in the #115 triage: the code itself repeats ONE keychain pass that bisync stopped with «all files were changed» with--force, and only after a--dry-runof that repeat shows it would delete nothing; what either side overwrites goes to.prversions/, and it touches no other pair and no person's--forceon the keychain (sync.repetir_sin_freno(),llavero.md).- Any
*-mirrorpair deletes on the far side: never exercise one without--dry-runfirst. No pair mirrors the whole device. - Anything entering
bisync.expected_prefix()(local,remote,remote_path,mode,device_remote,RAIZ_UPSTREAM, and the target of analiasremote inrclone.conf) names the baseline: a change shelves it (state/<pair>.old-<date>/, then an explicit--resync), never renames it, and there is no listing rename (engine.md,catalogue.md). - Anything that deletes or rewrites user data returns an
EditPlan(consequences/warnings/execute()) shown throughtk_pairs.confirmar_plan()before it runs; nothing repairs itself.
Conventions
- Writing code:
docs/agents/code-writing-conventions.md, before changing Python. Comments, docstrings and everything the user sees are in Spanish; keep the technical citations (rclone, VeraCrypt, kernel, specs) in code that mirrors them. Docs underdocs/agents/are English. - Indirection points: everything that touches the network, a real device or the desktop is a module-level function tests can replace (
catalog.run(),update.fetch(),tk.mostrar()/confirmar_plan(),equipo.DIR…). New ones keep that shape; the registry is incommands-testing.md. - Windows traps:
pid_alive()usesOpenProcess, neveros.kill(which terminates on Windows); background processes usepythonw.exe+CREATE_NO_WINDOW; child runs getstdin=DEVNULL, so a pair needing--resyncis skipped rather than resynced unattended (the keychain and a pair whose reference is empty excepted:engine.md). .gitignoreexcludes device/user paths (bin/,runtime/,keys/,filters/,logs/,state/,sync_config.toml,rclone.conf,prdrive-profile.toml), build artefacts andinstall/secret.py.
Documentation
README.md: the user-facing front page. Spanish, short, no internals. Longer user guides:docs/guia/.device-readme.md: the light quick guide, not for repo readers: the installer copies it to the volume root asREADME.md(deploy.write_guide(), best-effort). Short, task-shaped, no internals; it is inDATOS_FICHEROS, so a build that forgets it fails at compile time.sync_config.example.toml: the schema reference forsync_config.tomland the remote's catalogueremote.toml(formerlypairs.toml; it also takes[remote]).LICENSE: Apache 2.0 verbatim..github/pull_request_template.md: what every PR answers (agents' included): where it touches, which data is at stake, what happens to devices already in use, how it was checked. A PR title is release text (--generate-notes;tk_updateshows it on every device): write it in Spanish, for the device's user.- A behaviour change updates the matching
docs/agents/reference/doc (anddocs/guia/if users see it), not this file: keep this one small, it loads every session.
Agent skills
- Issue tracker: GitHub Issues (
Jeremaya25/prdrive) via theghCLI. Seedocs/agents/issue-tracker.md. - Triage labels: five canonical labels, each string equal to its role name. See
docs/agents/triage-labels.md. - Domain docs: single-context, one
CONTEXT.md+docs/adr/at the repo root, created lazily. Seedocs/agents/domain.md.
