contree-cli 0.5.0__tar.gz → 0.5.1__tar.gz

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 (46) hide show
  1. {contree_cli-0.5.0 → contree_cli-0.5.1}/PKG-INFO +16 -9
  2. {contree_cli-0.5.0 → contree_cli-0.5.1}/README.md +15 -8
  3. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/__main__.py +17 -0
  4. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/agent.md +12 -7
  5. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/arguments.py +16 -17
  6. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/auth.py +68 -24
  7. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/images.py +45 -7
  8. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/ps.py +81 -73
  9. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/client.py +41 -6
  10. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/config.py +13 -18
  11. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/manual.md +11 -7
  12. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/types.py +12 -0
  13. contree_cli-0.5.1/contree_cli/update_check.py +165 -0
  14. {contree_cli-0.5.0 → contree_cli-0.5.1}/pyproject.toml +1 -1
  15. {contree_cli-0.5.0 → contree_cli-0.5.1}/LICENSE +0 -0
  16. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/__init__.py +0 -0
  17. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/__init__.py +0 -0
  18. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/agent.py +0 -0
  19. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/cat.py +0 -0
  20. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/cd.py +0 -0
  21. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/cp.py +0 -0
  22. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/env.py +0 -0
  23. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/file.py +0 -0
  24. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/kill.py +0 -0
  25. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/ls.py +0 -0
  26. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/run.py +0 -0
  27. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/session.py +0 -0
  28. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/show.py +0 -0
  29. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/skill.py +0 -0
  30. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/tag.py +0 -0
  31. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/cli/use.py +0 -0
  32. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/log.py +0 -0
  33. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/man.py +0 -0
  34. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/mapped_file.py +0 -0
  35. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/migrations.py +0 -0
  36. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/output.py +0 -0
  37. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/py.typed +0 -0
  38. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/session.py +0 -0
  39. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/shell/__init__.py +0 -0
  40. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/shell/completer.py +0 -0
  41. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/shell/history.py +0 -0
  42. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/shell/parser.py +0 -0
  43. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/shell/repl.py +0 -0
  44. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/shell/trie.py +0 -0
  45. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/skill.py +0 -0
  46. {contree_cli-0.5.0 → contree_cli-0.5.1}/contree_cli/skill_body.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: contree-cli
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: CLI client for the ConTree sandbox platform
5
5
  Keywords: sandbox,cli,vm,ai-agent,contree
6
6
  Author: Dmitry Orlov
@@ -279,9 +279,9 @@ contree --help
279
279
  contree auth
280
280
  ```
281
281
 
282
- You'll be prompted to enter your API token and project ID. The CLI verifies the token and saves credentials to `~/.config/contree-cli/config.ini`.
282
+ You'll be prompted to enter your API token and project ID. The CLI verifies the token and saves credentials to `~/.config/contree/auth.ini` (override the data directory via `CONTREE_HOME`).
283
283
 
284
- If `NEBIUS_API_KEY` and `NEBIUS_AI_PROJECT` environment variables are set and no CLI flags are passed, they are picked up automatically instead of prompting.
284
+ If `--token`/`--url`/`--project` flags are omitted, `contree auth` reads `CONTREE_TOKEN` (or `NEBIUS_API_KEY`), `CONTREE_URL`, and `CONTREE_PROJECT` (or `NEBIUS_AI_PROJECT`) from the environment instead of prompting. These variables are read only during registration; runtime commands use the saved profile only.
285
285
 
286
286
  ### 2. Install agent skills (optional)
287
287
 
@@ -492,17 +492,24 @@ contree auth switch staging # switch active profile
492
492
 
493
493
  ### Environment variables
494
494
 
495
+ Read at runtime (any command):
496
+
495
497
  | Variable | Purpose |
496
498
  |---|---|
497
- | `CONTREE_HOME` | Data directory (default `~/.config/contree-cli`) |
498
- | `CONTREE_TOKEN` | API bearer token (overrides config) |
499
- | `CONTREE_URL` | API base URL (overrides config) |
500
- | `CONTREE_PROJECT` | Project ID (overrides config) |
501
- | `CONTREE_PROFILE` | Active profile name |
499
+ | `CONTREE_HOME` | Data directory (default `$XDG_CONFIG_HOME/contree`, or `~/.config/contree`) |
500
+ | `CONTREE_PROFILE` | Active profile name (selects which profile commands use) |
502
501
  | `CONTREE_SESSION` | Explicit session key (for multi-terminal workflows) |
503
502
  | `CONTREE_SESSION_DB` | Path to session SQLite database |
504
503
 
505
- Environment variables take precedence over the config file. `--token` and `--url` flags override everything.
504
+ Read only by `contree auth` (registration-time fallbacks for omitted flags):
505
+
506
+ | Variable | Used for |
507
+ |---|---|
508
+ | `CONTREE_TOKEN` / `NEBIUS_API_KEY` | `--token` |
509
+ | `CONTREE_URL` | `--url` |
510
+ | `CONTREE_PROJECT` / `NEBIUS_AI_PROJECT` | `--project` |
511
+
512
+ Credentials come strictly from the saved profile at runtime. `--token`, `--url`, `--project` CLI flags override profile fields for a single invocation.
506
513
 
507
514
  ## Zero Dependencies
508
515
 
@@ -72,9 +72,9 @@ contree --help
72
72
  contree auth
73
73
  ```
74
74
 
75
- You'll be prompted to enter your API token and project ID. The CLI verifies the token and saves credentials to `~/.config/contree-cli/config.ini`.
75
+ You'll be prompted to enter your API token and project ID. The CLI verifies the token and saves credentials to `~/.config/contree/auth.ini` (override the data directory via `CONTREE_HOME`).
76
76
 
77
- If `NEBIUS_API_KEY` and `NEBIUS_AI_PROJECT` environment variables are set and no CLI flags are passed, they are picked up automatically instead of prompting.
77
+ If `--token`/`--url`/`--project` flags are omitted, `contree auth` reads `CONTREE_TOKEN` (or `NEBIUS_API_KEY`), `CONTREE_URL`, and `CONTREE_PROJECT` (or `NEBIUS_AI_PROJECT`) from the environment instead of prompting. These variables are read only during registration; runtime commands use the saved profile only.
78
78
 
79
79
  ### 2. Install agent skills (optional)
80
80
 
@@ -285,17 +285,24 @@ contree auth switch staging # switch active profile
285
285
 
286
286
  ### Environment variables
287
287
 
288
+ Read at runtime (any command):
289
+
288
290
  | Variable | Purpose |
289
291
  |---|---|
290
- | `CONTREE_HOME` | Data directory (default `~/.config/contree-cli`) |
291
- | `CONTREE_TOKEN` | API bearer token (overrides config) |
292
- | `CONTREE_URL` | API base URL (overrides config) |
293
- | `CONTREE_PROJECT` | Project ID (overrides config) |
294
- | `CONTREE_PROFILE` | Active profile name |
292
+ | `CONTREE_HOME` | Data directory (default `$XDG_CONFIG_HOME/contree`, or `~/.config/contree`) |
293
+ | `CONTREE_PROFILE` | Active profile name (selects which profile commands use) |
295
294
  | `CONTREE_SESSION` | Explicit session key (for multi-terminal workflows) |
296
295
  | `CONTREE_SESSION_DB` | Path to session SQLite database |
297
296
 
298
- Environment variables take precedence over the config file. `--token` and `--url` flags override everything.
297
+ Read only by `contree auth` (registration-time fallbacks for omitted flags):
298
+
299
+ | Variable | Used for |
300
+ |---|---|
301
+ | `CONTREE_TOKEN` / `NEBIUS_API_KEY` | `--token` |
302
+ | `CONTREE_URL` | `--url` |
303
+ | `CONTREE_PROJECT` / `NEBIUS_AI_PROJECT` | `--project` |
304
+
305
+ Credentials come strictly from the saved profile at runtime. `--token`, `--url`, `--project` CLI flags override profile fields for a single invocation.
299
306
 
300
307
  ## Zero Dependencies
301
308
 
@@ -2,6 +2,7 @@ import contextvars
2
2
  import logging
3
3
  import sys
4
4
  from collections.abc import Callable
5
+ from contextlib import suppress
5
6
  from dataclasses import replace
6
7
 
7
8
  import contree_cli.config as config_mod
@@ -12,6 +13,7 @@ from contree_cli.config import SETTINGS, Config
12
13
  from contree_cli.log import setup_logging
13
14
  from contree_cli.output import FORMATTERS
14
15
  from contree_cli.session import SessionStore, get_session_key
16
+ from contree_cli.update_check import UpdateChecker
15
17
 
16
18
  log = logging.getLogger(__name__)
17
19
 
@@ -27,6 +29,21 @@ def main() -> None:
27
29
  args = parser.parse_args()
28
30
  setup_logging(level=getattr(logging, args.log_level.upper(), logging.INFO))
29
31
 
32
+ # Update check runs only after argparse so it skips --help / --version
33
+ # / no-command paths and so the warning respects --log-level. refresh()
34
+ # is best-effort; check() is a pure predicate.
35
+ checker = UpdateChecker()
36
+ with suppress(Exception):
37
+ checker.refresh()
38
+ if not checker.is_latest():
39
+ log.warning(
40
+ "A new version of contree-cli is available: %s (installed: %s)."
41
+ " Upgrade with `uv tool install -U contree-cli` or"
42
+ " `pip install -U contree-cli`.",
43
+ checker.state.latest_version,
44
+ checker.current_version,
45
+ )
46
+
30
47
  config_mod.CONFIG_FILE = args.config_path
31
48
  config_mod.CONFIG_DIR = args.config_path.parent
32
49
 
@@ -328,18 +328,23 @@ Per-command -p is useful for cross-project operations:
328
328
  contree -p project-a images --prefix=base
329
329
  contree -p project-b images import tag:base:latest
330
330
 
331
- Data directory: ~/.config/contree-cli/
332
- config.ini profile credentials
333
- sessions-{profile}.db per-profile sessions, history, cache
334
- skills.db installed agent skill registry
331
+ Data directory: $XDG_CONFIG_HOME/contree/ (or ~/.config/contree/)
332
+ auth.ini profile credentials (mode 0600)
333
+ cli.ini optional CLI defaults
334
+ cli/sessions/{profile}.db per-profile sessions, history, cache
335
+ cli/skills.db installed agent skill registry
336
+ cli/version_check.json cached PyPI update-check state
335
337
 
336
338
  Environment variables:
337
339
  CONTREE_HOME data directory override
338
- CONTREE_TOKEN API token (overrides config)
339
- CONTREE_URL API URL (overrides config)
340
- CONTREE_PROFILE active profile (overrides config)
340
+ CONTREE_PROFILE active profile (selects which profile commands use)
341
341
  CONTREE_SESSION explicit session key
342
342
 
343
+ Read only by `contree auth` (registration-time fallbacks):
344
+ CONTREE_TOKEN / NEBIUS_API_KEY token when --token is omitted
345
+ CONTREE_URL URL when --url is omitted
346
+ CONTREE_PROJECT / NEBIUS_AI_PROJECT project ID when --project is omitted
347
+
343
348
  More: contree auth --help
344
349
 
345
350
  All commands
@@ -71,20 +71,19 @@ mutating commands (change remote or local session state):
71
71
  contree use IMAGE | run -- CMD | file edit PATH | file cp SRC DEST
72
72
  contree tag UUID TAG | kill UUID | cd PATH | session checkout BRANCH
73
73
 
74
- environment variables (advanced overrides; most users can ignore):
75
- CONTREE_TOKEN API bearer token (overrides config file)
76
- CONTREE_URL API base URL (overrides config file)
77
- CONTREE_PROJECT Project ID for IAM auth (overrides config file)
78
- CONTREE_PROFILE Active config profile (overrides config file)
79
- CONTREE_SESSION Explicit session name (for multi-terminal workflows).
80
- If unset, contree auto-generates <cwd>+<8hex> (derived from
81
- profile+ppid+tty); export your own for stable reuse.
82
- You can also pass -S/--session instead of exporting env.
83
- CONTREE_SESSION_DB Path to session SQLite database
84
-
85
- nebius shortcuts (used by `contree auth` as fallback when flags are omitted):
86
- NEBIUS_API_KEY Fallback token for auth registration
87
- NEBIUS_AI_PROJECT Fallback project ID for IAM auth registration
74
+ environment variables:
75
+ CONTREE_PROFILE Active config profile (selects which profile to use)
76
+ CONTREE_SESSION Explicit session name (for multi-terminal workflows).
77
+ If unset, contree auto-generates <cwd>+<8hex> (derived
78
+ from profile+ppid+tty); export your own for stable
79
+ reuse. You can also pass -S/--session instead.
80
+ CONTREE_SESSION_DB Path to session SQLite database
81
+ CONTREE_NO_UPDATE_CHECK Set to any value to disable PyPI update checks
82
+
83
+ registration-time fallbacks (only read by `contree auth`, not at runtime):
84
+ CONTREE_TOKEN / NEBIUS_API_KEY Token used when --token is omitted
85
+ CONTREE_URL URL used when --url is omitted
86
+ CONTREE_PROJECT / NEBIUS_AI_PROJECT Project ID used when --project is omitted
88
87
  """
89
88
 
90
89
  DESCRIPTION = """\
@@ -121,7 +120,7 @@ parser.add_argument(
121
120
  parser.add_argument(
122
121
  *FLAGS["token"],
123
122
  default=None,
124
- help="API token (overrides config and env)",
123
+ help="API token (overrides profile for this invocation)",
125
124
  )
126
125
 
127
126
 
@@ -133,12 +132,12 @@ parser.add_argument(
133
132
  *FLAGS["url"],
134
133
  default=None,
135
134
  type=_strip_trailing_slashes,
136
- help="API base URL (overrides config and env)",
135
+ help="API base URL (overrides profile for this invocation)",
137
136
  )
138
137
  parser.add_argument(
139
138
  *FLAGS["project"],
140
139
  default=None,
141
- help="Project ID (overrides config and env)",
140
+ help="Project ID (overrides profile for this invocation)",
142
141
  )
143
142
  parser.add_argument(
144
143
  *FLAGS["config"],
@@ -8,9 +8,13 @@ Supports two auth types:
8
8
  iam (default) — bearer token + project ID, default URL provided
9
9
  jwt (legacy) — bearer token only, URL must be specified
10
10
 
11
- Nebius environment variable shortcuts:
12
- NEBIUS_API_KEY used as token fallback during registration
13
- NEBIUS_AI_PROJECT used as project fallback during IAM registration
11
+ Environment variable fallbacks during registration:
12
+ CONTREE_TOKEN / NEBIUS_API_KEY used when --token is omitted
13
+ CONTREE_URL used when --url is omitted
14
+ CONTREE_PROJECT / NEBIUS_AI_PROJECT used when --project is omitted (IAM)
15
+
16
+ Other commands ignore these variables; only ``contree auth`` reads
17
+ them. ``CONTREE_PROFILE`` selects the profile for any command.
14
18
 
15
19
  Subcommands:
16
20
  profiles List saved profiles (* marks active)
@@ -22,6 +26,7 @@ from __future__ import annotations
22
26
  import argparse
23
27
  import getpass
24
28
  import hashlib
29
+ import json
25
30
  import logging
26
31
  import os
27
32
  from dataclasses import dataclass
@@ -35,6 +40,7 @@ from contree_cli.types import FLAGS
35
40
  logger = logging.getLogger(__name__)
36
41
  PROFILE_CHECK_TIMEOUT = 2.0
37
42
  PROFILE_CHECK_CONCURRENCY = 4
43
+ REQUIRED_PERMISSION = "list"
38
44
 
39
45
  EPILOG = """\
40
46
  for coding agents:
@@ -169,6 +175,24 @@ def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
169
175
  return cmd_auth, AuthArgs
170
176
 
171
177
 
178
+ def env_fallback(names: tuple[str, ...], *, what: str) -> str | None:
179
+ for name in names:
180
+ value = os.environ.get(name)
181
+ if value:
182
+ logger.info("Using %s from %s", what, name)
183
+ return value
184
+ return None
185
+
186
+
187
+ def check_permission(payload: object, permission: str) -> bool:
188
+ if not isinstance(payload, dict):
189
+ return False
190
+ perms = payload.get("permissions")
191
+ if not isinstance(perms, dict):
192
+ return False
193
+ return bool(perms.get(permission))
194
+
195
+
172
196
  def cmd_auth(args: AuthArgs) -> int | None:
173
197
  cfg = Config()
174
198
  exists = args.profile in cfg
@@ -188,18 +212,16 @@ def cmd_auth(args: AuthArgs) -> int | None:
188
212
  print("Aborted.")
189
213
  return 1
190
214
 
191
- # Token: --token > NEBIUS_API_KEY > interactive prompt
192
- token = args.token
215
+ # Token: --token > CONTREE_TOKEN > NEBIUS_API_KEY > interactive prompt
216
+ token = args.token or env_fallback(
217
+ ("CONTREE_TOKEN", "NEBIUS_API_KEY"),
218
+ what="token",
219
+ )
193
220
  if token is None:
194
- nebius_key = os.environ.get("NEBIUS_API_KEY")
195
- if nebius_key:
196
- logger.info("Using token from NEBIUS_API_KEY")
197
- token = nebius_key
198
- else:
199
- token = getpass.getpass("Token: ")
221
+ token = getpass.getpass("Token: ")
200
222
 
201
- # URL: --url > type-specific default > interactive prompt
202
- url = args.url
223
+ # URL: --url > CONTREE_URL > type-specific default > interactive prompt
224
+ url = args.url or env_fallback(("CONTREE_URL",), what="URL")
203
225
  if url is None:
204
226
  if args.auth_type == AuthType.IAM:
205
227
  url = Config.DEFAULT_IAM_URL
@@ -209,17 +231,15 @@ def cmd_auth(args: AuthArgs) -> int | None:
209
231
  logger.error("URL is required for JWT auth")
210
232
  return 1
211
233
 
212
- # Project (IAM only): --project > NEBIUS_AI_PROJECT > interactive prompt
234
+ # Project (IAM only): --project > CONTREE_PROJECT > NEBIUS_AI_PROJECT > prompt
213
235
  project: str | None = None
214
236
  if args.auth_type == AuthType.IAM:
215
- project = args.project
237
+ project = args.project or env_fallback(
238
+ ("CONTREE_PROJECT", "NEBIUS_AI_PROJECT"),
239
+ what="project",
240
+ )
216
241
  if project is None:
217
- nebius_project = os.environ.get("NEBIUS_AI_PROJECT")
218
- if nebius_project:
219
- logger.info("Using project from NEBIUS_AI_PROJECT")
220
- project = nebius_project
221
- else:
222
- project = input("Project ID: ").strip()
242
+ project = input("Project ID: ").strip()
223
243
  profile = ConfigProfile(
224
244
  name=args.profile,
225
245
  token=token,
@@ -235,15 +255,33 @@ def cmd_auth(args: AuthArgs) -> int | None:
235
255
  return 1
236
256
 
237
257
  try:
238
- client.get("/v1/whoami")
258
+ resp = client.get("/v1/whoami")
259
+ whoami = json.loads(resp.read() or b"{}")
239
260
  except ApiError as exc:
240
261
  # Logs the API error message, not the token itself.
241
262
  # nosemgrep: python-logger-credential-disclosure
242
263
  logger.error("Token verification failed: %s. Profile not changed.", exc)
243
264
  return 1
265
+ except ValueError as exc:
266
+ logger.error("Could not parse /v1/whoami response: %s", exc)
267
+ return 1
268
+
269
+ if not check_permission(whoami, REQUIRED_PERMISSION):
270
+ project_label = profile.project or profile.url
271
+ logger.warning(
272
+ "Warning: token is valid but sandboxes are disabled on %s"
273
+ " (no %r permission). The profile will be saved but no commands"
274
+ " will work until the service is enabled.",
275
+ project_label,
276
+ REQUIRED_PERMISSION,
277
+ )
244
278
 
245
279
  cfg[args.profile] = profile
246
- logger.info("Token verified and saved to profile %r", args.profile)
280
+ logger.info(
281
+ "auth accepted, profile %r saved to -> %s",
282
+ args.profile,
283
+ cfg.path,
284
+ )
247
285
  return None
248
286
 
249
287
 
@@ -284,11 +322,17 @@ def cmd_list(args: ProfilesArgs) -> None:
284
322
 
285
323
  try:
286
324
  resp = client.get("/v1/whoami")
287
- resp.read()
325
+ payload = resp.read()
288
326
  except TimeoutError:
289
327
  return profile, "timeout"
290
328
  except Exception:
291
329
  return profile, "error"
330
+ try:
331
+ whoami = json.loads(payload or b"{}")
332
+ except ValueError:
333
+ return profile, "error"
334
+ if not check_permission(whoami, REQUIRED_PERMISSION):
335
+ return profile, "inactive"
292
336
  return profile, "ok"
293
337
 
294
338
  formatter = FORMATTER.get()
@@ -25,11 +25,13 @@ from contree_cli.types import (
25
25
  isoformat_datetime,
26
26
  parse_datetime,
27
27
  parse_interval,
28
+ positive_int,
28
29
  )
29
30
 
30
31
  logger = logging.getLogger(__name__)
31
32
 
32
- PAGE_SIZE = 100
33
+ PAGE_SIZE = 1000
34
+ LIMIT_DEFAULT = 3000
33
35
  TERMINAL_STATUSES = frozenset({"SUCCESS", "FAILED", "CANCELLED"})
34
36
  DOCKER_HUB = "docker.io"
35
37
 
@@ -77,6 +79,7 @@ class ImagesArgs(ArgumentsProtocol):
77
79
  all_images: bool = False
78
80
  since: datetime | None = None
79
81
  until: datetime | None = None
82
+ limit: int = LIMIT_DEFAULT
80
83
 
81
84
  @classmethod
82
85
  def from_args(cls, ns: argparse.Namespace) -> ImagesArgs:
@@ -86,6 +89,7 @@ class ImagesArgs(ArgumentsProtocol):
86
89
  all_images=getattr(ns, "all_images", False),
87
90
  since=getattr(ns, "since", None),
88
91
  until=getattr(ns, "until", None),
92
+ limit=getattr(ns, "limit", LIMIT_DEFAULT),
89
93
  )
90
94
 
91
95
 
@@ -189,6 +193,12 @@ def _add_list_args(p: argparse.ArgumentParser) -> None:
189
193
  type=parse_interval,
190
194
  help="Show images before. " + str(parse_interval.__doc__),
191
195
  )
196
+ p.add_argument(
197
+ *FLAGS["limit"],
198
+ type=positive_int,
199
+ default=LIMIT_DEFAULT,
200
+ help="Stop after this many images and warn if more are available",
201
+ )
192
202
 
193
203
 
194
204
  def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
@@ -249,7 +259,7 @@ def cmd_images(args: ImagesArgs) -> None:
249
259
  client = CLIENT.get()
250
260
  formatter = FORMATTER.get()
251
261
 
252
- base_params: dict[str, str] = {"limit": str(PAGE_SIZE)}
262
+ base_params: dict[str, str] = {}
253
263
  if args.prefix is not None:
254
264
  base_params["tag"] = args.prefix
255
265
  if args.uuid is not None:
@@ -262,13 +272,19 @@ def cmd_images(args: ImagesArgs) -> None:
262
272
  base_params["until"] = isoformat_datetime(args.until)
263
273
 
264
274
  offset = 0
265
- while True:
266
- params = {**base_params, "offset": str(offset)}
275
+ emitted = 0
276
+ while emitted < args.limit:
277
+ page_size = min(PAGE_SIZE, args.limit - emitted)
278
+ params = {
279
+ **base_params,
280
+ "offset": str(offset),
281
+ "limit": str(page_size),
282
+ }
267
283
  resp = client.get("/v1/images", params=params)
268
284
  data = json.loads(resp.read())
269
285
  images = data["images"]
270
286
  if not images:
271
- break
287
+ return
272
288
  for image in images:
273
289
  created_at = parse_datetime(image["created_at"])
274
290
  formatter(
@@ -276,9 +292,31 @@ def cmd_images(args: ImagesArgs) -> None:
276
292
  created_at=created_at,
277
293
  tag=image.get("tag") or "",
278
294
  )
279
- if len(images) < PAGE_SIZE:
280
- break
295
+ emitted += len(images)
296
+ if len(images) < page_size:
297
+ return
281
298
  offset += len(images)
299
+ if emitted < args.limit:
300
+ logger.info(
301
+ "Fetched %d images so far... (press Ctrl+C to break)",
302
+ emitted,
303
+ )
304
+
305
+ # Hit the limit. Probe one extra record (offset=emitted, limit=1) to
306
+ # detect truncation without re-fetching a full page.
307
+ probe_params = {**base_params, "offset": str(offset), "limit": "1"}
308
+ resp = client.get("/v1/images", params=probe_params)
309
+ data = json.loads(resp.read())
310
+ if data.get("images"):
311
+ # Flush buffered output (e.g. TableFormatter) before the warning
312
+ # so the truncation note appears AFTER the listing on screen.
313
+ formatter.flush()
314
+ logger.warning(
315
+ "Output truncated at --limit=%d images; more results are"
316
+ " available. Raise --limit or narrow with"
317
+ " --prefix/--since/--until.",
318
+ args.limit,
319
+ )
282
320
 
283
321
 
284
322
  def _parse_explicit_tag(ref: str) -> tuple[str, str | None]:
@@ -13,14 +13,19 @@ import argparse
13
13
  import itertools
14
14
  import json
15
15
  import logging
16
- from collections.abc import Iterator
17
16
  from dataclasses import dataclass
18
17
  from datetime import datetime, timedelta
19
18
  from typing import Any
20
19
 
21
20
  from contree_cli import CLIENT, FORMATTER, ArgumentsProtocol, SetupResult
22
21
  from contree_cli.output import OutputFormatter
23
- from contree_cli.types import FLAGS, isoformat_datetime, parse_datetime, parse_interval
22
+ from contree_cli.types import (
23
+ FLAGS,
24
+ isoformat_datetime,
25
+ parse_datetime,
26
+ parse_interval,
27
+ positive_int,
28
+ )
24
29
 
25
30
  logger = logging.getLogger(__name__)
26
31
 
@@ -103,7 +108,7 @@ def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
103
108
 
104
109
  p.add_argument(
105
110
  *FLAGS["show_max"],
106
- type=int,
111
+ type=positive_int,
107
112
  default=1000,
108
113
  help=(
109
114
  "Show at most this many operations, useful"
@@ -114,64 +119,26 @@ def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
114
119
  return cmd_ps, PsArgs
115
120
 
116
121
 
117
- def operations_iterator(
118
- status: str | None = None,
119
- kind: str | None = None,
120
- show_max: int | None = None,
121
- page_size: int = PAGE_SIZE,
122
- since: str | None = None,
123
- until: str | None = None,
124
- ) -> Iterator[dict[str, Any]]:
125
- client = CLIENT.get()
126
- assert client is not None, "Client not initialized"
127
-
128
- offset = 0
129
- counter = 1
130
- while True:
131
- params: dict[str, str] = {
132
- "limit": str(page_size),
133
- "offset": str(offset),
134
- }
135
- if status:
136
- params["status"] = status
137
- if kind:
138
- params["kind"] = kind
139
- if since:
140
- params["since"] = since
141
- if until:
142
- params["until"] = until
143
- resp = client.get("/v1/operations", params=params)
144
- operations = json.loads(resp.read())
145
-
146
- if not operations:
147
- break
148
-
149
- offset += len(operations)
150
- for op in operations:
151
- if show_max is not None and counter >= show_max:
152
- logger.warning(
153
- "Reached show_max limit of %d, for see more increase --show-max",
154
- show_max,
155
- )
156
- return
157
- counter += 1
158
- yield dict(
159
- uuid=op["uuid"],
160
- status=op["status"],
161
- kind=op["kind"],
162
- created_at=parse_datetime(op["created_at"]),
163
- duration=timedelta(seconds=op["duration"])
164
- if op.get("duration") is not None
165
- else None,
166
- error=op.get("error") or "",
167
- )
168
-
169
- if len(operations) < page_size:
170
- break
122
+ def emit_op(formatter: OutputFormatter, op: dict[str, Any], *, quiet: bool) -> None:
123
+ row = dict(
124
+ uuid=op["uuid"],
125
+ status=op["status"],
126
+ kind=op["kind"],
127
+ created_at=parse_datetime(op["created_at"]),
128
+ duration=timedelta(seconds=op["duration"])
129
+ if op.get("duration") is not None
130
+ else None,
131
+ error=op.get("error") or "",
132
+ )
133
+ if quiet:
134
+ print(row["uuid"])
135
+ else:
136
+ formatter(**row)
171
137
 
172
138
 
173
139
  def cmd_ps(args: PsArgs) -> None:
174
140
  formatter: OutputFormatter = FORMATTER.get()
141
+ client = CLIENT.get()
175
142
 
176
143
  status: str | None = None
177
144
  if args.status is not None:
@@ -182,21 +149,62 @@ def cmd_ps(args: PsArgs) -> None:
182
149
  elif not args.all:
183
150
  status = "EXECUTING"
184
151
 
185
- since: str | None = None
152
+ base_params: dict[str, str] = {}
153
+ if status:
154
+ base_params["status"] = status
155
+ if args.kind:
156
+ base_params["kind"] = args.kind
186
157
  if args.since is not None:
187
- since = isoformat_datetime(args.since)
188
- until: str | None = None
158
+ base_params["since"] = isoformat_datetime(args.since)
189
159
  if args.until is not None:
190
- until = isoformat_datetime(args.until)
191
-
192
- for op in operations_iterator(
193
- status=status,
194
- kind=args.kind,
195
- show_max=args.show_max,
196
- since=since,
197
- until=until,
198
- ):
199
- if args.quiet:
200
- print(op["uuid"])
201
- else:
202
- formatter(**op)
160
+ base_params["until"] = isoformat_datetime(args.until)
161
+
162
+ limit = args.show_max
163
+ offset = 0
164
+ emitted = 0
165
+ hit_limit = False
166
+
167
+ while limit is None or emitted < limit:
168
+ page_size = PAGE_SIZE if limit is None else min(PAGE_SIZE, limit - emitted)
169
+ params = {
170
+ **base_params,
171
+ "offset": str(offset),
172
+ "limit": str(page_size),
173
+ }
174
+ resp = client.get("/v1/operations", params=params)
175
+ operations = json.loads(resp.read())
176
+ if not operations:
177
+ return
178
+ for op in operations:
179
+ if limit is not None and emitted >= limit:
180
+ hit_limit = True
181
+ break
182
+ emit_op(formatter, op, quiet=args.quiet)
183
+ emitted += 1
184
+ if hit_limit:
185
+ break
186
+ if len(operations) < page_size:
187
+ return
188
+ offset += len(operations)
189
+ if limit is None or emitted < limit:
190
+ logger.info(
191
+ "Fetched %d operations so far... (press Ctrl+C to break)",
192
+ emitted,
193
+ )
194
+
195
+ if limit is None:
196
+ return
197
+
198
+ # Hit the limit. Probe one extra record (offset=emitted, limit=1) to
199
+ # detect truncation without re-fetching a full page.
200
+ probe_params = {**base_params, "offset": str(emitted), "limit": "1"}
201
+ resp = client.get("/v1/operations", params=probe_params)
202
+ operations = json.loads(resp.read())
203
+ if operations:
204
+ formatter.flush()
205
+ logger.warning(
206
+ "Output truncated at --show-max=%d operations; more results"
207
+ " are available. Raise --show-max or filter with"
208
+ " --status/--kind/--since/--until.",
209
+ limit,
210
+ )
@@ -9,7 +9,7 @@ import platform
9
9
  import sys
10
10
  import time
11
11
  from abc import ABC, abstractmethod
12
- from collections.abc import Iterator
12
+ from collections.abc import Iterable, Iterator
13
13
  from importlib.metadata import PackageNotFoundError, version
14
14
  from typing import IO, cast
15
15
  from urllib.parse import urlencode, urlsplit
@@ -21,7 +21,7 @@ log = logging.getLogger(__name__)
21
21
  RETRY_DELAYS = (1, 2, 4, 5, 10, 10, 10)
22
22
 
23
23
 
24
- def _cli_version() -> str:
24
+ def cli_version() -> str:
25
25
  try:
26
26
  return version("contree-cli")
27
27
  except PackageNotFoundError:
@@ -29,12 +29,43 @@ def _cli_version() -> str:
29
29
 
30
30
 
31
31
  CLI_USER_AGENT = (
32
- f"contree-cli/{_cli_version()} "
32
+ f"contree-cli/{cli_version()} "
33
33
  f"Python/{'.'.join(map(str, sys.version_info))} "
34
34
  f"{platform.platform()} "
35
35
  )
36
36
 
37
37
 
38
+ class HeaderFormatter:
39
+ """Lazy redactor for HTTP headers, formats only on emit."""
40
+
41
+ SENSITIVE_HEADERS = frozenset(
42
+ {
43
+ "authorization",
44
+ "proxy-authorization",
45
+ "cookie",
46
+ "set-cookie",
47
+ "x-api-key",
48
+ "x-auth-token",
49
+ }
50
+ )
51
+
52
+ def __init__(
53
+ self,
54
+ headers: dict[str, str] | list[tuple[str, str]],
55
+ ) -> None:
56
+ self.headers = headers
57
+
58
+ def __str__(self) -> str:
59
+ items: Iterable[tuple[str, str]] = (
60
+ self.headers.items() if isinstance(self.headers, dict) else self.headers
61
+ )
62
+ redacted = {
63
+ k: "<redacted>" if k.lower() in self.SENSITIVE_HEADERS else v
64
+ for k, v in items
65
+ }
66
+ return repr(redacted)
67
+
68
+
38
69
  class BodyFormatter:
39
70
  """Lazy %s-arg for logging HTTP bodies — formats only on emit."""
40
71
 
@@ -186,9 +217,10 @@ class ContreeClient(ABC):
186
217
  attempts = len(RETRY_DELAYS) + 1
187
218
 
188
219
  log.debug(
189
- "%s %s body=%s",
220
+ "%s %s headers=%s body=%s",
190
221
  method,
191
222
  full_path,
223
+ HeaderFormatter(merged),
192
224
  BodyFormatter(body, content_type=merged.get("Content-Type", "")),
193
225
  )
194
226
 
@@ -220,24 +252,27 @@ class ContreeClient(ABC):
220
252
 
221
253
  if 200 <= resp.status < 300:
222
254
  log.debug(
223
- "%s %s -> %d %s",
255
+ "%s %s -> %d %s headers=%s",
224
256
  method,
225
257
  full_path,
226
258
  resp.status,
227
259
  resp.reason,
260
+ HeaderFormatter(list(resp.getheaders())),
228
261
  )
229
262
  if log.isEnabledFor(logging.DEBUG):
230
263
  return self.log_and_buffer(method, full_path, resp)
231
264
  return resp
232
265
 
266
+ resp_headers = list(resp.getheaders())
233
267
  resp_body = resp.read().decode("utf-8", errors="replace")
234
268
  log.debug(
235
- "%s %s -> %d %s (%dB)",
269
+ "%s %s -> %d %s (%dB) headers=%s",
236
270
  method,
237
271
  full_path,
238
272
  resp.status,
239
273
  resp.reason,
240
274
  len(resp_body),
275
+ HeaderFormatter(resp_headers),
241
276
  )
242
277
  log.debug(
243
278
  "%s %s response body: %s",
@@ -94,6 +94,10 @@ class Config(MutableMapping[str, ConfigProfile]):
94
94
  self.__active: str = "default"
95
95
  self._load()
96
96
 
97
+ @property
98
+ def path(self) -> Path:
99
+ return self.__path
100
+
97
101
  # -- persistence ---------------------------------------------------------
98
102
 
99
103
  def _load(self) -> None:
@@ -191,32 +195,23 @@ class Config(MutableMapping[str, ConfigProfile]):
191
195
  self._save()
192
196
 
193
197
  def resolve(self, profile_override: str | None = None) -> ConfigProfile:
194
- """Resolve the active profile with env-var overrides.
198
+ """Resolve the active profile by name.
195
199
 
196
200
  Priority: *profile_override* > ``CONTREE_PROFILE`` > config default.
197
- Per-field: ``CONTREE_TOKEN`` / ``CONTREE_URL`` / ``CONTREE_PROJECT``
198
- override the stored values.
201
+ Credentials come strictly from the saved profile; runtime
202
+ commands do not read tokens, URLs, or project IDs from the
203
+ environment. To register/refresh credentials from env vars use
204
+ ``contree auth``.
199
205
  """
200
206
  name = profile_override or os.environ.get("CONTREE_PROFILE") or self.__active
201
- env_token = os.environ.get("CONTREE_TOKEN")
202
- env_url = os.environ.get("CONTREE_URL")
203
- env_project = os.environ.get("CONTREE_PROJECT")
204
-
205
207
  if name in self.__profiles:
206
- p = self.__profiles[name]
207
- return ConfigProfile(
208
- name=name,
209
- token=env_token or p.token,
210
- url=env_url or p.url,
211
- auth_type=p.auth_type,
212
- project=env_project or p.project,
213
- )
208
+ return self.__profiles[name]
214
209
  return ConfigProfile(
215
210
  name=name,
216
- token=env_token,
217
- url=env_url or "",
211
+ token=None,
212
+ url="",
218
213
  auth_type=AuthType.JWT,
219
- project=env_project,
214
+ project=None,
220
215
  )
221
216
 
222
217
  def switch(self, name: str) -> None:
@@ -198,9 +198,11 @@ Profiles
198
198
 
199
199
  Each profile has its own session database.
200
200
 
201
- Auth fallback: if `NEBIUS_API_KEY` and `NEBIUS_AI_PROJECT` are set
202
- in the environment and no `--token`/`--project` flags are passed,
203
- `contree auth` picks them up automatically (no interactive prompts).
201
+ Auth fallback: when `--token`/`--url`/`--project` flags are omitted,
202
+ `contree auth` reads `CONTREE_TOKEN` (or `NEBIUS_API_KEY`),
203
+ `CONTREE_URL`, and `CONTREE_PROJECT` (or `NEBIUS_AI_PROJECT`) from
204
+ the environment. These are used only during registration; runtime
205
+ commands read credentials strictly from the saved profile.
204
206
 
205
207
  More: contree auth --help
206
208
 
@@ -218,12 +220,14 @@ override with $CONTREE_HOME.
218
220
  Environment variables:
219
221
  CONTREE_HOME data directory
220
222
  XDG_CONFIG_HOME XDG base config dir, used to derive default CONTREE_HOME
221
- CONTREE_TOKEN API token (overrides config)
222
- CONTREE_URL API URL (overrides config)
223
- CONTREE_PROJECT project ID (overrides config)
224
- CONTREE_PROFILE active profile
223
+ CONTREE_PROFILE active profile (selects which profile commands use)
225
224
  CONTREE_SESSION explicit session key
226
225
 
226
+ Registration-time fallbacks (read only by `contree auth`):
227
+ CONTREE_TOKEN / NEBIUS_API_KEY token when --token is omitted
228
+ CONTREE_URL URL when --url is omitted
229
+ CONTREE_PROJECT / NEBIUS_AI_PROJECT project ID when --project is omitted
230
+
227
231
  More: contree --help
228
232
 
229
233
  All commands
@@ -62,6 +62,7 @@ FLAGS: Mapping[str, tuple[str, ...]] = MappingProxyType(
62
62
  "uuid": ("-i", "--uuid"),
63
63
  "username": ("--username",),
64
64
  "password": ("--password",),
65
+ "limit": ("--limit",),
65
66
  # use
66
67
  "new": ("-N", "--new"),
67
68
  # session
@@ -89,6 +90,17 @@ class ArgumentsFormatter(argparse.RawDescriptionHelpFormatter):
89
90
  return help_text
90
91
 
91
92
 
93
+ def positive_int(value: str) -> int:
94
+ """argparse type for flags that must be at least 1."""
95
+ try:
96
+ n = int(value)
97
+ except ValueError as exc:
98
+ raise argparse.ArgumentTypeError(f"invalid int value: {value!r}") from exc
99
+ if n < 1:
100
+ raise argparse.ArgumentTypeError(f"must be >= 1, got {n}")
101
+ return n
102
+
103
+
92
104
  def get_command_docs(setup_fn: SetupFn) -> tuple[str | None, str | None]:
93
105
  """Extract description and epilog from the module that defines *setup_fn*.
94
106
 
@@ -0,0 +1,165 @@
1
+ """PyPI update check, rate-limited to once per day.
2
+
3
+ State file at ``$CONTREE_HOME/cli/version_check.json``::
4
+
5
+ {
6
+ "last_check": 1762555200,
7
+ "latest_version": "0.5.0"
8
+ }
9
+
10
+ ``last_check`` is a Unix epoch timestamp; storing seconds keeps the
11
+ freshness check trivial (one subtraction) and immune to timezone /
12
+ ISO-format quirks.
13
+
14
+ Network errors, malformed cache files, and parse failures are swallowed:
15
+ the update check must never break a user's command.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import re
23
+ import time
24
+ import urllib.request
25
+ from contextlib import suppress
26
+ from dataclasses import asdict, dataclass
27
+ from datetime import timedelta
28
+ from pathlib import Path
29
+
30
+ from contree_cli import config
31
+ from contree_cli.client import CLI_USER_AGENT, cli_version
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class UpdateState:
36
+ last_check: int = 0
37
+ latest_version: str = ""
38
+
39
+ @classmethod
40
+ def from_file(cls, path: Path) -> UpdateState:
41
+ try:
42
+ with path.open() as f:
43
+ data = json.load(f)
44
+ return cls(
45
+ last_check=int(data["last_check"]),
46
+ latest_version=str(data["latest_version"]),
47
+ )
48
+ except Exception:
49
+ return cls()
50
+
51
+ def to_file(self, path: Path) -> None:
52
+ with suppress(OSError):
53
+ path.parent.mkdir(parents=True, exist_ok=True)
54
+ path.write_text(json.dumps(asdict(self), indent=1))
55
+
56
+
57
+ class UpdateChecker:
58
+ PYPI_URL = "https://pypi.org/pypi/contree-cli/json"
59
+ CHECK_INTERVAL = timedelta(days=1)
60
+ NETWORK_TIMEOUT = 2.0
61
+ OPT_OUT_ENV = "CONTREE_NO_UPDATE_CHECK"
62
+ STATE_PATH = config.CONTREE_HOME / "cli" / "version_check.json"
63
+ # Capture leading digits of each dot-separated component; anything
64
+ # past the digits (``a1``, ``-rc.1``, etc.) marks a pre-release.
65
+ COMPONENT_REGEX = re.compile(r"\d+")
66
+
67
+ def __init__(
68
+ self,
69
+ *,
70
+ state_path: Path = STATE_PATH,
71
+ current_version: str = cli_version(),
72
+ ) -> None:
73
+ self.state_path = state_path
74
+ self.current_version = current_version
75
+ # ``state`` holds whatever we know about PyPI's latest version.
76
+ # Default sentinel ("", last_check=0) means "no cache yet" —
77
+ # is_latest() treats it as up-to-date so callers don't warn.
78
+ self.state: UpdateState = UpdateState()
79
+
80
+ def parse_version(self, value: str) -> tuple[tuple[int, int], ...]:
81
+ """Parse ``value`` into a sortable tuple of ``(number, rank)``.
82
+
83
+ ``rank`` is ``1`` for a clean numeric component and ``0`` for a
84
+ pre-release suffix (``a1``, ``-rc.1``, …). With this encoding,
85
+ ``0.4.2a1`` < ``0.4.2`` < ``0.4.21`` as expected. Components with
86
+ no digits at all are dropped.
87
+ """
88
+ parts: list[tuple[int, int]] = []
89
+ for raw in value.split("."):
90
+ match = self.COMPONENT_REGEX.search(raw)
91
+ if not match:
92
+ continue
93
+ tail = raw[match.end() :]
94
+ parts.append((int(match.group()), 0 if tail else 1))
95
+ return tuple(parts)
96
+
97
+ def fetch_latest_version(self) -> str | None:
98
+ try:
99
+ request = urllib.request.Request(
100
+ self.PYPI_URL,
101
+ headers={
102
+ "User-Agent": CLI_USER_AGENT,
103
+ "Accept": "application/json",
104
+ },
105
+ )
106
+ with urllib.request.urlopen( # nosemgrep
107
+ request, timeout=self.NETWORK_TIMEOUT
108
+ ) as resp:
109
+ payload = json.loads(resp.read())
110
+ except Exception:
111
+ return None
112
+ info = payload.get("info") if isinstance(payload, dict) else None
113
+ if not isinstance(info, dict):
114
+ return None
115
+ version = info.get("version")
116
+ return version if isinstance(version, str) else None
117
+
118
+ @property
119
+ def enabled(self) -> bool:
120
+ return self.OPT_OUT_ENV not in os.environ and self.current_version != "editable"
121
+
122
+ def is_cache_fresh(self, state: UpdateState) -> bool:
123
+ """True if ``state.last_check`` is within ``CHECK_INTERVAL``."""
124
+ return time.time() - state.last_check < self.CHECK_INTERVAL.total_seconds()
125
+
126
+ def refresh(self) -> None:
127
+ """Load the cache, refetch from PyPI if stale, persist new state.
128
+
129
+ Populates ``self.state`` with whatever we know after this call.
130
+ :meth:`is_latest` then decides whether to warn based purely on
131
+ in-memory state — no further file IO.
132
+ """
133
+ if not self.enabled:
134
+ return
135
+
136
+ self.state = UpdateState.from_file(self.state_path)
137
+ if self.is_cache_fresh(self.state):
138
+ return
139
+
140
+ latest = self.fetch_latest_version()
141
+ if latest is None:
142
+ # Network failed; keep whatever was cached.
143
+ return
144
+
145
+ self.state = UpdateState(
146
+ last_check=int(time.time()),
147
+ latest_version=latest,
148
+ )
149
+ self.state.to_file(self.state_path)
150
+
151
+ def is_latest(self) -> bool:
152
+ """Return True if the installed version is at or ahead of the cached
153
+ ``latest_version``.
154
+
155
+ Returns True when checks are disabled or the cached
156
+ ``latest_version`` is the empty sentinel — callers default to
157
+ "no warning" in those cases. Pure decision based on in-memory
158
+ state populated by :meth:`refresh`; never touches the network
159
+ or filesystem.
160
+ """
161
+ if not self.enabled or not self.state.latest_version:
162
+ return True
163
+ return self.parse_version(self.current_version) >= self.parse_version(
164
+ self.state.latest_version,
165
+ )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "contree-cli"
3
- version = "0.5.0"
3
+ version = "0.5.1"
4
4
  description = "CLI client for the ConTree sandbox platform"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
File without changes