contree-cli 0.4.5__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.4.5 → contree_cli-0.5.1}/PKG-INFO +16 -9
  2. {contree_cli-0.4.5 → contree_cli-0.5.1}/README.md +15 -8
  3. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/__main__.py +35 -9
  4. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/agent.md +12 -7
  5. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/arguments.py +17 -18
  6. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/auth.py +68 -24
  7. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/file.py +14 -14
  8. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/images.py +45 -7
  9. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/ps.py +81 -73
  10. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/run.py +74 -26
  11. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/client.py +192 -13
  12. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/config.py +62 -25
  13. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/manual.md +18 -11
  14. contree_cli-0.5.1/contree_cli/migrations.py +92 -0
  15. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/session.py +2 -0
  16. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/shell/repl.py +1 -10
  17. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/skill.py +1 -1
  18. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/types.py +12 -0
  19. contree_cli-0.5.1/contree_cli/update_check.py +165 -0
  20. {contree_cli-0.4.5 → contree_cli-0.5.1}/pyproject.toml +1 -1
  21. {contree_cli-0.4.5 → contree_cli-0.5.1}/LICENSE +0 -0
  22. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/__init__.py +0 -0
  23. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/__init__.py +0 -0
  24. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/agent.py +0 -0
  25. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/cat.py +0 -0
  26. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/cd.py +0 -0
  27. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/cp.py +0 -0
  28. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/env.py +0 -0
  29. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/kill.py +0 -0
  30. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/ls.py +0 -0
  31. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/session.py +0 -0
  32. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/show.py +0 -0
  33. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/skill.py +0 -0
  34. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/tag.py +0 -0
  35. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/use.py +0 -0
  36. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/log.py +0 -0
  37. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/man.py +0 -0
  38. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/mapped_file.py +0 -0
  39. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/output.py +0 -0
  40. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/py.typed +0 -0
  41. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/shell/__init__.py +0 -0
  42. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/shell/completer.py +0 -0
  43. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/shell/history.py +0 -0
  44. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/shell/parser.py +0 -0
  45. {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/shell/trie.py +0 -0
  46. {contree_cli-0.4.5 → 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.4.5
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,16 +2,18 @@ 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
8
9
  from contree_cli import CLIENT, FORMATTER, PROFILE, SESSION_STORE, ArgumentsProtocol
9
10
  from contree_cli.arguments import parser
10
11
  from contree_cli.client import ApiError, client_from_profile
11
- from contree_cli.config import Config
12
+ 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
 
@@ -21,9 +23,27 @@ def main() -> None:
21
23
  parser.print_help()
22
24
  exit(0)
23
25
 
26
+ if SETTINGS.has_section("cli"):
27
+ parser.set_defaults(**SETTINGS["cli"])
28
+
24
29
  args = parser.parse_args()
25
30
  setup_logging(level=getattr(logging, args.log_level.upper(), logging.INFO))
26
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
+
27
47
  config_mod.CONFIG_FILE = args.config_path
28
48
  config_mod.CONFIG_DIR = args.config_path.parent
29
49
 
@@ -38,14 +58,19 @@ def main() -> None:
38
58
  if args.project:
39
59
  profile = replace(profile, project=args.project)
40
60
 
41
- # auth creates its own client and can work with nonexistent profiles
42
- if args.command not in ("auth",):
43
- if profile.name not in cfg:
44
- log.error(
45
- "Profile %r does not exist. Run `contree auth` first.",
46
- profile.name,
47
- )
48
- exit(1)
61
+ # Local-only commands don't need a client or a configured profile:
62
+ # auth bootstraps its own; agent/man/skill operate purely on local files.
63
+ LOCAL_COMMANDS = ("auth", "agent", "man", "skill")
64
+ needs_client = args.command not in LOCAL_COMMANDS
65
+
66
+ if needs_client and profile.name not in cfg:
67
+ log.error(
68
+ "Profile %r does not exist. Run `contree auth` first.",
69
+ profile.name,
70
+ )
71
+ exit(1)
72
+
73
+ if needs_client:
49
74
  try:
50
75
  client = client_from_profile(profile)
51
76
  except ValueError as exc:
@@ -57,6 +82,7 @@ def main() -> None:
57
82
 
58
83
  session_key = get_session_key(profile.name, override=args.session_key)
59
84
  db_path = profile.session_db_path
85
+ log.debug("Running in session: %s", session_key)
60
86
 
61
87
  with SessionStore(db_path, session_key) as store:
62
88
  PROFILE.set(profile)
@@ -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 = """\
@@ -95,7 +94,7 @@ through the ConTree REST API.
95
94
 
96
95
  Authentication:
97
96
  Bearer token + project ID. Default API URL:
98
- https://api.studio.nebius.com/sandboxes/
97
+ https://api.tokenfactory.nebius.com/sandboxes/
99
98
 
100
99
  Use `contree auth --help` to configure persistent credentials.
101
100
 
@@ -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()
@@ -19,7 +19,6 @@ import argparse
19
19
  import hashlib
20
20
  import json
21
21
  import logging
22
- import os
23
22
  import shlex
24
23
  import subprocess
25
24
  import tempfile
@@ -28,6 +27,7 @@ from pathlib import Path
28
27
 
29
28
  from contree_cli import CLIENT, SESSION_STORE, ArgumentsProtocol, SetupResult
30
29
  from contree_cli.client import ApiError, ContreeClient, resolve_image, stream_response
30
+ from contree_cli.config import EDITOR
31
31
  from contree_cli.session import SessionStore
32
32
  from contree_cli.types import FLAGS
33
33
 
@@ -79,7 +79,8 @@ def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
79
79
  )
80
80
  edit_p.add_argument(
81
81
  *FLAGS["editor"],
82
- help="Editor command (default: $EDITOR or vi)",
82
+ default=EDITOR,
83
+ help=f"Editor command (default: {EDITOR})",
83
84
  )
84
85
  edit_p.add_argument("path", help="Path inside image")
85
86
  edit_p.set_defaults(handler=cmd_file_edit, load_args=FileEditArgs)
@@ -128,14 +129,14 @@ def _upload_and_record(
128
129
  except ApiError as exc:
129
130
  if exc.status != 404:
130
131
  raise
131
- data = local_path.read_bytes()
132
- resp = client.request(
133
- "POST",
134
- "/v1/files",
135
- body=data,
136
- headers={"Content-Type": "application/octet-stream"},
137
- )
138
- file_uuid = json.loads(resp.read())["uuid"]
132
+ with open(local_path, "rb") as fh:
133
+ resp = client.request(
134
+ "POST",
135
+ "/v1/files",
136
+ body=fh,
137
+ headers={"Content-Type": "application/octet-stream"},
138
+ )
139
+ file_uuid = json.loads(resp.read())["uuid"]
139
140
  logger.info("Uploaded %s (%s)", instance_path, file_uuid)
140
141
 
141
142
  history_id = store.set_image(
@@ -178,14 +179,13 @@ def cmd_file_edit(args: FileEditArgs) -> int | None:
178
179
 
179
180
  # 2. Record original hash, open editor
180
181
  original_hash = _file_sha256(tmp_file)
181
- editor = args.editor or os.environ.get("EDITOR", "vi")
182
- logger.info("Opening %s in %s", tmp_file, editor)
182
+ logger.info("Opening %s in %s", tmp_file, args.editor)
183
183
  # $EDITOR may contain shell expressions (env vars, tilde, pipes),
184
184
  # e.g. "TERM=xterm vim" or "~/bin/editor". shlex.split would not
185
- # expand those shell=True is required. The file path is quoted
185
+ # expand those, shell=True is required. The file path is quoted
186
186
  # via shlex.quote to prevent injection from the filename.
187
187
  # nosemgrep: subprocess-shell-true
188
- rc = subprocess.call(f"{editor} {shlex.quote(str(tmp_file))}", shell=True)
188
+ rc = subprocess.call(f"{args.editor} {shlex.quote(str(tmp_file))}", shell=True)
189
189
  if rc != 0:
190
190
  logger.error("Editor exited with code %d", rc)
191
191
  return 1
@@ -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]: