agents-city 0.3.0-beta.21
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/.claude-plugin/marketplace.json +16 -0
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.es.md +2021 -0
- package/README.md +1999 -0
- package/benchmarks/committee/README.md +39 -0
- package/benchmarks/committee/metrics.py +51 -0
- package/benchmarks/committee/run.py +64 -0
- package/benchmarks/committee/traces/01-single.json +8 -0
- package/benchmarks/committee/traces/02-mesh.json +16 -0
- package/benchmarks/committee/traces/03-chair.json +15 -0
- package/benchmarks/latency/README.md +35 -0
- package/benchmarks/latency/fake-claude-cli.mjs +119 -0
- package/benchmarks/latency/fake-native-server.mjs +340 -0
- package/benchmarks/latency/fake-tui.py +91 -0
- package/benchmarks/latency/live.py +380 -0
- package/benchmarks/stress/README.md +20 -0
- package/benchmarks/stress/run.py +487 -0
- package/bin/agents +4 -0
- package/bin/agents-city.js +116 -0
- package/bin/benchmark +17 -0
- package/bin/bus +6 -0
- package/bin/cities +3 -0
- package/bin/city +86 -0
- package/bin/committee +6 -0
- package/bin/demo +200 -0
- package/bin/exit +5 -0
- package/bin/hall +73 -0
- package/bin/hall.html +426 -0
- package/bin/logs +3 -0
- package/bin/report.py +14 -0
- package/bin/reset +3 -0
- package/bin/road +3 -0
- package/bin/seat +10 -0
- package/bin/serve.py +1428 -0
- package/bin/setup.py +753 -0
- package/bin/skills +3 -0
- package/bin/test +131 -0
- package/bin/test-adapter.py +183 -0
- package/bin/test-admision.py +99 -0
- package/bin/test-avatar.py +58 -0
- package/bin/test-benchmark.py +40 -0
- package/bin/test-broker.py +334 -0
- package/bin/test-cage.py +206 -0
- package/bin/test-card.py +402 -0
- package/bin/test-channel.py +379 -0
- package/bin/test-cities.py +453 -0
- package/bin/test-claude-runtime.py +252 -0
- package/bin/test-committee.py +340 -0
- package/bin/test-contracts.py +544 -0
- package/bin/test-crecimiento.py +164 -0
- package/bin/test-demo.py +308 -0
- package/bin/test-doctor.py +106 -0
- package/bin/test-domains.py +151 -0
- package/bin/test-evidencia.py +50 -0
- package/bin/test-exit.py +128 -0
- package/bin/test-hall-protocol.py +93 -0
- package/bin/test-launch.py +169 -0
- package/bin/test-live-feed.py +508 -0
- package/bin/test-pairing.py +99 -0
- package/bin/test-parcels.py +283 -0
- package/bin/test-runtime-failures.py +252 -0
- package/bin/test-runtime-ui.py +488 -0
- package/bin/test-runtime.py +268 -0
- package/bin/test-rutas.py +80 -0
- package/bin/test-seat.py +1386 -0
- package/bin/test-security.py +150 -0
- package/bin/test-serve.py +1057 -0
- package/bin/test-stress.py +64 -0
- package/bin/test-widgets.py +143 -0
- package/bin/test-workspace.py +188 -0
- package/bin/testlib.py +95 -0
- package/bin/tokens.py +14 -0
- package/bus/scripts/deploy.sh +60 -0
- package/bus/scripts/mint-token.sh +68 -0
- package/bus/scripts/setup-dev.sh +79 -0
- package/bus/scripts/test-channel.ts +8 -0
- package/bus/scripts/test-hub.ts +266 -0
- package/bus/scripts/test-local.sh +40 -0
- package/bus/scripts/test-queue.ts +132 -0
- package/bus/worker/package-lock.json +1971 -0
- package/bus/worker/package.json +17 -0
- package/bus/worker/src/index.ts +485 -0
- package/bus/worker/tsconfig.json +13 -0
- package/bus/worker/wrangler.toml +32 -0
- package/city/oven/README.md +59 -0
- package/city/oven/collect.py +43 -0
- package/city/oven/oven.html +4 -0
- package/city/scripts/history.py +120 -0
- package/city/scripts/seed.py +442 -0
- package/city/web/assets/modelos/License.txt +28 -0
- package/city/web/assets/modelos/PROCEDENCIA.txt +3 -0
- package/city/web/assets/modelos/building-a.glb +0 -0
- package/city/web/assets/modelos/building-b.glb +0 -0
- package/city/web/assets/modelos/building-c.glb +0 -0
- package/city/web/assets/modelos/building-d.glb +0 -0
- package/city/web/assets/modelos/building-e.glb +0 -0
- package/city/web/assets/modelos/building-f.glb +0 -0
- package/city/web/assets/modelos/building-g.glb +0 -0
- package/city/web/assets/modelos/building-h.glb +0 -0
- package/city/web/assets/sprites/building-a.png +0 -0
- package/city/web/assets/sprites/building-b.png +0 -0
- package/city/web/assets/sprites/building-c.png +0 -0
- package/city/web/assets/sprites/building-d.png +0 -0
- package/city/web/assets/sprites/building-e.png +0 -0
- package/city/web/assets/sprites/building-f.png +0 -0
- package/city/web/assets/sprites/building-g.png +0 -0
- package/city/web/assets/sprites/building-h.png +0 -0
- package/city/web/assets/sprites/building-i.png +0 -0
- package/city/web/assets/sprites/building-j.png +0 -0
- package/city/web/assets/sprites/building-k.png +0 -0
- package/city/web/assets/sprites/building-l.png +0 -0
- package/city/web/assets/sprites/building-m.png +0 -0
- package/city/web/assets/sprites/building-n.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-a.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-b.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-c.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-d.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-e.png +0 -0
- package/city/web/assets/sprites/catalogo.json +47 -0
- package/city/web/assets/sprites/detail-awning-wide.png +0 -0
- package/city/web/assets/sprites/detail-awning.png +0 -0
- package/city/web/assets/sprites/detail-overhang-wide.png +0 -0
- package/city/web/assets/sprites/detail-overhang.png +0 -0
- package/city/web/assets/sprites/detail-parasol-a.png +0 -0
- package/city/web/assets/sprites/detail-parasol-b.png +0 -0
- package/city/web/assets/sprites/low-detail-building-a.png +0 -0
- package/city/web/assets/sprites/low-detail-building-b.png +0 -0
- package/city/web/assets/sprites/low-detail-building-c.png +0 -0
- package/city/web/assets/sprites/low-detail-building-d.png +0 -0
- package/city/web/assets/sprites/low-detail-building-e.png +0 -0
- package/city/web/assets/sprites/low-detail-building-f.png +0 -0
- package/city/web/assets/sprites/low-detail-building-g.png +0 -0
- package/city/web/assets/sprites/low-detail-building-h.png +0 -0
- package/city/web/assets/sprites/low-detail-building-i.png +0 -0
- package/city/web/assets/sprites/low-detail-building-j.png +0 -0
- package/city/web/assets/sprites/low-detail-building-k.png +0 -0
- package/city/web/assets/sprites/low-detail-building-l.png +0 -0
- package/city/web/assets/sprites/low-detail-building-m.png +0 -0
- package/city/web/assets/sprites/low-detail-building-n.png +0 -0
- package/city/web/assets/sprites/low-detail-building-wide-a.png +0 -0
- package/city/web/assets/sprites/low-detail-building-wide-b.png +0 -0
- package/city/web/assets/sprites/medidas.json +248 -0
- package/city/web/dist/city.js +939 -0
- package/city/web/dist/index.html +294 -0
- package/city/web/dist/modelos/License.txt +28 -0
- package/city/web/dist/modelos/PROCEDENCIA.txt +3 -0
- package/city/web/dist/modelos/building-a.glb +0 -0
- package/city/web/dist/modelos/building-b.glb +0 -0
- package/city/web/dist/modelos/building-c.glb +0 -0
- package/city/web/dist/modelos/building-d.glb +0 -0
- package/city/web/dist/modelos/building-e.glb +0 -0
- package/city/web/dist/modelos/building-f.glb +0 -0
- package/city/web/dist/modelos/building-g.glb +0 -0
- package/city/web/dist/modelos/building-h.glb +0 -0
- package/city/web/dist/sprites/building-a.png +0 -0
- package/city/web/dist/sprites/building-b.png +0 -0
- package/city/web/dist/sprites/building-c.png +0 -0
- package/city/web/dist/sprites/building-d.png +0 -0
- package/city/web/dist/sprites/building-e.png +0 -0
- package/city/web/dist/sprites/building-f.png +0 -0
- package/city/web/dist/sprites/building-g.png +0 -0
- package/city/web/dist/sprites/building-h.png +0 -0
- package/city/web/dist/sprites/building-i.png +0 -0
- package/city/web/dist/sprites/building-j.png +0 -0
- package/city/web/dist/sprites/building-k.png +0 -0
- package/city/web/dist/sprites/building-l.png +0 -0
- package/city/web/dist/sprites/building-m.png +0 -0
- package/city/web/dist/sprites/building-n.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-a.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-b.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-c.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-d.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-e.png +0 -0
- package/city/web/dist/sprites/catalogo.json +47 -0
- package/city/web/dist/sprites/detail-awning-wide.png +0 -0
- package/city/web/dist/sprites/detail-awning.png +0 -0
- package/city/web/dist/sprites/detail-overhang-wide.png +0 -0
- package/city/web/dist/sprites/detail-overhang.png +0 -0
- package/city/web/dist/sprites/detail-parasol-a.png +0 -0
- package/city/web/dist/sprites/detail-parasol-b.png +0 -0
- package/city/web/dist/sprites/low-detail-building-a.png +0 -0
- package/city/web/dist/sprites/low-detail-building-b.png +0 -0
- package/city/web/dist/sprites/low-detail-building-c.png +0 -0
- package/city/web/dist/sprites/low-detail-building-d.png +0 -0
- package/city/web/dist/sprites/low-detail-building-e.png +0 -0
- package/city/web/dist/sprites/low-detail-building-f.png +0 -0
- package/city/web/dist/sprites/low-detail-building-g.png +0 -0
- package/city/web/dist/sprites/low-detail-building-h.png +0 -0
- package/city/web/dist/sprites/low-detail-building-i.png +0 -0
- package/city/web/dist/sprites/low-detail-building-j.png +0 -0
- package/city/web/dist/sprites/low-detail-building-k.png +0 -0
- package/city/web/dist/sprites/low-detail-building-l.png +0 -0
- package/city/web/dist/sprites/low-detail-building-m.png +0 -0
- package/city/web/dist/sprites/low-detail-building-n.png +0 -0
- package/city/web/dist/sprites/low-detail-building-wide-a.png +0 -0
- package/city/web/dist/sprites/low-detail-building-wide-b.png +0 -0
- package/city/web/dist/sprites/medidas.json +248 -0
- package/city/web/dist-hall/hall.js +264 -0
- package/city/web/index.html +294 -0
- package/city/web/package.json +18 -0
- package/city/web/sella.py +21 -0
- package/city/web/src/activity-actors.ts +47 -0
- package/city/web/src/activity.ts +172 -0
- package/city/web/src/ayuntamiento.ts +580 -0
- package/city/web/src/draw.ts +640 -0
- package/city/web/src/game-speech.ts +79 -0
- package/city/web/src/hall.ts +1867 -0
- package/city/web/src/main.ts +2225 -0
- package/city/web/src/oven.ts +124 -0
- package/city/web/src/people.ts +244 -0
- package/city/web/src/presencia.ts +193 -0
- package/city/web/src/puertas.ts +180 -0
- package/city/web/src/repo-roles.ts +59 -0
- package/city/worker/package.json +16 -0
- package/city/worker/schema.sql +117 -0
- package/city/worker/src/index.ts +702 -0
- package/city/worker/src/square.ts +218 -0
- package/city/worker/wrangler.toml +58 -0
- package/demo/ada.md +50 -0
- package/demo/bruno.md +62 -0
- package/demo/camila.md +56 -0
- package/demo/city/ada.md +40 -0
- package/demo/city/city.yml +8 -0
- package/demo/city/parcels.yml +15 -0
- package/demo/city/roads.json +4 -0
- package/demo/city/units.yml +10 -0
- package/demo/clinica/city.yml +8 -0
- package/demo/clinica/parcels.yml +15 -0
- package/demo/clinica/roads.json +4 -0
- package/demo/clinica/units.yml +9 -0
- package/demo/clinica/vera.md +46 -0
- package/demo/dante.md +54 -0
- package/demo/despacho/city.yml +8 -0
- package/demo/despacho/marta.md +46 -0
- package/demo/despacho/parcels.yml +15 -0
- package/demo/despacho/roads.json +4 -0
- package/demo/despacho/units.yml +9 -0
- package/demo/elsa.md +50 -0
- package/demo/farid.md +52 -0
- package/demo/greta.md +50 -0
- package/demo/hugo.md +49 -0
- package/demo/iris.md +49 -0
- package/demo/jonas.md +51 -0
- package/demo/kira.md +51 -0
- package/demo/luca.md +49 -0
- package/demo/parcels.yml +92 -0
- package/demo/seed.py +20 -0
- package/demo/show.py +161 -0
- package/demo/stories.py +824 -0
- package/demo/units.yml +33 -0
- package/docs/agents-first.md +99 -0
- package/docs/glossary.md +55 -0
- package/docs/map-live-layers.md +73 -0
- package/docs/security.md +231 -0
- package/docs/self-host.md +151 -0
- package/docs/testing.md +205 -0
- package/package.json +73 -0
- package/plugin/.claude-plugin/plugin.json +62 -0
- package/plugin/.mcp.json +15 -0
- package/plugin/channel/activity-cli.ts +16 -0
- package/plugin/channel/adapter-prompts.ts +113 -0
- package/plugin/channel/adapter.js +4292 -0
- package/plugin/channel/adapter.ts +133 -0
- package/plugin/channel/bus.js +20188 -0
- package/plugin/channel/bus.ts +131 -0
- package/plugin/channel/city-config.ts +125 -0
- package/plugin/channel/claude-channel.ts +58 -0
- package/plugin/channel/cli-args.ts +75 -0
- package/plugin/channel/client.js +4200 -0
- package/plugin/channel/client.ts +38 -0
- package/plugin/channel/committee/activity.ts +256 -0
- package/plugin/channel/committee/collection.ts +154 -0
- package/plugin/channel/committee/decision.ts +175 -0
- package/plugin/channel/committee/floor.ts +135 -0
- package/plugin/channel/committee/guards.ts +30 -0
- package/plugin/channel/committee/history.ts +38 -0
- package/plugin/channel/committee/render.ts +102 -0
- package/plugin/channel/committee/service.ts +72 -0
- package/plugin/channel/committee/storage.ts +92 -0
- package/plugin/channel/committee/types.ts +163 -0
- package/plugin/channel/committee/view.ts +63 -0
- package/plugin/channel/committee-cli.ts +109 -0
- package/plugin/channel/delivery-metrics.ts +61 -0
- package/plugin/channel/delivery-queue.ts +118 -0
- package/plugin/channel/hub/activity-controller.ts +72 -0
- package/plugin/channel/hub/activity-feed.ts +169 -0
- package/plugin/channel/hub/committee-controller.ts +60 -0
- package/plugin/channel/hub/connections.ts +48 -0
- package/plugin/channel/hub/diagnostics.ts +67 -0
- package/plugin/channel/hub/envelope-validity.ts +109 -0
- package/plugin/channel/hub/envelopes.ts +66 -0
- package/plugin/channel/hub/lifecycle.ts +91 -0
- package/plugin/channel/hub/local-roads.ts +89 -0
- package/plugin/channel/hub/remote-roads.ts +165 -0
- package/plugin/channel/hub/road-controller.ts +99 -0
- package/plugin/channel/hub-client.ts +149 -0
- package/plugin/channel/local-hub.js +5995 -0
- package/plugin/channel/local-hub.ts +349 -0
- package/plugin/channel/map-reporter.ts +48 -0
- package/plugin/channel/package-lock.json +1731 -0
- package/plugin/channel/package.json +19 -0
- package/plugin/channel/protocol.ts +98 -0
- package/plugin/channel/road-cli.ts +18 -0
- package/plugin/channel/run.sh +15 -0
- package/plugin/channel/runtime/claude.ts +458 -0
- package/plugin/channel/runtime/codex-config.ts +77 -0
- package/plugin/channel/runtime/codex.ts +669 -0
- package/plugin/channel/runtime/command.ts +62 -0
- package/plugin/channel/runtime/factory.ts +15 -0
- package/plugin/channel/runtime/json-rpc.ts +154 -0
- package/plugin/channel/runtime/kimi.ts +277 -0
- package/plugin/channel/runtime/opencode.ts +248 -0
- package/plugin/channel/runtime/process.ts +84 -0
- package/plugin/channel/runtime/types.ts +45 -0
- package/plugin/channel/runtime-files.ts +73 -0
- package/plugin/channel/runtime-gateway.js +6396 -0
- package/plugin/channel/runtime-gateway.ts +358 -0
- package/plugin/channel/runtime-metrics.ts +52 -0
- package/plugin/channel/runtime-subscription.ts +180 -0
- package/plugin/channel/terminal-delivery.ts +131 -0
- package/plugin/channel/untrusted.ts +60 -0
- package/plugin/commands/committee.md +31 -0
- package/plugin/commands/exit.md +32 -0
- package/plugin/commands/goals.md +20 -0
- package/plugin/commands/join.md +18 -0
- package/plugin/commands/notice.md +20 -0
- package/plugin/commands/propose.md +19 -0
- package/plugin/commands/round.md +17 -0
- package/plugin/commands/session.md +22 -0
- package/plugin/commands/settings.md +25 -0
- package/plugin/commands/setup.md +21 -0
- package/plugin/commands/team.md +23 -0
- package/plugin/domains/custom.md +27 -0
- package/plugin/domains/finance.md +31 -0
- package/plugin/domains/healthcare.md +34 -0
- package/plugin/domains/legal.md +31 -0
- package/plugin/domains/marketing.md +33 -0
- package/plugin/domains/operations.md +31 -0
- package/plugin/domains/research.md +32 -0
- package/plugin/domains/sales.md +30 -0
- package/plugin/domains/software.md +37 -0
- package/plugin/hooks/activity.sh +6 -0
- package/plugin/hooks/digging.sh +68 -0
- package/plugin/hooks/growth.sh +42 -0
- package/plugin/hooks/hooks.json +106 -0
- package/plugin/hooks/notice-on-pr.sh +35 -0
- package/plugin/hooks/notice-on-stop.sh +80 -0
- package/plugin/hooks/notice-pending.sh +58 -0
- package/plugin/hooks/solo-en-ciudad.sh +23 -0
- package/plugin/hooks/tokens.sh +47 -0
- package/plugin/roles/examples/account-executive.md +25 -0
- package/plugin/roles/examples/ai-manager.md +34 -0
- package/plugin/roles/examples/associate.md +24 -0
- package/plugin/roles/examples/brand-lead.md +20 -0
- package/plugin/roles/examples/cfo.md +20 -0
- package/plugin/roles/examples/city-lead.md +25 -0
- package/plugin/roles/examples/clinical-director.md +26 -0
- package/plugin/roles/examples/clinical-ops.md +25 -0
- package/plugin/roles/examples/clinician.md +25 -0
- package/plugin/roles/examples/compliance.md +27 -0
- package/plugin/roles/examples/content.md +24 -0
- package/plugin/roles/examples/controller.md +27 -0
- package/plugin/roles/examples/cpto.md +41 -0
- package/plugin/roles/examples/customer-success.md +25 -0
- package/plugin/roles/examples/data-engineer.md +37 -0
- package/plugin/roles/examples/data.md +60 -0
- package/plugin/roles/examples/dev.md +37 -0
- package/plugin/roles/examples/devops.md +39 -0
- package/plugin/roles/examples/enablement.md +24 -0
- package/plugin/roles/examples/ethics.md +24 -0
- package/plugin/roles/examples/fin-analytics.md +27 -0
- package/plugin/roles/examples/health-compliance.md +25 -0
- package/plugin/roles/examples/health-data.md +26 -0
- package/plugin/roles/examples/knowledge.md +25 -0
- package/plugin/roles/examples/lifecycle.md +26 -0
- package/plugin/roles/examples/llm-engineer.md +34 -0
- package/plugin/roles/examples/managing-partner.md +20 -0
- package/plugin/roles/examples/methods.md +23 -0
- package/plugin/roles/examples/operations-lead.md +23 -0
- package/plugin/roles/examples/ops.md +24 -0
- package/plugin/roles/examples/patient-safety.md +26 -0
- package/plugin/roles/examples/performance.md +27 -0
- package/plugin/roles/examples/po.md +36 -0
- package/plugin/roles/examples/process-owner.md +23 -0
- package/plugin/roles/examples/product-design.md +39 -0
- package/plugin/roles/examples/program-manager.md +24 -0
- package/plugin/roles/examples/quality.md +25 -0
- package/plugin/roles/examples/research-director.md +24 -0
- package/plugin/roles/examples/research-ops.md +25 -0
- package/plugin/roles/examples/researcher.md +25 -0
- package/plugin/roles/examples/revenue-lead.md +25 -0
- package/plugin/roles/examples/revops.md +26 -0
- package/plugin/roles/examples/seo.md +27 -0
- package/plugin/roles/examples/specialist.md +24 -0
- package/plugin/scripts/admision.py +165 -0
- package/plugin/scripts/apaga.py +232 -0
- package/plugin/scripts/avatar.py +211 -0
- package/plugin/scripts/broker.py +531 -0
- package/plugin/scripts/cage.py +252 -0
- package/plugin/scripts/capabilities.py +197 -0
- package/plugin/scripts/card.py +312 -0
- package/plugin/scripts/cities.py +520 -0
- package/plugin/scripts/city-env.sh +90 -0
- package/plugin/scripts/city-runtime.sh +87 -0
- package/plugin/scripts/city-session.sh +526 -0
- package/plugin/scripts/city_env.py +67 -0
- package/plugin/scripts/crecimiento.py +140 -0
- package/plugin/scripts/deliberations.py +48 -0
- package/plugin/scripts/doctor.py +172 -0
- package/plugin/scripts/domains.py +201 -0
- package/plugin/scripts/evidencia.py +64 -0
- package/plugin/scripts/find-repos.sh +111 -0
- package/plugin/scripts/gh.py +159 -0
- package/plugin/scripts/hall_protocol.py +147 -0
- package/plugin/scripts/hook_activity.py +139 -0
- package/plugin/scripts/launch.py +94 -0
- package/plugin/scripts/logs.py +98 -0
- package/plugin/scripts/pairing.py +185 -0
- package/plugin/scripts/parcels.py +132 -0
- package/plugin/scripts/read-card.py +51 -0
- package/plugin/scripts/report.py +201 -0
- package/plugin/scripts/reset.py +180 -0
- package/plugin/scripts/roads.py +230 -0
- package/plugin/scripts/roles.py +274 -0
- package/plugin/scripts/runtime_log.py +69 -0
- package/plugin/scripts/runtime_processes.py +132 -0
- package/plugin/scripts/rutas.py +83 -0
- package/plugin/scripts/seat.py +1349 -0
- package/plugin/scripts/tokens.py +194 -0
- package/plugin/scripts/trust-repos.py +59 -0
- package/plugin/scripts/ui.py +280 -0
- package/plugin/scripts/units.py +80 -0
- package/plugin/scripts/workspace.py +364 -0
- package/plugin/skills/city/SKILL.md +191 -0
- package/templates/blank.md +39 -0
- package/templates/finance.md +44 -0
- package/templates/legal.md +45 -0
- package/templates/marketing.md +48 -0
- package/templates/product.md +45 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Report token spend to the city, from your own agent transcripts.
|
|
3
|
+
|
|
4
|
+
Claude Code records the usage of every message it makes — input, output, and both
|
|
5
|
+
kinds of cache — in the session transcripts under ~/.claude/projects. This walks
|
|
6
|
+
them, adds them up per day and per model, and reports the totals.
|
|
7
|
+
|
|
8
|
+
./bin/tokens.py show what it would send
|
|
9
|
+
./bin/tokens.py --push send it
|
|
10
|
+
./bin/tokens.py --days 7 only the last 7 days (default 30)
|
|
11
|
+
|
|
12
|
+
What leaves your machine: numbers. A day, a model name, and four counts. Never a
|
|
13
|
+
prompt, never a file name, never a project path.
|
|
14
|
+
|
|
15
|
+
And what the city does with it: a **global** total. Spend is stored per person
|
|
16
|
+
because that is the only way to deduplicate reports, but there is no endpoint
|
|
17
|
+
that ranks people by it and there is not going to be one. A leaderboard changes
|
|
18
|
+
what people do, and what this map is for is the opposite.
|
|
19
|
+
|
|
20
|
+
Incremental, and carefully so: it remembers the size and mtime of every
|
|
21
|
+
transcript *and the totals it got from it*, so a second run re-reads only what
|
|
22
|
+
changed and still reports the complete picture. Caching only the marks would be
|
|
23
|
+
worse than not caching at all — reports are absolute per day, so a run that
|
|
24
|
+
skipped the other files touched today would overwrite today with a fraction of
|
|
25
|
+
it.
|
|
26
|
+
"""
|
|
27
|
+
import argparse
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
import urllib.request
|
|
32
|
+
from collections import defaultdict
|
|
33
|
+
from datetime import datetime, timedelta, timezone
|
|
34
|
+
|
|
35
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
36
|
+
import city_env
|
|
37
|
+
|
|
38
|
+
PROYECTOS = os.path.expanduser('~/.claude/projects')
|
|
39
|
+
ESTADO = os.path.join(city_env.CANAL, 'tokens-state.json')
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def estado_previo():
|
|
43
|
+
"""{path: {"m": "<size>:<mtime>", "r": {"<day>|<model>": [in, out, cr, cw]}}}"""
|
|
44
|
+
try:
|
|
45
|
+
with open(ESTADO) as f:
|
|
46
|
+
d = json.load(f)
|
|
47
|
+
return d if isinstance(d, dict) else {}
|
|
48
|
+
except Exception:
|
|
49
|
+
return {}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def guarda_estado(d):
|
|
53
|
+
os.makedirs(os.path.dirname(ESTADO), exist_ok=True)
|
|
54
|
+
with open(ESTADO, 'w') as f:
|
|
55
|
+
json.dump(d, f)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def transcripciones(desde_ts):
|
|
59
|
+
for raiz, _, ficheros in os.walk(PROYECTOS):
|
|
60
|
+
for f in ficheros:
|
|
61
|
+
if not f.endswith('.jsonl'):
|
|
62
|
+
continue
|
|
63
|
+
p = os.path.join(raiz, f)
|
|
64
|
+
try:
|
|
65
|
+
st = os.stat(p)
|
|
66
|
+
except OSError:
|
|
67
|
+
continue
|
|
68
|
+
if st.st_mtime < desde_ts:
|
|
69
|
+
continue
|
|
70
|
+
yield p, st
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def suma(fichero):
|
|
74
|
+
"""Add up one transcript, returning {"<day>|<model>": [in, out, cr, cw]}.
|
|
75
|
+
|
|
76
|
+
Tolerant on purpose: a half-written line at the end of a live session is
|
|
77
|
+
normal, not an error worth stopping for.
|
|
78
|
+
"""
|
|
79
|
+
dias = defaultdict(lambda: {'input': 0, 'output': 0, 'cache_read': 0, 'cache_write': 0})
|
|
80
|
+
try:
|
|
81
|
+
fh = open(fichero, errors='replace')
|
|
82
|
+
except OSError:
|
|
83
|
+
return {}
|
|
84
|
+
with fh:
|
|
85
|
+
for linea in fh:
|
|
86
|
+
if '"usage"' not in linea:
|
|
87
|
+
continue
|
|
88
|
+
try:
|
|
89
|
+
d = json.loads(linea)
|
|
90
|
+
except Exception:
|
|
91
|
+
continue
|
|
92
|
+
m = d.get('message') or {}
|
|
93
|
+
u = m.get('usage') or d.get('usage')
|
|
94
|
+
ts = d.get('timestamp') or ''
|
|
95
|
+
if not u or len(ts) < 10:
|
|
96
|
+
continue
|
|
97
|
+
clave = ts[:10] + '|' + (m.get('model') or d.get('model') or '')[:40]
|
|
98
|
+
a = dias[clave]
|
|
99
|
+
a['input'] += u.get('input_tokens', 0) or 0
|
|
100
|
+
a['output'] += u.get('output_tokens', 0) or 0
|
|
101
|
+
a['cache_read'] += u.get('cache_read_input_tokens', 0) or 0
|
|
102
|
+
a['cache_write'] += u.get('cache_creation_input_tokens', 0) or 0
|
|
103
|
+
return {k: [v['input'], v['output'], v['cache_read'], v['cache_write']]
|
|
104
|
+
for k, v in dias.items()}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def acumula(previo, corte):
|
|
108
|
+
"""Every transcript in the window, summed — the cached rows included, because
|
|
109
|
+
the report is the whole window every time (the city stores it absolutely)."""
|
|
110
|
+
nuevo = {}
|
|
111
|
+
total = defaultdict(lambda: [0, 0, 0, 0])
|
|
112
|
+
leidos = saltados = 0
|
|
113
|
+
for p, st in transcripciones(corte.timestamp()):
|
|
114
|
+
marca = f'{st.st_size}:{int(st.st_mtime)}'
|
|
115
|
+
antes = previo.get(p)
|
|
116
|
+
if isinstance(antes, dict) and antes.get('m') == marca:
|
|
117
|
+
filas_f = antes.get('r') or {}
|
|
118
|
+
saltados += 1
|
|
119
|
+
else:
|
|
120
|
+
filas_f = suma(p)
|
|
121
|
+
leidos += 1
|
|
122
|
+
nuevo[p] = {'m': marca, 'r': filas_f}
|
|
123
|
+
for k, v in filas_f.items():
|
|
124
|
+
t = total[k]
|
|
125
|
+
for i in range(4):
|
|
126
|
+
t[i] += v[i]
|
|
127
|
+
return nuevo, total, leidos, saltados
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def main():
|
|
131
|
+
ap = argparse.ArgumentParser()
|
|
132
|
+
ap.add_argument('--url', default=city_env.url())
|
|
133
|
+
ap.add_argument('--token', default='')
|
|
134
|
+
ap.add_argument('--days', type=int, default=30)
|
|
135
|
+
ap.add_argument('--push', action='store_true')
|
|
136
|
+
ap.add_argument('--all', action='store_true', help='re-read every transcript, ignoring state')
|
|
137
|
+
ap.add_argument('--quiet', action='store_true', help='say nothing unless it fails')
|
|
138
|
+
a = ap.parse_args()
|
|
139
|
+
|
|
140
|
+
di = (lambda *a, **k: None) if a.quiet else print
|
|
141
|
+
if not os.path.isdir(PROYECTOS):
|
|
142
|
+
di(f'No transcripts at {PROYECTOS}. Nothing to report.')
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
corte = datetime.now(timezone.utc) - timedelta(days=a.days)
|
|
146
|
+
desde = corte.strftime('%Y-%m-%d')
|
|
147
|
+
previo = {} if a.all else estado_previo()
|
|
148
|
+
nuevo, total, leidos, saltados = acumula(previo, corte)
|
|
149
|
+
|
|
150
|
+
filas = [{'day': k.split('|', 1)[0], 'model': k.split('|', 1)[1],
|
|
151
|
+
'input': v[0], 'output': v[1], 'cache_read': v[2], 'cache_write': v[3]}
|
|
152
|
+
for k, v in sorted(total.items()) if k.split('|', 1)[0] >= desde]
|
|
153
|
+
|
|
154
|
+
if not filas:
|
|
155
|
+
di(f'Nothing new in the last {a.days} days. ({saltados} transcripts unchanged.)')
|
|
156
|
+
return 0
|
|
157
|
+
|
|
158
|
+
por_dia = defaultdict(int)
|
|
159
|
+
for f in filas:
|
|
160
|
+
por_dia[f['day']] += f['input'] + f['output']
|
|
161
|
+
di(f' {leidos} transcripts read, {saltados} unchanged\n')
|
|
162
|
+
for d in sorted(por_dia)[-10:]:
|
|
163
|
+
di(f' {d} {por_dia[d]:>12,} tokens')
|
|
164
|
+
total = sum(por_dia.values())
|
|
165
|
+
di(f'\n {total:,} tokens across {len(por_dia)} days, {len(filas)} day/model rows')
|
|
166
|
+
|
|
167
|
+
if not a.push:
|
|
168
|
+
guarda_estado(nuevo) # the reading is the expensive part; keep it either way
|
|
169
|
+
di('\n Nothing sent. Add --push when it looks right.')
|
|
170
|
+
return 0
|
|
171
|
+
if not a.url:
|
|
172
|
+
print('\n No city URL. Pass --url or set AGENTS_CITY_URL.', file=sys.stderr)
|
|
173
|
+
return 1
|
|
174
|
+
tk = city_env.token(a.token)
|
|
175
|
+
if not tk:
|
|
176
|
+
print('\n No bus token. Pass --token or set CITY_BUS_TOKEN.', file=sys.stderr)
|
|
177
|
+
return 1
|
|
178
|
+
|
|
179
|
+
req = urllib.request.Request(
|
|
180
|
+
a.url.rstrip('/') + '/api/tokens',
|
|
181
|
+
data=json.dumps({'days': filas}).encode(),
|
|
182
|
+
headers={'content-type': 'application/json', 'authorization': f'Bearer {tk}'})
|
|
183
|
+
try:
|
|
184
|
+
with urllib.request.urlopen(req, timeout=60) as r:
|
|
185
|
+
di('\n ' + r.read().decode())
|
|
186
|
+
guarda_estado(nuevo)
|
|
187
|
+
except Exception as e:
|
|
188
|
+
print(f'\n The city refused it: {e}', file=sys.stderr)
|
|
189
|
+
return 1
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
if __name__ == '__main__':
|
|
194
|
+
sys.exit(main())
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Mark the folders this session is about to open as already trusted.
|
|
3
|
+
|
|
4
|
+
trust-repos.py <path> [<path>...]
|
|
5
|
+
|
|
6
|
+
Without it every tmux window sits on the "do you trust this folder?" dialog and
|
|
7
|
+
you answer them one at a time.
|
|
8
|
+
|
|
9
|
+
It touches only the `hasTrustDialogAccepted` key of the paths it is given — the
|
|
10
|
+
ones from this person's own card — and nothing else in ~/.claude.json.
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
|
|
17
|
+
CONF = os.path.expanduser("~/.claude.json")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main():
|
|
21
|
+
rutas = [os.path.realpath(r) for r in sys.argv[1:] if r]
|
|
22
|
+
if not rutas:
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
with open(CONF, encoding="utf-8") as f:
|
|
27
|
+
d = json.load(f)
|
|
28
|
+
except FileNotFoundError:
|
|
29
|
+
d = {}
|
|
30
|
+
except (ValueError, OSError) as e:
|
|
31
|
+
print(f"[city] cannot read {CONF}: {e}", file=sys.stderr)
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
proyectos = d.setdefault("projects", {})
|
|
35
|
+
nuevas = 0
|
|
36
|
+
for r in rutas:
|
|
37
|
+
p = proyectos.setdefault(r, {})
|
|
38
|
+
if not p.get("hasTrustDialogAccepted"):
|
|
39
|
+
p["hasTrustDialogAccepted"] = True
|
|
40
|
+
nuevas += 1
|
|
41
|
+
|
|
42
|
+
if nuevas == 0:
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
# Written atomically: this file is big, and losing it hurts.
|
|
46
|
+
carpeta = os.path.dirname(CONF)
|
|
47
|
+
fd, tmp = tempfile.mkstemp(dir=carpeta, prefix=".claude.json.")
|
|
48
|
+
try:
|
|
49
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
50
|
+
json.dump(d, f, indent=2, ensure_ascii=False)
|
|
51
|
+
os.replace(tmp, CONF)
|
|
52
|
+
print(f"[city] trusted {nuevas} folder(s)", file=sys.stderr)
|
|
53
|
+
except OSError as e:
|
|
54
|
+
if os.path.exists(tmp):
|
|
55
|
+
os.unlink(tmp)
|
|
56
|
+
print(f"[city] could not save {CONF}: {e}", file=sys.stderr)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
main()
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Terminal widgets for the setup wizard. Standard library only, no deps.
|
|
3
|
+
|
|
4
|
+
Everything here degrades: if there is no TTY — a pipe, CI, an agent running the
|
|
5
|
+
command — each widget falls back to plain prompts or to its default. A wizard
|
|
6
|
+
that only works in a nice terminal is a wizard that breaks in half the places
|
|
7
|
+
people actually run things.
|
|
8
|
+
"""
|
|
9
|
+
import curses
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import textwrap
|
|
13
|
+
|
|
14
|
+
TTY = sys.stdin.isatty() and sys.stdout.isatty()
|
|
15
|
+
|
|
16
|
+
# Scripted answers, for tests and for CI. A wizard that can only be exercised by
|
|
17
|
+
# hand is a wizard whose logic nobody checks, and the logic is where the bugs are:
|
|
18
|
+
# every widget takes its answer from here when the file is present.
|
|
19
|
+
_GUION = None
|
|
20
|
+
if os.environ.get('AGENTS_CITY_ANSWERS'):
|
|
21
|
+
import json as _json
|
|
22
|
+
with open(os.environ['AGENTS_CITY_ANSWERS']) as _f:
|
|
23
|
+
_GUION = _json.load(_f)
|
|
24
|
+
TTY = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _guionizado(clave, defecto=None):
|
|
28
|
+
"""The scripted answer for this widget, by title.
|
|
29
|
+
|
|
30
|
+
A plain value is *the* answer, used however many times the widget asks — which
|
|
31
|
+
matters because a list is the answer for a multi-select, not a queue of
|
|
32
|
+
answers. For the widgets asked repeatedly in a loop ("another unit", "another
|
|
33
|
+
username") wrap it: {"queue": ["a", "b", ""]}.
|
|
34
|
+
"""
|
|
35
|
+
if _GUION is None:
|
|
36
|
+
return ('sin guion', None)
|
|
37
|
+
if clave not in _GUION:
|
|
38
|
+
return ('falta', defecto)
|
|
39
|
+
v = _GUION[clave]
|
|
40
|
+
if isinstance(v, dict) and 'queue' in v:
|
|
41
|
+
cola = v['queue']
|
|
42
|
+
return ('ok', cola.pop(0)) if cola else ('agotado', defecto)
|
|
43
|
+
return ('ok', v)
|
|
44
|
+
|
|
45
|
+
# ── colours ────────────────────────────────────────────────────────────────
|
|
46
|
+
INK, DIM, ACCENT, GOOD, WARN = 1, 2, 3, 4, 5
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _pairs():
|
|
50
|
+
curses.start_color()
|
|
51
|
+
curses.use_default_colors()
|
|
52
|
+
curses.init_pair(INK, curses.COLOR_WHITE, -1)
|
|
53
|
+
curses.init_pair(DIM, curses.COLOR_BLUE, -1)
|
|
54
|
+
curses.init_pair(ACCENT, curses.COLOR_YELLOW, -1)
|
|
55
|
+
curses.init_pair(GOOD, curses.COLOR_GREEN, -1)
|
|
56
|
+
curses.init_pair(WARN, curses.COLOR_RED, -1)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _chrome(win, title, step, of, hint):
|
|
60
|
+
"""Same frame on every screen: where you are, and which keys work here."""
|
|
61
|
+
h, w = win.getmaxyx()
|
|
62
|
+
win.attron(curses.color_pair(ACCENT) | curses.A_BOLD)
|
|
63
|
+
win.addnstr(0, 2, "AGENTS CITY", w - 4)
|
|
64
|
+
win.attroff(curses.color_pair(ACCENT) | curses.A_BOLD)
|
|
65
|
+
if step:
|
|
66
|
+
win.attron(curses.color_pair(DIM))
|
|
67
|
+
win.addnstr(0, 15, f"step {step} of {of}", w - 17)
|
|
68
|
+
win.attroff(curses.color_pair(DIM))
|
|
69
|
+
win.attron(curses.A_BOLD)
|
|
70
|
+
win.addnstr(2, 2, title[: w - 4], w - 4)
|
|
71
|
+
win.attroff(curses.A_BOLD)
|
|
72
|
+
win.attron(curses.color_pair(DIM))
|
|
73
|
+
win.addnstr(h - 1, 2, hint[: w - 4], w - 4)
|
|
74
|
+
win.attroff(curses.color_pair(DIM))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def texto(cuerpo, w):
|
|
78
|
+
"""Wrap for the terminal, keeping blank lines as paragraph breaks."""
|
|
79
|
+
out = []
|
|
80
|
+
for p in cuerpo.split("\n\n"):
|
|
81
|
+
out += textwrap.wrap(" ".join(p.split()), max(20, w - 6)) or [""]
|
|
82
|
+
out.append("")
|
|
83
|
+
return out[:-1] if out else out
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def pantalla(titulo, cuerpo, step=None, of=None, hint="enter to continue · q to quit"):
|
|
87
|
+
"""A page of text. Returns False if the person quits."""
|
|
88
|
+
if not TTY:
|
|
89
|
+
estado, v = _guionizado(titulo, True)
|
|
90
|
+
print(f"[{titulo}] {'skipped' if v is False else 'read'}")
|
|
91
|
+
return True if v is None else bool(v)
|
|
92
|
+
|
|
93
|
+
def run(win):
|
|
94
|
+
curses.curs_set(0)
|
|
95
|
+
_pairs()
|
|
96
|
+
while True:
|
|
97
|
+
win.erase()
|
|
98
|
+
h, w = win.getmaxyx()
|
|
99
|
+
_chrome(win, titulo, step, of, hint)
|
|
100
|
+
for i, l in enumerate(texto(cuerpo, w)[: h - 7]):
|
|
101
|
+
win.addnstr(4 + i, 2, l, w - 4)
|
|
102
|
+
win.refresh()
|
|
103
|
+
k = win.getch()
|
|
104
|
+
if k in (10, 13, curses.KEY_ENTER, ord(" ")):
|
|
105
|
+
return True
|
|
106
|
+
if k in (ord("q"), 27):
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
return curses.wrapper(run)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def elige(titulo, opciones, marcadas=None, step=None, of=None, minimo=0, ayuda=""):
|
|
113
|
+
"""Multi-select with checkboxes. `opciones` is [(id, label, detail)].
|
|
114
|
+
|
|
115
|
+
Returns the list of chosen ids, or None if the person quits.
|
|
116
|
+
"""
|
|
117
|
+
marcadas = set(marcadas or [])
|
|
118
|
+
if not TTY:
|
|
119
|
+
estado, v = _guionizado(titulo)
|
|
120
|
+
elegidas = sorted(marcadas) if v is None else v
|
|
121
|
+
print(f"[{titulo}] {elegidas}")
|
|
122
|
+
return elegidas
|
|
123
|
+
|
|
124
|
+
def run(win):
|
|
125
|
+
curses.curs_set(0)
|
|
126
|
+
_pairs()
|
|
127
|
+
sel, top, filtro, escribiendo = 0, 0, "", False
|
|
128
|
+
while True:
|
|
129
|
+
win.erase()
|
|
130
|
+
h, w = win.getmaxyx()
|
|
131
|
+
visibles = [o for o in opciones
|
|
132
|
+
if not filtro or filtro.lower() in (o[0] + " " + o[1]).lower()]
|
|
133
|
+
sel = max(0, min(sel, len(visibles) - 1))
|
|
134
|
+
filas = h - 8
|
|
135
|
+
if sel < top:
|
|
136
|
+
top = sel
|
|
137
|
+
if sel >= top + filas:
|
|
138
|
+
top = sel - filas + 1
|
|
139
|
+
|
|
140
|
+
hint = ("type to filter · esc clear" if escribiendo else
|
|
141
|
+
"↑↓ move · space pick · / filter · a all · n none · enter done · q quit")
|
|
142
|
+
_chrome(win, titulo, step, of, hint)
|
|
143
|
+
if ayuda:
|
|
144
|
+
win.attron(curses.color_pair(DIM))
|
|
145
|
+
win.addnstr(3, 2, ayuda[: w - 4], w - 4)
|
|
146
|
+
win.attroff(curses.color_pair(DIM))
|
|
147
|
+
|
|
148
|
+
for i, (oid, etiqueta, detalle) in enumerate(visibles[top:top + filas]):
|
|
149
|
+
y = 5 + i
|
|
150
|
+
puesto = top + i == sel
|
|
151
|
+
marca = "[x]" if oid in marcadas else "[ ]"
|
|
152
|
+
if puesto:
|
|
153
|
+
win.attron(curses.A_REVERSE)
|
|
154
|
+
win.addnstr(y, 2, f" {marca} {etiqueta} ", w - 4)
|
|
155
|
+
if puesto:
|
|
156
|
+
win.attroff(curses.A_REVERSE)
|
|
157
|
+
if detalle:
|
|
158
|
+
x = 2 + len(f" {marca} {etiqueta} ")
|
|
159
|
+
if x < w - 6:
|
|
160
|
+
win.attron(curses.color_pair(DIM))
|
|
161
|
+
win.addnstr(y, x, detalle[: w - x - 3], w - x - 3)
|
|
162
|
+
win.attroff(curses.color_pair(DIM))
|
|
163
|
+
|
|
164
|
+
pie = f"{len(marcadas)} picked"
|
|
165
|
+
if filtro:
|
|
166
|
+
pie += f" · filter: {filtro}"
|
|
167
|
+
if minimo and len(marcadas) < minimo:
|
|
168
|
+
pie += f" · pick at least {minimo}"
|
|
169
|
+
win.attron(curses.color_pair(ACCENT))
|
|
170
|
+
win.addnstr(h - 3, 2, pie[: w - 4], w - 4)
|
|
171
|
+
win.attroff(curses.color_pair(ACCENT))
|
|
172
|
+
win.refresh()
|
|
173
|
+
|
|
174
|
+
k = win.getch()
|
|
175
|
+
if escribiendo:
|
|
176
|
+
if k in (10, 13, 27):
|
|
177
|
+
escribiendo = False
|
|
178
|
+
elif k in (curses.KEY_BACKSPACE, 127, 8):
|
|
179
|
+
filtro = filtro[:-1]
|
|
180
|
+
elif 32 <= k < 127:
|
|
181
|
+
filtro += chr(k)
|
|
182
|
+
continue
|
|
183
|
+
if k in (curses.KEY_UP, ord("k")):
|
|
184
|
+
sel -= 1
|
|
185
|
+
elif k in (curses.KEY_DOWN, ord("j")):
|
|
186
|
+
sel += 1
|
|
187
|
+
elif k == ord(" ") and visibles:
|
|
188
|
+
oid = visibles[sel][0]
|
|
189
|
+
marcadas.symmetric_difference_update({oid})
|
|
190
|
+
elif k == ord("/"):
|
|
191
|
+
escribiendo, filtro = True, ""
|
|
192
|
+
elif k == ord("a"):
|
|
193
|
+
marcadas.update(o[0] for o in visibles)
|
|
194
|
+
elif k == ord("n"):
|
|
195
|
+
marcadas.difference_update(o[0] for o in visibles)
|
|
196
|
+
elif k in (10, 13, curses.KEY_ENTER):
|
|
197
|
+
if len(marcadas) >= minimo:
|
|
198
|
+
return sorted(marcadas)
|
|
199
|
+
elif k in (ord("q"), 27):
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
return curses.wrapper(run)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def una(titulo, opciones, step=None, of=None, ayuda=""):
|
|
206
|
+
"""Single choice. Returns the id, or None.
|
|
207
|
+
|
|
208
|
+
Unlike `elige`, the cursor wraps: with one thing to pick, running off the end
|
|
209
|
+
and coming back round is what you want. Which also means an empty list would
|
|
210
|
+
divide by zero, so nothing to choose returns nothing rather than a traceback.
|
|
211
|
+
"""
|
|
212
|
+
if not opciones:
|
|
213
|
+
return None
|
|
214
|
+
if not TTY:
|
|
215
|
+
estado, v = _guionizado(titulo)
|
|
216
|
+
elegida = (v if v is not None else (opciones[0][0] if opciones else None))
|
|
217
|
+
print(f"[{titulo}] {elegida}")
|
|
218
|
+
return elegida
|
|
219
|
+
|
|
220
|
+
def run(win):
|
|
221
|
+
curses.curs_set(0)
|
|
222
|
+
_pairs()
|
|
223
|
+
sel = 0
|
|
224
|
+
while True:
|
|
225
|
+
win.erase()
|
|
226
|
+
h, w = win.getmaxyx()
|
|
227
|
+
_chrome(win, titulo, step, of, "↑↓ move · enter pick · q quit")
|
|
228
|
+
if ayuda:
|
|
229
|
+
win.attron(curses.color_pair(DIM))
|
|
230
|
+
for i, l in enumerate(texto(ayuda, w)[:3]):
|
|
231
|
+
win.addnstr(3 + i, 2, l, w - 4)
|
|
232
|
+
win.attroff(curses.color_pair(DIM))
|
|
233
|
+
for i, (_oid, etiqueta, detalle) in enumerate(opciones[: h - 9]):
|
|
234
|
+
y = 7 + i
|
|
235
|
+
if i == sel:
|
|
236
|
+
win.attron(curses.A_REVERSE)
|
|
237
|
+
win.addnstr(y, 2, f" {etiqueta} ", w - 4)
|
|
238
|
+
if i == sel:
|
|
239
|
+
win.attroff(curses.A_REVERSE)
|
|
240
|
+
if detalle:
|
|
241
|
+
x = 4 + len(etiqueta)
|
|
242
|
+
if x < w - 6:
|
|
243
|
+
win.attron(curses.color_pair(DIM))
|
|
244
|
+
win.addnstr(y, x, detalle[: w - x - 3], w - x - 3)
|
|
245
|
+
win.attroff(curses.color_pair(DIM))
|
|
246
|
+
win.refresh()
|
|
247
|
+
k = win.getch()
|
|
248
|
+
if k in (curses.KEY_UP, ord("k")):
|
|
249
|
+
sel = (sel - 1) % len(opciones)
|
|
250
|
+
elif k in (curses.KEY_DOWN, ord("j")):
|
|
251
|
+
sel = (sel + 1) % len(opciones)
|
|
252
|
+
elif k in (10, 13, curses.KEY_ENTER):
|
|
253
|
+
return opciones[sel][0]
|
|
254
|
+
elif k in (ord("q"), 27):
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
return curses.wrapper(run)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def pide(pregunta, defecto="", validar=None, ayuda=""):
|
|
261
|
+
"""One line of text, with a default and optional validation."""
|
|
262
|
+
if not TTY:
|
|
263
|
+
estado, v = _guionizado(pregunta, defecto)
|
|
264
|
+
print(f"[{pregunta}] {v!r}")
|
|
265
|
+
return v
|
|
266
|
+
while True:
|
|
267
|
+
curses.endwin() if curses.has_colors() and False else None
|
|
268
|
+
suf = f" [{defecto}]" if defecto else ""
|
|
269
|
+
if ayuda:
|
|
270
|
+
print(f"\n {ayuda}")
|
|
271
|
+
try:
|
|
272
|
+
v = input(f" {pregunta}{suf}: ").strip() or defecto
|
|
273
|
+
except (EOFError, KeyboardInterrupt):
|
|
274
|
+
return None
|
|
275
|
+
if validar:
|
|
276
|
+
problema = validar(v)
|
|
277
|
+
if problema:
|
|
278
|
+
print(f" ↳ {problema}")
|
|
279
|
+
continue
|
|
280
|
+
return v
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The one reader and writer of `units.yml` — the districts of a city.
|
|
3
|
+
|
|
4
|
+
Three places touched this file each in their own way: the seeder parsed it, the
|
|
5
|
+
wizard emitted it, and the seat emitted a one-district version of it. Three copies
|
|
6
|
+
of one format is how `parcels.yml` ended up with a reader that looked for a key no
|
|
7
|
+
writer wrote, so the districts get the same treatment before they get the chance.
|
|
8
|
+
|
|
9
|
+
The two special districts are part of the format, not of any caller: every city has
|
|
10
|
+
a `lab` (research that does not ship yet, and is needed) and a `none` (what serves
|
|
11
|
+
several units, or none). Writers emit them; readers hand them back like any other.
|
|
12
|
+
"""
|
|
13
|
+
import re
|
|
14
|
+
import parcels # the field syntax (`key: value ; key: value`) is the same one
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def lee(ruta):
|
|
18
|
+
"""Every district in the file, `special` ones included, in file order.
|
|
19
|
+
|
|
20
|
+
Returns [{'id', 'name', 'color', 'order', 'note'}]. Missing file reads as
|
|
21
|
+
empty — the caller decides whether an empty city is an error.
|
|
22
|
+
"""
|
|
23
|
+
try:
|
|
24
|
+
with open(ruta, encoding='utf-8') as f:
|
|
25
|
+
txt = f.read()
|
|
26
|
+
except (OSError, IsADirectoryError):
|
|
27
|
+
return []
|
|
28
|
+
fuera = []
|
|
29
|
+
for linea in txt.splitlines():
|
|
30
|
+
if linea.strip().startswith('- id:'):
|
|
31
|
+
c = parcels.campos(linea)
|
|
32
|
+
ident = c.get('id', '')
|
|
33
|
+
try:
|
|
34
|
+
orden = int(c.get('order', 50))
|
|
35
|
+
except ValueError:
|
|
36
|
+
orden = 50
|
|
37
|
+
fuera.append({'id': ident, 'name': c.get('name', ident),
|
|
38
|
+
'color': c.get('color', 'c8b48a'), 'order': orden,
|
|
39
|
+
'note': None})
|
|
40
|
+
elif 'note:' in linea and fuera:
|
|
41
|
+
fuera[-1]['note'] = linea.split('note:')[1].strip().strip('"')
|
|
42
|
+
return fuera
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def propias(ruta):
|
|
46
|
+
"""Only the districts the team declared — lab and none filtered out."""
|
|
47
|
+
return [u for u in lee(ruta) if u['id'] not in ('lab', 'none')]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def escribe(ruta, unidades):
|
|
51
|
+
"""Write the whole file: the team's districts plus the two special ones.
|
|
52
|
+
|
|
53
|
+
`unidades` is [{'id', 'name', 'color'}] in display order; `order` is assigned
|
|
54
|
+
from position because that is all it ever meant. Ids are cleaned the same way
|
|
55
|
+
the wizard cleans them, so a caller cannot write an id the map cannot use.
|
|
56
|
+
"""
|
|
57
|
+
lineas = ['# Business units — the districts of your city.',
|
|
58
|
+
'#',
|
|
59
|
+
'# The map reads this: names, colours and order come from here, not from the',
|
|
60
|
+
'# code. Change a name and the map redraws itself.',
|
|
61
|
+
'units:']
|
|
62
|
+
for i, u in enumerate(unidades, 1):
|
|
63
|
+
ident = re.sub(r'[^a-z0-9]+', '-', str(u['id']).lower()).strip('-')[:20] or f'u{i}'
|
|
64
|
+
color = str(u.get('color', 'c8b48a')).lstrip('#').lower()
|
|
65
|
+
if not re.fullmatch(r'[0-9a-f]{6}', color):
|
|
66
|
+
color = 'c8b48a'
|
|
67
|
+
nombre = str(u.get('name', ident)).replace(';', ',')[:20]
|
|
68
|
+
lineas.append(f' - id: {ident:<12} ; name: {nombre:<20} ; color: {color} ; order: {i}')
|
|
69
|
+
lineas += ['',
|
|
70
|
+
'# These two every city has, whatever the business. The lab does not ship yet',
|
|
71
|
+
'# and is needed; "none" is what serves several units, or none. Its houses',
|
|
72
|
+
'# are drawn with permanent scaffolding, and its growth is not compared',
|
|
73
|
+
'# against the shipping units: few floors there is early, not behind.',
|
|
74
|
+
'special:',
|
|
75
|
+
' - id: lab ; name: Lab ; color: 8fb8c9 ; order: 90',
|
|
76
|
+
' note: "doesn\'t ship yet, and it\'s needed"',
|
|
77
|
+
' - id: none ; name: No unit ; color: c8b48a ; order: 99',
|
|
78
|
+
' note: "serves several units, or none"']
|
|
79
|
+
with open(ruta, 'w', encoding='utf-8') as f:
|
|
80
|
+
f.write('\n'.join(lineas) + '\n')
|