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.
- {contree_cli-0.4.5 → contree_cli-0.5.1}/PKG-INFO +16 -9
- {contree_cli-0.4.5 → contree_cli-0.5.1}/README.md +15 -8
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/__main__.py +35 -9
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/agent.md +12 -7
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/arguments.py +17 -18
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/auth.py +68 -24
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/file.py +14 -14
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/images.py +45 -7
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/ps.py +81 -73
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/run.py +74 -26
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/client.py +192 -13
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/config.py +62 -25
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/manual.md +18 -11
- contree_cli-0.5.1/contree_cli/migrations.py +92 -0
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/session.py +2 -0
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/shell/repl.py +1 -10
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/skill.py +1 -1
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/types.py +12 -0
- contree_cli-0.5.1/contree_cli/update_check.py +165 -0
- {contree_cli-0.4.5 → contree_cli-0.5.1}/pyproject.toml +1 -1
- {contree_cli-0.4.5 → contree_cli-0.5.1}/LICENSE +0 -0
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/__init__.py +0 -0
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/__init__.py +0 -0
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/agent.py +0 -0
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/cat.py +0 -0
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/cd.py +0 -0
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/cp.py +0 -0
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/env.py +0 -0
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/kill.py +0 -0
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/ls.py +0 -0
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/session.py +0 -0
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/show.py +0 -0
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/skill.py +0 -0
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/tag.py +0 -0
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/cli/use.py +0 -0
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/log.py +0 -0
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/man.py +0 -0
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/mapped_file.py +0 -0
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/output.py +0 -0
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/py.typed +0 -0
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/shell/__init__.py +0 -0
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/shell/completer.py +0 -0
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/shell/history.py +0 -0
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/shell/parser.py +0 -0
- {contree_cli-0.4.5 → contree_cli-0.5.1}/contree_cli/shell/trie.py +0 -0
- {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.
|
|
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
|
|
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`
|
|
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
|
|
498
|
-
| `
|
|
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
|
-
|
|
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
|
|
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`
|
|
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
|
|
291
|
-
| `
|
|
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
|
-
|
|
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
|
-
#
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
12
|
-
NEBIUS_API_KEY used
|
|
13
|
-
|
|
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
|
-
|
|
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 >
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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] = {
|
|
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
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
280
|
-
|
|
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]:
|