agents-city 0.3.0-beta.21 → 0.3.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.
Files changed (57) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.es.md +310 -70
  3. package/README.md +297 -69
  4. package/bin/agents-city.js +3 -0
  5. package/bin/doctor +3 -0
  6. package/bin/hall.html +164 -24
  7. package/bin/navegador.mjs +415 -0
  8. package/bin/serve.py +383 -127
  9. package/bin/shortcut +3 -0
  10. package/bin/test +5 -2
  11. package/bin/test-actualiza.py +130 -0
  12. package/bin/test-atajos.py +301 -0
  13. package/bin/test-busca.py +216 -0
  14. package/bin/test-cage.py +170 -2
  15. package/bin/test-card.py +2 -2
  16. package/bin/test-cities.py +45 -0
  17. package/bin/test-contracts.py +12 -5
  18. package/bin/test-doctor.py +33 -0
  19. package/bin/test-navegador.py +164 -0
  20. package/bin/test-seat.py +245 -25
  21. package/bin/test-serve.py +214 -9
  22. package/bin/test-workspace.py +63 -0
  23. package/bin/testlib.py +23 -0
  24. package/bin/update +3 -0
  25. package/city/web/dist/city.js +47 -47
  26. package/city/web/dist/index.html +1 -1
  27. package/city/web/dist-hall/hall.js +2193 -174
  28. package/city/web/src/bienvenida.ts +686 -0
  29. package/city/web/src/es.ts +180 -0
  30. package/city/web/src/hall.ts +520 -168
  31. package/city/web/src/idioma.ts +86 -0
  32. package/city/web/src/main.ts +27 -0
  33. package/city/web/src/motores.ts +54 -0
  34. package/docs/agents-first.md +8 -1
  35. package/docs/security.md +46 -12
  36. package/docs/testing.md +1 -1
  37. package/package.json +1 -1
  38. package/plugin/.claude-plugin/plugin.json +1 -1
  39. package/plugin/channel/bus.js +1 -1
  40. package/plugin/channel/bus.ts +1 -1
  41. package/plugin/channel/runtime/codex.ts +1 -1
  42. package/plugin/channel/runtime-gateway.js +1 -1
  43. package/plugin/scripts/actualiza.py +198 -0
  44. package/plugin/scripts/atajos.py +506 -0
  45. package/plugin/scripts/busca.py +436 -0
  46. package/plugin/scripts/cage.py +266 -26
  47. package/plugin/scripts/capabilities.py +17 -10
  48. package/plugin/scripts/card.py +10 -0
  49. package/plugin/scripts/cities.py +34 -0
  50. package/plugin/scripts/city-session.sh +33 -7
  51. package/plugin/scripts/doctor.py +122 -0
  52. package/plugin/scripts/find-repos.sh +12 -105
  53. package/plugin/scripts/read-card.py +6 -2
  54. package/plugin/scripts/report.py +5 -6
  55. package/plugin/scripts/reset.py +50 -14
  56. package/plugin/scripts/seat.py +445 -103
  57. package/plugin/scripts/workspace.py +197 -0
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Two languages, one page.
3
+ *
4
+ * The READMEs shipped in Spanish and English from the start and the product did
5
+ * not, which is the wrong way round: a person reads the documentation once and
6
+ * lives in the interface every day. So the Hall speaks both.
7
+ *
8
+ * The dictionary is keyed by the ENGLISH sentence, not by an invented id. That
9
+ * matters here: an untranslated string falls back to the source and the page
10
+ * stays usable while coverage grows, instead of showing `hall.agents.title` to
11
+ * somebody. It also means the code still reads as English prose, so a
12
+ * contributor who speaks neither can follow it.
13
+ */
14
+
15
+ type Diccionario = Record<string, string>;
16
+
17
+ const ES: Diccionario = {};
18
+
19
+ /**
20
+ * The lookup key: the source sentence with its whitespace flattened.
21
+ *
22
+ * A paragraph in a template literal is indented to match the code around it,
23
+ * and that indentation is invisible in HTML but fatal to an exact-match
24
+ * dictionary — every translated paragraph would have to be re-indented in
25
+ * lockstep with the file it came from. Flattening both sides means prose can be
26
+ * written where it reads best and translated as one line.
27
+ */
28
+ function clave(fuente: string): string {
29
+ return fuente.replace(/\s+/g, ' ').trim();
30
+ }
31
+
32
+ /** Register translations. Split across files so each screen carries its own. */
33
+ export function anota(pares: Diccionario): void {
34
+ for (const [fuente, texto] of Object.entries(pares)) ES[clave(fuente)] = texto;
35
+ }
36
+
37
+ type Lengua = 'es' | 'en';
38
+
39
+ let ELEGIDO: Lengua | '' = '';
40
+
41
+ /** The language showing right now: the stored choice, else this browser's. */
42
+ export function idioma(): 'es' | 'en' {
43
+ if (ELEGIDO === 'es' || ELEGIDO === 'en') return ELEGIDO;
44
+ try {
45
+ const guardado = localStorage.getItem('hall-idioma');
46
+ if (guardado === 'es' || guardado === 'en') {
47
+ ELEGIDO = guardado;
48
+ return ELEGIDO;
49
+ }
50
+ } catch {
51
+ /* a browser with site data blocked still gets a working page */
52
+ }
53
+ const suyo = (navigator.language || 'en').toLowerCase();
54
+ ELEGIDO = suyo.startsWith('es') ? 'es' : 'en';
55
+ return ELEGIDO;
56
+ }
57
+
58
+ export function ponIdioma(cual: 'es' | 'en'): void {
59
+ ELEGIDO = cual;
60
+ try {
61
+ localStorage.setItem('hall-idioma', cual);
62
+ } catch {
63
+ /* not being able to remember it is not a reason to refuse the switch */
64
+ }
65
+ document.documentElement.lang = cual;
66
+ }
67
+
68
+ /**
69
+ * One string in the showing language.
70
+ *
71
+ * `t` takes the English source: `t('Your cities')`. Interpolation is by
72
+ * `{name}` placeholders so a translator can move them — Spanish does not put
73
+ * numbers and nouns where English does.
74
+ */
75
+ export function t(fuente: string, valores?: Record<string, string | number>): string {
76
+ const texto = idioma() === 'es' ? (ES[clave(fuente)] ?? fuente) : fuente;
77
+ if (!valores) return texto;
78
+ return texto.replace(/\{(\w+)\}/g, (_, clave) =>
79
+ valores[clave] === undefined ? `{${clave}}` : String(valores[clave]),
80
+ );
81
+ }
82
+
83
+ /** Plural without a library: two forms, which is all these two languages need. */
84
+ export function plural(n: number, una: string, varias: string): string {
85
+ return t(n === 1 ? una : varias, { n: String(n) });
86
+ }
@@ -50,7 +50,30 @@ import { Puertas } from './puertas';
50
50
  let COLOR: Record<string, number> = {};
51
51
  let BARRIOS: { id: string; nom: string; cols: number; nota?: string }[] = [];
52
52
  const COLOR_POR_DEFECTO = 0xc8b48a;
53
+ //: The canvas behind the city, per skin.
54
+ //:
55
+ //: The map itself stays a night city in both: its buildings, shadows and
56
+ //: asphalt are lit for a dark ground, and bleaching them would be a redesign
57
+ //: rather than a palette swap. What day mode changes is the margin around the
58
+ //: plan — it takes the ground's own colour, so a light Hall frames a deliberate
59
+ //: viewport instead of leaving paper around a black island.
53
60
  const SUELO = 0x121821;
61
+ const SUELO_CLARO = 0x0b1119;
62
+
63
+ /** The Hall's theme message: the map is framed by it and must match its light. */
64
+ function esTema(dato: unknown): dato is { type: string; theme: 'light' | 'dark' } {
65
+ const m = dato as { type?: unknown; theme?: unknown } | null;
66
+ return !!m && m.type === 'agents-city-map-theme/1' && (m.theme === 'light' || m.theme === 'dark');
67
+ }
68
+
69
+ function ponTema(cual: 'light' | 'dark'): void {
70
+ const suelo = cual === 'light' ? SUELO_CLARO : SUELO;
71
+ document.documentElement.dataset.tema = cual;
72
+ document.body.style.background = '#' + suelo.toString(16).padStart(6, '0');
73
+ // The renderer may not exist yet (the message can beat the boot); the body
74
+ // colour above already carries the skin until it does.
75
+ if (appGlobal) appGlobal.renderer.background.color = suelo;
76
+ }
54
77
 
55
78
  interface Parcela {
56
79
  id: string;
@@ -175,6 +198,10 @@ window.addEventListener('message', (message) => {
175
198
  else configPendiente = message.data;
176
199
  return;
177
200
  }
201
+ if (esTema(message.data)) {
202
+ ponTema(message.data.theme);
203
+ return;
204
+ }
178
205
  if (!isMapActivityMessage(message.data)) return;
179
206
  const event = message.data.event;
180
207
  if (!isSpeechEvent(event) && !isPresenceEvent(event) && !event.kind.startsWith('committee.')) {
@@ -0,0 +1,54 @@
1
+ /**
2
+ * What runs an agent: the provider, the engine and how hard it thinks.
3
+ *
4
+ * One list, imported by both the character sheet and the first-run guide. They
5
+ * used to be the sheet's private constants, which meant the guide could not ask
6
+ * the question at all — somebody built five houses and then went hunting for
7
+ * three dropdowns to set what they had already decided in their head. A curated
8
+ * list is never allowed to hold a card hostage, so `opciones` always keeps a
9
+ * value that is already written even when it is not one of these.
10
+ */
11
+
12
+ export const RUNTIMES = ['claude', 'codex', 'opencode', 'kimi'];
13
+ export const MODELOS = ['haiku', 'sonnet', 'opus', 'fable'];
14
+ export const NIVEL_ESFUERZO: Record<string, number> = {
15
+ low: 1,
16
+ medium: 2,
17
+ high: 3,
18
+ xhigh: 4,
19
+ max: 5,
20
+ };
21
+ export const ESFUERZOS = Object.keys(NIVEL_ESFUERZO);
22
+
23
+ /** `<option>`s from known values plus the current one when it is off the list. */
24
+ export function opciones(
25
+ valores: string[],
26
+ actual: string,
27
+ esc: (s: unknown) => string,
28
+ porDefecto = 'default',
29
+ ): string {
30
+ return valores
31
+ .concat(actual && !valores.includes(actual) ? [actual] : [])
32
+ .map(
33
+ (v) =>
34
+ `<option value="${esc(v)}"${v === actual ? ' selected' : ''}>${esc(v || porDefecto)}</option>`,
35
+ )
36
+ .join('');
37
+ }
38
+
39
+ /** The engine's honest power reading. An alias maps to its tier; empty means
40
+ * the owner's default — unknown here, so it reads "default" rather than
41
+ * inventing a number. */
42
+ export function nivelDeMotor(model: string): { ancho: number; texto: string; defecto: boolean } {
43
+ const alias = model.toLowerCase();
44
+ if (!alias) return { ancho: 0.5, texto: 'default', defecto: true };
45
+ for (const [clave, ancho] of [
46
+ ['fable', 1],
47
+ ['opus', 0.8],
48
+ ['sonnet', 0.55],
49
+ ['haiku', 0.35],
50
+ ] as const) {
51
+ if (alias.includes(clave)) return { ancho, texto: alias, defecto: false };
52
+ }
53
+ return { ancho: 0.6, texto: alias, defecto: false };
54
+ }
@@ -31,7 +31,14 @@ what makes the map polymorphic instead of assuming everyone ships pull requests.
31
31
  `Agente`, so the launcher, the cage and the map read one model regardless of how
32
32
  the card was written.
33
33
 
34
- Legacy (unchanged every repo is an agent whose single mount is that repo):
34
+ **Read forever, written never.** The legacy shape below is still parsed by
35
+ every door, so a city written a year ago opens today exactly as it did. Nothing
36
+ writes it any more: the wizard and the Hall both produce the agent-first shape
37
+ through `workspace.claves_de_roster`, because two writers of one fact is how the
38
+ terminal and the web ended up able to produce different cities for the same
39
+ city. Re-running `./bin/seat --agents` over an old card upgrades it in place.
40
+
41
+ Legacy (still read — every repo is an agent whose single mount is that repo):
35
42
 
36
43
  ```yaml
37
44
  repos: [nova, store-service]
package/docs/security.md CHANGED
@@ -14,18 +14,32 @@ credential to hold, and a signed trail of everything it tried.
14
14
 
15
15
  ## Layer 1 — the cage (`plugin/scripts/cage.py`)
16
16
 
17
- Claude, OpenCode and Kimi repo windows launch under a generated macOS seatbelt
18
- profile:
17
+ Claude, OpenCode and Kimi agent windows launch confined by the kernel. Two
18
+ mechanisms, one meaning — and the meaning is what `cage.py` owns, so the
19
+ launcher asks for a prefix and never learns which kernel it is on:
19
20
 
20
21
  ```
21
- sandbox-exec -f ~/.agents-city/.runtime/cage/<window>.sb <runtime …>
22
+ macOS sandbox-exec -f ~/.agents-city/.runtime/cage/<window>.sb <runtime …>
23
+ Linux bwrap --ro-bind / / … --tmpfs ~/.ssh … <runtime …>
22
24
  ```
23
25
 
24
- The profile reads top to bottom: allow everything, deny all writes, re-allow
25
- the working set, then seal the secrets — reads and writes both. The semantics
26
- (last matching rule wins; children and grandchildren inherit) were verified on
27
- a real machine before a line of it was written, and `bin/test-cage.py` re-proves
28
- them live on every run where seatbelt exists.
26
+ On macOS the profile reads top to bottom: allow everything, deny all writes,
27
+ re-allow the working set, then seal the secrets — reads and writes both. The
28
+ semantics (last matching rule wins; children and grandchildren inherit) were
29
+ verified on a real machine before a line of it was written.
30
+
31
+ On Linux the same shape is expressed as mounts, applied in the same order and
32
+ with the same last-one-wins rule: the whole filesystem read-only, the working
33
+ set re-bound writable, each sealed directory replaced by an empty tmpfs, each
34
+ sealed file replaced by `/dev/null`, and finally this window's own broker token
35
+ re-admitted read-only. A sealed path is not refused — it is *not there*.
36
+
37
+ Availability is checked by doing, not by looking: `bwrap_sirve()` builds a real
38
+ namespace once and remembers the answer, because Ubuntu 24's AppArmor policy and
39
+ hardened kernels can refuse unprivileged user namespaces even with bubblewrap
40
+ installed. Where the cage cannot run, the launcher says so and starts uncaged —
41
+ the behaviour the product always had. `bin/test-cage.py` re-proves both cages
42
+ live: seatbelt on every macOS run, bubblewrap on every Linux run.
29
43
 
30
44
  Inside the cage a window **can**: work freely in its own repo, run builds,
31
45
  reach the network, keep its runtime state (`~/.claude`, `~/.codex`,
@@ -33,10 +47,30 @@ reach the network, keep its runtime state (`~/.claude`, `~/.codex`,
33
47
 
34
48
  Inside the cage a window **cannot**: read or write `~/.ssh`, `~/.aws`,
35
49
  `~/.kube`, `~/.gnupg`, `~/.docker`, `~/.config/gcloud`, `~/.config/gh`,
36
- `~/.git-credentials`, `~/.netrc`, `~/.pgpass`, cargo credentials, any remote
37
- road `.env` under `~/.claude/channels/`, or the broker's state; nor write
38
- anywhere outside its repo and the allowed runtime/cache set other repos on
39
- the machine included.
50
+ `~/.git-credentials`, `~/.netrc`, `~/.pgpass`, cargo credentials,
51
+ `~/.claude/.credentials.json`, any remote road `.env` under
52
+ `~/.claude/channels/`, or the broker's state; nor write anywhere outside its
53
+ repo and the allowed runtime/cache set — other repos on the machine included.
54
+
55
+ `~/.claude/.credentials.json` earns its own line because it is the one this
56
+ product created: `~/.claude` stays writable for runtime state, and outside
57
+ macOS there is no Keychain, so Claude Code writes its OAuth tokens there as
58
+ plain JSON. Every third-party credential store was sealed while ours was not,
59
+ which is the sort of hole that only a review looking for it finds.
60
+
61
+ ### Where the two cages differ, exactly
62
+
63
+ They seal the same set, and both are re-proved live on every CI run — seatbelt
64
+ on macOS, bubblewrap on Linux. One difference is real and worth knowing:
65
+
66
+ macOS states the road-token rule as a **pattern** (`channels/*/.env`), so a
67
+ road created after the window started is sealed too. Linux states it as one
68
+ **mount per file**, and a mount needs a path that exists — so a road opened
69
+ mid-session is not sealed inside windows that were already running. It is
70
+ sealed for every window started afterwards, and closing and reopening the city
71
+ closes the gap. The alternative — hiding the whole `channels` directory — would
72
+ also hide what the in-window hooks legitimately read, so this stays a known,
73
+ bounded difference rather than a silent one.
40
74
 
41
75
  Dials, all environment variables read at launch:
42
76
 
package/docs/testing.md CHANGED
@@ -149,7 +149,7 @@ temporary prefix before touching a global installation:
149
149
  ```bash
150
150
  npm pack
151
151
  PREFIX=$(mktemp -d)
152
- npm install -g --prefix "$PREFIX" ./agents-city-0.3.0-beta.21.tgz
152
+ npm install -g --prefix "$PREFIX" ./agents-city-*.tgz
153
153
  "$PREFIX/bin/agents-city" --version
154
154
  ```
155
155
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-city",
3
- "version": "0.3.0-beta.21",
3
+ "version": "0.3.0",
4
4
  "description": "Run autonomous agent cities: one chair seat, role-aware repo agents, repo-owned skills, and explicit roads.",
5
5
  "bin": {
6
6
  "agents-city": "bin/agents-city.js"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "city",
3
3
  "displayName": "Agents City",
4
- "version": "0.3.0-beta.21",
4
+ "version": "0.3.0",
5
5
  "description": "One autonomous city seat with a work domain, role, goal, repo support agents, recognised skills and explicit roads to other cities.",
6
6
  "author": {
7
7
  "name": "jlcases",
@@ -20100,7 +20100,7 @@ try {
20100
20100
  }
20101
20101
  var chair = Boolean(context && context.actors[actor]?.role === "chair" && actor === "seat");
20102
20102
  var server = new Server(
20103
- { name: "agents-city-bus", version: "0.3.0-beta.21" },
20103
+ { name: "agents-city-bus", version: "0.2.1" },
20104
20104
  {
20105
20105
  capabilities: {
20106
20106
  ...channelEnabled ? { experimental: { "claude/channel": {} } } : {},
@@ -24,7 +24,7 @@ try {
24
24
 
25
25
  const chair = Boolean(context && context.actors[actor]?.role === 'chair' && actor === 'seat');
26
26
  const server = new Server(
27
- { name: 'agents-city-bus', version: '0.3.0-beta.21' },
27
+ { name: 'agents-city-bus', version: '0.2.1' },
28
28
  {
29
29
  capabilities: {
30
30
  ...(channelEnabled ? { experimental: { 'claude/channel': {} } } : {}),
@@ -67,7 +67,7 @@ export class CodexConnector implements RuntimeConnector {
67
67
  async (method, params) => this.providerRequest(method, params),
68
68
  );
69
69
  await this.rpc.request('initialize', {
70
- clientInfo: { name: 'agents-city', title: 'Agents City', version: '0.3.0-beta.21' },
70
+ clientInfo: { name: 'agents-city', title: 'Agents City', version: '0.2.1' },
71
71
  capabilities: { experimentalApi: true },
72
72
  });
73
73
  await this.rpc.notify('initialized');
@@ -4894,7 +4894,7 @@ var CodexConnector = class {
4894
4894
  async (method, params) => this.providerRequest(method, params)
4895
4895
  );
4896
4896
  await this.rpc.request("initialize", {
4897
- clientInfo: { name: "agents-city", title: "Agents City", version: "0.3.0-beta.21" },
4897
+ clientInfo: { name: "agents-city", title: "Agents City", version: "0.2.1" },
4898
4898
  capabilities: { experimentalApi: true }
4899
4899
  });
4900
4900
  await this.rpc.notify("initialized");
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env python3
2
+ """Knowing there is a new version, and getting it.
3
+
4
+ A tool that cannot tell you it is out of date leaves every owner running
5
+ whatever they installed the day they found it — including the day a security
6
+ fix shipped. So this asks npm, and it asks rarely: once a day at most, cached
7
+ under the runtime dir, and never on a plain command. The check runs where the
8
+ person deliberately opened something (`doctor`, `update`, the Hall), not behind
9
+ their back on every `agents-city cities`.
10
+
11
+ It is a GET to the public registry and nothing else: no identifiers, no
12
+ counters, nothing about this machine leaves it. `CITY_UPDATE_CHECK=0` turns it
13
+ off completely, and the answer is cached so a plane, a firewall or a dead
14
+ registry degrade to silence rather than to an error.
15
+ """
16
+
17
+ import json
18
+ import os
19
+ import subprocess
20
+ import sys
21
+ import time
22
+ import urllib.error
23
+ import urllib.request
24
+
25
+ GUIONES = os.path.dirname(os.path.abspath(__file__))
26
+ sys.path.insert(0, GUIONES)
27
+ import cities # noqa: E402
28
+
29
+ PAQUETE = 'agents-city'
30
+ REGISTRO = f'https://registry.npmjs.org/{PAQUETE}/latest'
31
+ #: A day. The point is to notice a release, not to poll a registry.
32
+ VIDA = 24 * 3600
33
+
34
+
35
+ def version_instalada():
36
+ """What is running right now, read from the package this file ships in."""
37
+ raiz = os.path.dirname(os.path.dirname(GUIONES))
38
+ try:
39
+ with open(os.path.join(raiz, 'package.json'), encoding='utf-8') as f:
40
+ return str(json.load(f).get('version') or '')
41
+ except (OSError, ValueError):
42
+ return ''
43
+
44
+
45
+ def _cache():
46
+ return os.path.join(cities.raiz(), '.runtime', 'version.json')
47
+
48
+
49
+ def _lee_cache():
50
+ try:
51
+ with open(_cache(), encoding='utf-8') as f:
52
+ return json.load(f)
53
+ except (OSError, ValueError):
54
+ return {}
55
+
56
+
57
+ def _guarda_cache(ultima):
58
+ ruta = _cache()
59
+ try:
60
+ os.makedirs(os.path.dirname(ruta), mode=0o700, exist_ok=True)
61
+ cities.escribe_atomico(ruta, json.dumps({'latest': ultima, 'when': time.time()}))
62
+ except OSError:
63
+ pass # not being able to remember is not a reason to fail a command
64
+
65
+
66
+ def consulta_registro(timeout=8):
67
+ """The published version, or '' when the network says nothing useful.
68
+
69
+ Eight seconds because the first call of the day pays DNS and a TLS
70
+ handshake from cold; a tighter budget turns "you are up to date" into a
71
+ lie told by a stopwatch.
72
+ """
73
+ try:
74
+ peticion = urllib.request.Request(
75
+ REGISTRO, headers={'Accept': 'application/vnd.npm.install-v1+json'}
76
+ )
77
+ with urllib.request.urlopen(peticion, timeout=timeout) as r:
78
+ return str(json.load(r).get('version') or '')
79
+ except (urllib.error.URLError, OSError, ValueError, TimeoutError):
80
+ return ''
81
+
82
+
83
+ def _partes(v):
84
+ """A version as comparable pieces. A prerelease sorts BELOW its release."""
85
+ base, _, pre = str(v).partition('-')
86
+ numeros = []
87
+ for trozo in base.split('.'):
88
+ try:
89
+ numeros.append(int(trozo))
90
+ except ValueError:
91
+ numeros.append(0)
92
+ while len(numeros) < 3:
93
+ numeros.append(0)
94
+ # No prerelease outranks one: 1.0.0 > 1.0.0-beta.1, as semver says.
95
+ return (numeros[0], numeros[1], numeros[2], 1 if not pre else 0, pre)
96
+
97
+
98
+ def es_mas_nueva(candidata, actual):
99
+ return bool(candidata) and bool(actual) and _partes(candidata) > _partes(actual)
100
+
101
+
102
+ def comprueba(forzar=False, vida=VIDA):
103
+ """(installed, published, there-is-an-update). Cached, quiet, opt-out-able.
104
+
105
+ Returns the published version as '' when the check is switched off or the
106
+ registry could not be reached — the caller then says nothing, which is the
107
+ right thing to say when you do not know.
108
+ """
109
+ instalada = version_instalada()
110
+ if os.environ.get('CITY_UPDATE_CHECK', '1') == '0':
111
+ return instalada, '', False
112
+ guardado = _lee_cache()
113
+ fresca = (time.time() - float(guardado.get('when') or 0)) < vida
114
+ if fresca and not forzar:
115
+ ultima = str(guardado.get('latest') or '')
116
+ else:
117
+ ultima = consulta_registro()
118
+ if ultima:
119
+ _guarda_cache(ultima)
120
+ else:
121
+ ultima = str(guardado.get('latest') or '')
122
+ return instalada, ultima, es_mas_nueva(ultima, instalada)
123
+
124
+
125
+ def aviso():
126
+ """One line for a person who did not ask, or '' when there is nothing to say."""
127
+ instalada, ultima, hay = comprueba()
128
+ if not hay:
129
+ return ''
130
+ return (f' A newer Agents City is out: {instalada} → {ultima}. '
131
+ f'Update with: agents-city update')
132
+
133
+
134
+ def como_se_instalo():
135
+ """'npm' when this copy lives inside a global npm install, else 'clone'.
136
+
137
+ It decides what `update` may do: pulling somebody's git checkout from under
138
+ them is not an update, it is a surprise.
139
+ """
140
+ raiz = os.path.dirname(os.path.dirname(GUIONES))
141
+ if os.path.isdir(os.path.join(raiz, '.git')):
142
+ return 'clone'
143
+ return 'npm' if f'node_modules{os.sep}{PAQUETE}' in raiz else 'clone'
144
+
145
+
146
+ def actualiza(canal=''):
147
+ """Install the newest published version over this one. Returns an exit code."""
148
+ if como_se_instalo() == 'clone':
149
+ print(' This is a git checkout, not an npm install.\n'
150
+ ' Update it the way you got it:\n'
151
+ ' git pull && npm pack && npm install -g ./agents-city-*.tgz')
152
+ return 1
153
+ destino = f'{PAQUETE}@{canal}' if canal else PAQUETE
154
+ print(f' Installing {destino} over {version_instalada()}…')
155
+ hecho = subprocess.run(['npm', 'install', '-g', destino])
156
+ if hecho.returncode != 0:
157
+ print(' npm could not install it. Nothing was changed.', file=sys.stderr)
158
+ return hecho.returncode
159
+ print(' Done. Open cities keep the code they already loaded;\n'
160
+ ' `agents-city exit <city>` and then `agents-city seat` picks the new one up.')
161
+ return 0
162
+
163
+
164
+ def _uso():
165
+ print(
166
+ ' usage: agents-city update [--check] [--tag beta]\n\n'
167
+ ' Install the newest published version, or just ask whether there is one.\n\n'
168
+ ' --check say what is installed and what is published, and stop\n'
169
+ ' --tag NAME follow a dist-tag (for example: beta)\n\n'
170
+ ' The check is one GET to the public npm registry, cached for a day.\n'
171
+ ' Nothing about this machine is sent. CITY_UPDATE_CHECK=0 disables it.'
172
+ )
173
+
174
+
175
+ def main():
176
+ args = sys.argv[1:]
177
+ if any(a in ('-h', '--help', 'help') for a in args):
178
+ _uso()
179
+ return 0
180
+ if '--check' in args:
181
+ instalada, ultima, hay = comprueba(forzar=True)
182
+ if not ultima:
183
+ print(f' Installed: {instalada}. The registry could not be reached '
184
+ '(or the check is off).')
185
+ return 0
186
+ print(f' Installed: {instalada}\n Published: {ultima}')
187
+ print(' There is a newer version — agents-city update' if hay
188
+ else ' You are up to date.')
189
+ return 0
190
+ canal = ''
191
+ if '--tag' in args:
192
+ i = args.index('--tag')
193
+ canal = args[i + 1] if len(args) > i + 1 else ''
194
+ return actualiza(canal)
195
+
196
+
197
+ if __name__ == '__main__':
198
+ sys.exit(main())