agents-city 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +16 -0
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.es.md +2226 -0
- package/README.md +2196 -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 +119 -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/doctor +3 -0
- package/bin/exit +5 -0
- package/bin/hall +73 -0
- package/bin/hall.html +498 -0
- package/bin/logs +3 -0
- package/bin/navegador.mjs +243 -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 +1579 -0
- package/bin/setup.py +753 -0
- package/bin/shortcut +3 -0
- package/bin/skills +3 -0
- package/bin/test +133 -0
- package/bin/test-actualiza.py +130 -0
- package/bin/test-adapter.py +183 -0
- package/bin/test-admision.py +99 -0
- package/bin/test-atajos.py +301 -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 +374 -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 +551 -0
- package/bin/test-crecimiento.py +164 -0
- package/bin/test-demo.py +308 -0
- package/bin/test-doctor.py +139 -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-navegador.py +140 -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 +1598 -0
- package/bin/test-security.py +150 -0
- package/bin/test-serve.py +1251 -0
- package/bin/test-stress.py +64 -0
- package/bin/test-widgets.py +143 -0
- package/bin/test-workspace.py +251 -0
- package/bin/testlib.py +118 -0
- package/bin/tokens.py +14 -0
- package/bin/update +3 -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 +1363 -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 +1959 -0
- package/city/web/src/main.ts +2252 -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 +106 -0
- package/docs/glossary.md +55 -0
- package/docs/map-live-layers.md +73 -0
- package/docs/security.md +265 -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/actualiza.py +198 -0
- package/plugin/scripts/admision.py +165 -0
- package/plugin/scripts/apaga.py +232 -0
- package/plugin/scripts/atajos.py +506 -0
- package/plugin/scripts/avatar.py +211 -0
- package/plugin/scripts/broker.py +531 -0
- package/plugin/scripts/cage.py +492 -0
- package/plugin/scripts/capabilities.py +210 -0
- package/plugin/scripts/card.py +322 -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 +552 -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 +294 -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 +55 -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 +1696 -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 +561 -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,201 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Report growth from where the work actually happens.
|
|
3
|
+
|
|
4
|
+
The city's cron can count merged pull requests by itself. It cannot run a command
|
|
5
|
+
inside your folders — and in a marketing, legal or finance city, growth is
|
|
6
|
+
whatever a command in that folder returns: pieces published, documents filed,
|
|
7
|
+
periods closed.
|
|
8
|
+
|
|
9
|
+
So this runs where the folders are, once a day is plenty, and reports the totals.
|
|
10
|
+
The city works out what changed today.
|
|
11
|
+
|
|
12
|
+
./bin/report.py show what it would send
|
|
13
|
+
./bin/report.py --push send it
|
|
14
|
+
./bin/report.py --data ../mine read a different data repo
|
|
15
|
+
|
|
16
|
+
Nothing to run by hand in the normal case: the plugin reports once a day from a
|
|
17
|
+
Stop hook. This is here for the first look, and for a backfill.
|
|
18
|
+
|
|
19
|
+
It needs your bus token, because the city does not take numbers from anonymous
|
|
20
|
+
callers: `CITY_BUS_TOKEN`, or the plugin's keychain entry, or `--token`.
|
|
21
|
+
"""
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
import urllib.request
|
|
29
|
+
|
|
30
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
31
|
+
import city_env
|
|
32
|
+
import parcels # the one reader of parcels.yml, shared with the seeder
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def lee(fichero, clave, defecto=''):
|
|
36
|
+
try:
|
|
37
|
+
t = open(fichero).read()
|
|
38
|
+
except FileNotFoundError:
|
|
39
|
+
return defecto
|
|
40
|
+
m = re.search(rf'^{clave}:\s*(.+)$', t, re.M)
|
|
41
|
+
return m.group(1).strip() if m else defecto
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_DONDE = {}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def donde_esta(repo):
|
|
48
|
+
"""The local folder of a repo, by the name on its remote.
|
|
49
|
+
|
|
50
|
+
`parcels.yml` deliberately holds no local paths: the same file is shared by a
|
|
51
|
+
whole team and the same repo sits somewhere different on every machine. That is
|
|
52
|
+
what find-repos.sh is for, and it caches its index for a day.
|
|
53
|
+
"""
|
|
54
|
+
if repo not in _DONDE:
|
|
55
|
+
guion = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'find-repos.sh')
|
|
56
|
+
try:
|
|
57
|
+
r = subprocess.run([guion, repo], capture_output=True, text=True, timeout=300)
|
|
58
|
+
_DONDE[repo] = r.stdout.strip()
|
|
59
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
60
|
+
_DONDE[repo] = ''
|
|
61
|
+
return _DONDE[repo]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parcelas(datos):
|
|
65
|
+
"""Every parcel, with the folder to run its grow command in.
|
|
66
|
+
|
|
67
|
+
The format is read by `parcels.py`, which the seeder reads too. All this adds is
|
|
68
|
+
the one thing that is local to this machine: where the folder actually is.
|
|
69
|
+
|
|
70
|
+
The folder used to be read as `c.get('path')` — a key this format does not have
|
|
71
|
+
and no writer has ever written; it is `ruta`. So it was always empty, every
|
|
72
|
+
parcel was skipped as folderless, and this script could not report a single
|
|
73
|
+
number for anybody. The message it printed sent you to add a `path:` that means
|
|
74
|
+
nothing.
|
|
75
|
+
|
|
76
|
+
`ruta` is a glob *inside* the repo, not a path to it. A concrete subdirectory
|
|
77
|
+
becomes the folder; a glob, or an empty one, means the whole repo, because there
|
|
78
|
+
is no single directory a pattern points at.
|
|
79
|
+
"""
|
|
80
|
+
declaradas, _, raras = parcels.lee(f'{datos}/parcels.yml')
|
|
81
|
+
if raras:
|
|
82
|
+
print(parcels.aviso(raras), file=sys.stderr)
|
|
83
|
+
fuera = []
|
|
84
|
+
for p in declaradas:
|
|
85
|
+
base = donde_esta(p['repo'])
|
|
86
|
+
carpeta = base
|
|
87
|
+
if base and p['ruta'] and not any(c in p['ruta'] for c in '*?['):
|
|
88
|
+
dentro = os.path.join(base, p['ruta'])
|
|
89
|
+
if os.path.isdir(dentro):
|
|
90
|
+
carpeta = dentro
|
|
91
|
+
fuera.append(dict(p, path=carpeta))
|
|
92
|
+
return fuera
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def cuenta(comando, cwd):
|
|
96
|
+
"""Run the grow command and take the first number it prints.
|
|
97
|
+
|
|
98
|
+
A command that fails counts as *unknown*, not as zero. Reporting zero because
|
|
99
|
+
a script broke would look like work being undone, which is worse than a gap.
|
|
100
|
+
"""
|
|
101
|
+
if not comando:
|
|
102
|
+
return None
|
|
103
|
+
try:
|
|
104
|
+
r = subprocess.run(comando, shell=True, cwd=cwd, capture_output=True,
|
|
105
|
+
text=True, timeout=120)
|
|
106
|
+
except subprocess.TimeoutExpired:
|
|
107
|
+
return None
|
|
108
|
+
if r.returncode != 0:
|
|
109
|
+
return None
|
|
110
|
+
m = re.search(r'-?\d+', r.stdout.replace(',', ''))
|
|
111
|
+
return int(m.group()) if m else None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def mide(datos, por_defecto, todas):
|
|
115
|
+
"""Run each parcel's grow command where it lives. A command that fails is
|
|
116
|
+
*unknown*, never zero — zero would look like work being undone."""
|
|
117
|
+
filas, sin_medir = [], []
|
|
118
|
+
for p in todas:
|
|
119
|
+
carpeta = os.path.expanduser(p['path']) if p['path'] else None
|
|
120
|
+
if not carpeta or not os.path.isdir(carpeta):
|
|
121
|
+
continue
|
|
122
|
+
# The folder's own descriptor wins over the city default: a parcel knows
|
|
123
|
+
# better than the city how it is counted.
|
|
124
|
+
cmd = lee(os.path.join(carpeta, '.city.yml'), 'grow_command', por_defecto)
|
|
125
|
+
n = cuenta(cmd, carpeta)
|
|
126
|
+
if n is None:
|
|
127
|
+
sin_medir.append(p['id'])
|
|
128
|
+
else:
|
|
129
|
+
filas.append({'id': p['id'], 'floors': n})
|
|
130
|
+
return filas, sin_medir
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def explica_vacio(di, datos, todas, por_defecto):
|
|
134
|
+
"""Nothing measured: say which of the three reasons it is, precisely. The
|
|
135
|
+
version before this printed advice about a field that does not exist."""
|
|
136
|
+
perdidas = [p['repo'] for p in todas if not p['path']]
|
|
137
|
+
di(f'Nothing to report from {datos}.')
|
|
138
|
+
if not todas:
|
|
139
|
+
di('There are no parcels in parcels.yml yet. ./bin/seat writes one per '
|
|
140
|
+
'folder you pick.')
|
|
141
|
+
elif perdidas:
|
|
142
|
+
di(f'{len(perdidas)} of {len(todas)} parcels are not cloned on this '
|
|
143
|
+
f'machine, so there is no folder to run anything in:')
|
|
144
|
+
di(' ' + ', '.join(sorted(set(perdidas))[:10]))
|
|
145
|
+
di('Clone them, or run ./plugin/scripts/find-repos.sh --refresh if they '
|
|
146
|
+
'are here under another folder name.')
|
|
147
|
+
elif not por_defecto:
|
|
148
|
+
di('The folders are here, but nothing says how growth is counted. Put '
|
|
149
|
+
'`grow_command` in city.yml, or a `.city.yml` in each folder.')
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def main():
|
|
153
|
+
ap = argparse.ArgumentParser()
|
|
154
|
+
ap.add_argument('--data', default=city_env.datos())
|
|
155
|
+
ap.add_argument('--url', default=city_env.url())
|
|
156
|
+
ap.add_argument('--token', default='')
|
|
157
|
+
ap.add_argument('--push', action='store_true')
|
|
158
|
+
ap.add_argument('--quiet', action='store_true', help='say nothing unless it fails')
|
|
159
|
+
a = ap.parse_args()
|
|
160
|
+
di = (lambda *x, **k: None) if a.quiet else print
|
|
161
|
+
datos = os.path.expanduser(a.data)
|
|
162
|
+
|
|
163
|
+
por_defecto = lee(f'{datos}/city.yml', 'grow_command')
|
|
164
|
+
todas = parcelas(datos)
|
|
165
|
+
filas, sin_medir = mide(datos, por_defecto, todas)
|
|
166
|
+
|
|
167
|
+
if not filas and not sin_medir:
|
|
168
|
+
explica_vacio(di, datos, todas, por_defecto)
|
|
169
|
+
return 0
|
|
170
|
+
|
|
171
|
+
for f in filas:
|
|
172
|
+
di(f" {f['id']:<34} {f['floors']}")
|
|
173
|
+
for s in sin_medir:
|
|
174
|
+
di(f' {s:<34} — could not measure (command failed or returned nothing)')
|
|
175
|
+
|
|
176
|
+
if not a.push:
|
|
177
|
+
di('\nNothing sent. Add --push when it looks right.')
|
|
178
|
+
return 0
|
|
179
|
+
if not a.url:
|
|
180
|
+
print('\nNo city URL. Pass --url or set AGENTS_CITY_URL.', file=sys.stderr)
|
|
181
|
+
return 1
|
|
182
|
+
tk = city_env.token(a.token)
|
|
183
|
+
if not tk:
|
|
184
|
+
print('\nNo bus token. Pass --token or set CITY_BUS_TOKEN.', file=sys.stderr)
|
|
185
|
+
return 1
|
|
186
|
+
|
|
187
|
+
req = urllib.request.Request(
|
|
188
|
+
a.url.rstrip('/') + '/api/growth',
|
|
189
|
+
data=json.dumps({'parcels': filas}).encode(),
|
|
190
|
+
headers={'content-type': 'application/json', 'authorization': f'Bearer {tk}'})
|
|
191
|
+
try:
|
|
192
|
+
with urllib.request.urlopen(req, timeout=60) as r:
|
|
193
|
+
di('\n' + r.read().decode())
|
|
194
|
+
except Exception as e:
|
|
195
|
+
print(f'\nThe city refused it: {e}', file=sys.stderr)
|
|
196
|
+
return 1
|
|
197
|
+
return 0
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
if __name__ == '__main__':
|
|
201
|
+
sys.exit(main())
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Reset one managed city to first-run state, recoverably.
|
|
3
|
+
|
|
4
|
+
The target is always explicit. Repos are references and are never touched. The
|
|
5
|
+
city folder and its local map state move to a timestamped backup, incident local
|
|
6
|
+
roads are removed at both ends, and an empty city with the same stable identity
|
|
7
|
+
is created in its place. The next ``agents-city seat --city <name>`` runs the
|
|
8
|
+
five onboarding questions again.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
import cities
|
|
19
|
+
import domains
|
|
20
|
+
import roads
|
|
21
|
+
import runtime_processes
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _backup_path(usuario, datos):
|
|
25
|
+
sello = time.strftime("%Y%m%d-%H%M%S")
|
|
26
|
+
base = os.path.join(cities.carpeta_usuario(usuario), ".backups")
|
|
27
|
+
nombre = f"{cities.slug_ciudad(datos)}-{sello}"
|
|
28
|
+
ruta = os.path.join(base, nombre)
|
|
29
|
+
n = 2
|
|
30
|
+
while os.path.exists(ruta):
|
|
31
|
+
ruta = os.path.join(base, f"{nombre}-{n}")
|
|
32
|
+
n += 1
|
|
33
|
+
return ruta
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _deten(datos, dry_run=False):
|
|
37
|
+
guion = os.path.join(os.path.dirname(__file__), "apaga.py")
|
|
38
|
+
args = [sys.executable, guion, datos]
|
|
39
|
+
if dry_run:
|
|
40
|
+
args.append("--dry-run")
|
|
41
|
+
return subprocess.run(args, capture_output=True, text=True)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _valida(datos, usuario):
|
|
45
|
+
usuario = usuario or cities.usuario_actual()
|
|
46
|
+
datos = os.path.realpath(datos)
|
|
47
|
+
if not cities.gestionada(datos, usuario):
|
|
48
|
+
raise ValueError(
|
|
49
|
+
"reset only accepts a managed city under "
|
|
50
|
+
f"{cities.carpeta_usuario(usuario)}; external/shared "
|
|
51
|
+
"folders are never erased"
|
|
52
|
+
)
|
|
53
|
+
if not cities.es_ciudad(datos):
|
|
54
|
+
raise ValueError(f"{datos} is not a city")
|
|
55
|
+
owner_en_disco = cities.lee_clave(datos, "owner")
|
|
56
|
+
if owner_en_disco and owner_en_disco != usuario:
|
|
57
|
+
raise ValueError(f"this city belongs to {owner_en_disco!r}, not {usuario!r}")
|
|
58
|
+
return datos, usuario
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _contexto(datos, usuario):
|
|
62
|
+
return {
|
|
63
|
+
"datos": datos,
|
|
64
|
+
"usuario": usuario,
|
|
65
|
+
"ident": cities.identidad(datos),
|
|
66
|
+
"nombre": cities.nombre(datos),
|
|
67
|
+
"slug": cities.slug_ciudad(datos),
|
|
68
|
+
"owner": cities.lee_clave(datos, "owner") or usuario,
|
|
69
|
+
"domain": domains.de_ciudad(datos),
|
|
70
|
+
"grow": cities.lee_clave(
|
|
71
|
+
datos, "grow_command", "git log --since=30.days --oneline | wc -l"
|
|
72
|
+
),
|
|
73
|
+
"backup": _backup_path(usuario, datos),
|
|
74
|
+
"estado": os.path.join(cities.raiz(), "state", cities.slug(datos)),
|
|
75
|
+
"runtime": runtime_processes.ruta(datos),
|
|
76
|
+
"roads": roads.lee(datos),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _preview(ctx):
|
|
81
|
+
print(f" Would reset city `{ctx['slug']}` at {ctx['datos']}")
|
|
82
|
+
print(f" Would preserve it at {ctx['backup']}")
|
|
83
|
+
print(" Would leave every referenced repo untouched")
|
|
84
|
+
if ctx["roads"]:
|
|
85
|
+
print(f" Would close {len(ctx['roads'])} road(s) at both ends")
|
|
86
|
+
parada = _deten(ctx["datos"], dry_run=True)
|
|
87
|
+
if parada.stdout.strip():
|
|
88
|
+
print(parada.stdout.rstrip())
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _para(datos):
|
|
92
|
+
parada = _deten(datos)
|
|
93
|
+
if parada.returncode != 0:
|
|
94
|
+
detalle = (parada.stderr or parada.stdout or "unknown stop error").strip()
|
|
95
|
+
raise ValueError(f"could not stop the city before reset: {detalle}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _cierra_carreteras(ctx):
|
|
99
|
+
"""Back up and remove the other half of every incident local road."""
|
|
100
|
+
backlinks = os.path.join(ctx["backup"], "road-backlinks")
|
|
101
|
+
for camino in ctx["roads"]:
|
|
102
|
+
otro = roads.destino_local(camino, ctx["usuario"])
|
|
103
|
+
if not otro:
|
|
104
|
+
continue
|
|
105
|
+
os.makedirs(backlinks, exist_ok=True)
|
|
106
|
+
if os.path.isfile(roads.ruta(otro)):
|
|
107
|
+
destino = os.path.join(backlinks, f"{cities.slug_ciudad(otro)}.roads.json")
|
|
108
|
+
shutil.copy2(roads.ruta(otro), destino)
|
|
109
|
+
roads.desconecta(otro, ctx["ident"])
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _mueve_si_existe(origen, destino):
|
|
113
|
+
if os.path.exists(origen):
|
|
114
|
+
shutil.move(origen, destino)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _respalda(ctx):
|
|
118
|
+
os.makedirs(ctx["backup"], exist_ok=False)
|
|
119
|
+
_cierra_carreteras(ctx)
|
|
120
|
+
shutil.move(ctx["datos"], os.path.join(ctx["backup"], "city"))
|
|
121
|
+
_mueve_si_existe(ctx["estado"], os.path.join(ctx["backup"], "state"))
|
|
122
|
+
_mueve_si_existe(ctx["runtime"], os.path.join(ctx["backup"], "runtime"))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _crea_vacia(ctx):
|
|
126
|
+
datos = ctx["datos"]
|
|
127
|
+
os.makedirs(datos, exist_ok=True)
|
|
128
|
+
cities.asegura_metadata(datos, ctx["owner"], ctx["nombre"], ctx["ident"])
|
|
129
|
+
# Keep stable identity and growth measure, but no seat, agents, repos or roads.
|
|
130
|
+
city_yml = os.path.join(datos, "city.yml")
|
|
131
|
+
texto = open(city_yml, encoding="utf-8").read()
|
|
132
|
+
if "\ndomain:" not in "\n" + texto:
|
|
133
|
+
texto += f"domain: {ctx['domain']}\n"
|
|
134
|
+
if "\ngrow_command:" not in "\n" + texto:
|
|
135
|
+
texto += f"grow_command: {ctx['grow']}\n"
|
|
136
|
+
cities.escribe_atomico(city_yml, texto)
|
|
137
|
+
roads.escribe(datos, [])
|
|
138
|
+
cities.selecciona(ctx["usuario"], datos)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def reinicia(datos, usuario="", dry_run=False):
|
|
142
|
+
datos, usuario = _valida(datos, usuario)
|
|
143
|
+
ctx = _contexto(datos, usuario)
|
|
144
|
+
if dry_run:
|
|
145
|
+
_preview(ctx)
|
|
146
|
+
return ctx["backup"]
|
|
147
|
+
|
|
148
|
+
_para(datos)
|
|
149
|
+
_respalda(ctx)
|
|
150
|
+
_crea_vacia(ctx)
|
|
151
|
+
return ctx["backup"]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def main():
|
|
155
|
+
ap = argparse.ArgumentParser(
|
|
156
|
+
description="Reset one city to onboarding; repos stay untouched and data is backed up"
|
|
157
|
+
)
|
|
158
|
+
ap.add_argument("city", help="city name or path (required)")
|
|
159
|
+
ap.add_argument("--dry-run", action="store_true", help="show every effect, change nothing")
|
|
160
|
+
a = ap.parse_args()
|
|
161
|
+
usuario = cities.usuario_actual()
|
|
162
|
+
datos = cities.resuelve(a.city, usuario)
|
|
163
|
+
if not datos:
|
|
164
|
+
conocidas = ", ".join(c["slug"] for c in cities.lista(usuario)) or "none"
|
|
165
|
+
print(f" No city called {a.city!r}. Known here: {conocidas}.", file=sys.stderr)
|
|
166
|
+
return 1
|
|
167
|
+
try:
|
|
168
|
+
backup = reinicia(datos, usuario, a.dry_run)
|
|
169
|
+
except ValueError as e:
|
|
170
|
+
print(f" {e}", file=sys.stderr)
|
|
171
|
+
return 1
|
|
172
|
+
if not a.dry_run:
|
|
173
|
+
corto = backup.replace(os.path.expanduser("~"), "~")
|
|
174
|
+
print(f" City `{cities.slug_ciudad(datos)}` reset. Recovery copy: {corto}")
|
|
175
|
+
print(f" Run `agents-city seat --city {cities.slug_ciudad(datos)}` for onboarding.")
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
sys.exit(main())
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Explicit roads between autonomous cities.
|
|
3
|
+
|
|
4
|
+
Roads connect city seats, never users and never repo windows. A local road is
|
|
5
|
+
written at both ends. A remote road is added from a small public invitation;
|
|
6
|
+
the other end accepts this city's invitation independently. No token or skill
|
|
7
|
+
content is ever stored here.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
import card
|
|
16
|
+
import cities
|
|
17
|
+
import domains
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
ADDRESS_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}/[a-z0-9][a-z0-9_-]{0,63}$")
|
|
21
|
+
ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$")
|
|
22
|
+
INVITATION_KEYS = {"version", "id", "name", "owner", "address", "domain", "role"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _limpia(camino):
|
|
26
|
+
"""Validate untrusted road metadata and return its storage shape."""
|
|
27
|
+
if not isinstance(camino, dict):
|
|
28
|
+
return None
|
|
29
|
+
ident = str(camino.get("id", "")).strip()
|
|
30
|
+
address = str(camino.get("address", "")).strip().lower()
|
|
31
|
+
if not ID_RE.fullmatch(ident) or not ADDRESS_RE.fullmatch(address):
|
|
32
|
+
return None
|
|
33
|
+
dueno, slug = address.split("/", 1)
|
|
34
|
+
owner = str(camino.get("owner") or dueno).strip().lower()
|
|
35
|
+
if owner != dueno:
|
|
36
|
+
return None
|
|
37
|
+
name = " ".join(str(camino.get("name") or slug).split())[:80] or slug
|
|
38
|
+
limpio = {"id": ident, "name": name, "owner": owner, "address": address}
|
|
39
|
+
dominio = domains.canonico(camino.get("domain"))
|
|
40
|
+
rol = str(camino.get("role") or "").strip().lower()
|
|
41
|
+
if dominio and re.fullmatch(r"[a-z0-9-]{1,80}", dominio):
|
|
42
|
+
limpio["domain"] = dominio
|
|
43
|
+
if rol and re.fullmatch(r"[a-z0-9-]{1,80}", rol):
|
|
44
|
+
limpio["role"] = rol
|
|
45
|
+
if camino.get("local"):
|
|
46
|
+
limpio["local"] = True
|
|
47
|
+
return limpio
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def ruta(datos):
|
|
51
|
+
return os.path.join(os.path.realpath(datos), "roads.json")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _atomico(ruta_, objeto):
|
|
55
|
+
os.makedirs(os.path.dirname(ruta_), exist_ok=True)
|
|
56
|
+
temporal = ruta_ + f".tmp-{os.getpid()}"
|
|
57
|
+
with open(temporal, "w", encoding="utf-8") as f:
|
|
58
|
+
json.dump(objeto, f, indent=2, ensure_ascii=False, sort_keys=True)
|
|
59
|
+
f.write("\n")
|
|
60
|
+
os.replace(temporal, ruta_)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def lee(datos):
|
|
64
|
+
try:
|
|
65
|
+
objeto = json.load(open(ruta(datos), encoding="utf-8"))
|
|
66
|
+
except (OSError, json.JSONDecodeError, TypeError):
|
|
67
|
+
return []
|
|
68
|
+
caminos = objeto.get("roads", []) if isinstance(objeto, dict) else []
|
|
69
|
+
return [limpio for r in caminos if (limpio := _limpia(r)) is not None]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def escribe(datos, caminos):
|
|
73
|
+
limpios, ids, direcciones = [], set(), set()
|
|
74
|
+
for camino in caminos:
|
|
75
|
+
limpio = _limpia(camino)
|
|
76
|
+
if not limpio or limpio["id"] in ids or limpio["address"] in direcciones:
|
|
77
|
+
continue
|
|
78
|
+
ids.add(limpio["id"])
|
|
79
|
+
direcciones.add(limpio["address"])
|
|
80
|
+
limpios.append(limpio)
|
|
81
|
+
limpios.sort(key=lambda r: (r["owner"], r["name"].lower(), r["id"]))
|
|
82
|
+
_atomico(ruta(datos), {"version": 1, "roads": limpios})
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def invitacion(datos, usuario=""):
|
|
86
|
+
usuario = usuario or cities.usuario_actual()
|
|
87
|
+
owner = cities.lee_clave(datos, "owner") or usuario
|
|
88
|
+
ficha = card.lee(os.path.join(datos, f"{owner}.md"))
|
|
89
|
+
invitacion_ = {
|
|
90
|
+
"version": 1,
|
|
91
|
+
"id": cities.identidad(datos),
|
|
92
|
+
"name": cities.nombre(datos),
|
|
93
|
+
"owner": owner,
|
|
94
|
+
"address": cities.direccion(usuario, datos),
|
|
95
|
+
"domain": domains.de_ciudad(datos),
|
|
96
|
+
}
|
|
97
|
+
if ficha.get("role"):
|
|
98
|
+
invitacion_["role"] = ficha["role"]
|
|
99
|
+
return invitacion_
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _pon(datos, destino, local=False):
|
|
103
|
+
caminos = [r for r in lee(datos) if r["id"] != destino["id"]]
|
|
104
|
+
nuevo = dict(destino)
|
|
105
|
+
if local:
|
|
106
|
+
nuevo["local"] = True
|
|
107
|
+
caminos.append(nuevo)
|
|
108
|
+
escribe(datos, caminos)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def conecta(origen, destino, usuario=""):
|
|
112
|
+
"""Connect two cities on this machine, symmetrically."""
|
|
113
|
+
usuario = usuario or cities.usuario_actual()
|
|
114
|
+
if cities.identidad(origen) == cities.identidad(destino):
|
|
115
|
+
raise ValueError("a city cannot build a road to itself")
|
|
116
|
+
_pon(origen, invitacion(destino, usuario), local=True)
|
|
117
|
+
_pon(destino, invitacion(origen, usuario), local=True)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def conecta_invitacion(origen, destino):
|
|
121
|
+
"""Add the local half of a road to a remote city invitation."""
|
|
122
|
+
necesarios = {"id", "name", "owner", "address"}
|
|
123
|
+
if not isinstance(destino, dict) or not necesarios <= set(destino):
|
|
124
|
+
raise ValueError("the invitation is missing id, name, owner or address")
|
|
125
|
+
if not set(destino) <= INVITATION_KEYS:
|
|
126
|
+
raise ValueError("the invitation contains fields other than public city identity")
|
|
127
|
+
limpia = _limpia(destino)
|
|
128
|
+
if not limpia:
|
|
129
|
+
raise ValueError("the invitation has invalid or inconsistent identity")
|
|
130
|
+
origen_publico = invitacion(origen)
|
|
131
|
+
if limpia["id"] == origen_publico["id"] or limpia["address"] == origen_publico["address"]:
|
|
132
|
+
raise ValueError("a city cannot build a road to itself")
|
|
133
|
+
_pon(origen, limpia, local=False)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def desconecta(origen, destino_id):
|
|
137
|
+
antes = lee(origen)
|
|
138
|
+
despues = [r for r in antes if r["id"] != destino_id]
|
|
139
|
+
escribe(origen, despues)
|
|
140
|
+
return len(antes) != len(despues)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def desconecta_local(origen, destino):
|
|
144
|
+
a = desconecta(origen, cities.identidad(destino))
|
|
145
|
+
b = desconecta(destino, cities.identidad(origen))
|
|
146
|
+
return a or b
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def quita_incidentes(datos, usuario=""):
|
|
150
|
+
"""Remove every local edge touching a city; used by a scoped reset."""
|
|
151
|
+
usuario = usuario or cities.usuario_actual()
|
|
152
|
+
ident = cities.identidad(datos)
|
|
153
|
+
for otra in cities.lista(usuario):
|
|
154
|
+
if os.path.realpath(otra["ruta"]) != os.path.realpath(datos):
|
|
155
|
+
desconecta(otra["ruta"], ident)
|
|
156
|
+
escribe(datos, [])
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def destino_local(camino, usuario=""):
|
|
160
|
+
"""Resolve a road record to a city currently present on this machine."""
|
|
161
|
+
if not camino.get("local"):
|
|
162
|
+
return ""
|
|
163
|
+
usuario = usuario or cities.usuario_actual()
|
|
164
|
+
return next((c["ruta"] for c in cities.lista(usuario) if c["id"] == camino.get("id")), "")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _resuelve(nombre, usuario):
|
|
168
|
+
datos = cities.resuelve(nombre, usuario)
|
|
169
|
+
if not datos:
|
|
170
|
+
conocidas = ", ".join(c["slug"] for c in cities.lista(usuario)) or "none"
|
|
171
|
+
raise ValueError(f"no city called {nombre!r}; known here: {conocidas}")
|
|
172
|
+
return datos
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _ayuda():
|
|
176
|
+
return """usage:
|
|
177
|
+
agents-city road list <city>
|
|
178
|
+
agents-city road connect <city> <local-city|invitation.json>
|
|
179
|
+
agents-city road disconnect <city> <local-city|city-id>
|
|
180
|
+
agents-city road invite <city>
|
|
181
|
+
|
|
182
|
+
Roads are explicit. Connecting two local cities writes both ends. For a remote
|
|
183
|
+
city, exchange the public JSON printed by `road invite`; it contains no token."""
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def main():
|
|
187
|
+
args = sys.argv[1:]
|
|
188
|
+
if not args or args[0] in ("-h", "--help", "help"):
|
|
189
|
+
print(_ayuda())
|
|
190
|
+
return 0
|
|
191
|
+
usuario = cities.usuario_actual()
|
|
192
|
+
try:
|
|
193
|
+
if args[0] == "list" and len(args) == 2:
|
|
194
|
+
origen = _resuelve(args[1], usuario)
|
|
195
|
+
caminos = lee(origen)
|
|
196
|
+
if not caminos:
|
|
197
|
+
print(f" {cities.nombre(origen)} has no roads.")
|
|
198
|
+
for r in caminos:
|
|
199
|
+
local = "local" if destino_local(r, usuario) else "remote"
|
|
200
|
+
print(f" {r['name']} {r['address']} [{local}]")
|
|
201
|
+
return 0
|
|
202
|
+
if args[0] == "invite" and len(args) == 2:
|
|
203
|
+
print(json.dumps(invitacion(_resuelve(args[1], usuario)), indent=2))
|
|
204
|
+
return 0
|
|
205
|
+
if args[0] == "connect" and len(args) == 3:
|
|
206
|
+
origen = _resuelve(args[1], usuario)
|
|
207
|
+
destino = cities.resuelve(args[2], usuario)
|
|
208
|
+
if destino:
|
|
209
|
+
conecta(origen, destino, usuario)
|
|
210
|
+
print(f" Road open: {cities.nombre(origen)} <-> {cities.nombre(destino)}")
|
|
211
|
+
else:
|
|
212
|
+
with open(os.path.expanduser(args[2]), encoding="utf-8") as f:
|
|
213
|
+
conecta_invitacion(origen, json.load(f))
|
|
214
|
+
print(f" Remote road added to {cities.nombre(origen)}.")
|
|
215
|
+
return 0
|
|
216
|
+
if args[0] == "disconnect" and len(args) == 3:
|
|
217
|
+
origen = _resuelve(args[1], usuario)
|
|
218
|
+
destino = cities.resuelve(args[2], usuario)
|
|
219
|
+
cambiado = desconecta_local(origen, destino) if destino else desconecta(origen, args[2])
|
|
220
|
+
print(" Road closed." if cambiado else " No such road.")
|
|
221
|
+
return 0
|
|
222
|
+
except (OSError, ValueError, json.JSONDecodeError) as e:
|
|
223
|
+
print(f" {e}", file=sys.stderr)
|
|
224
|
+
return 1
|
|
225
|
+
print(_ayuda(), file=sys.stderr)
|
|
226
|
+
return 1
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
if __name__ == "__main__":
|
|
230
|
+
sys.exit(main())
|