contree-cli 0.4.4__tar.gz → 0.5.0__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.4 → contree_cli-0.5.0}/PKG-INFO +1 -1
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/__main__.py +18 -9
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/arguments.py +1 -1
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/file.py +14 -14
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/run.py +74 -26
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/client.py +152 -8
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/config.py +49 -7
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/manual.md +7 -4
- contree_cli-0.5.0/contree_cli/migrations.py +92 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/session.py +2 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/shell/repl.py +1 -10
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/skill.py +1 -1
- {contree_cli-0.4.4 → contree_cli-0.5.0}/pyproject.toml +1 -1
- {contree_cli-0.4.4 → contree_cli-0.5.0}/LICENSE +0 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/README.md +0 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/__init__.py +0 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/agent.md +0 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/__init__.py +0 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/agent.py +0 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/auth.py +0 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/cat.py +0 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/cd.py +0 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/cp.py +0 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/env.py +0 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/images.py +0 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/kill.py +0 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/ls.py +0 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/ps.py +0 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/session.py +0 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/show.py +0 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/skill.py +0 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/tag.py +0 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/use.py +0 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/log.py +0 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/man.py +0 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/mapped_file.py +0 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/output.py +0 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/py.typed +0 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/shell/__init__.py +0 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/shell/completer.py +0 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/shell/history.py +0 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/shell/parser.py +0 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/shell/trie.py +0 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/skill_body.md +0 -0
- {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/types.py +0 -0
|
@@ -8,7 +8,7 @@ import contree_cli.config as config_mod
|
|
|
8
8
|
from contree_cli import CLIENT, FORMATTER, PROFILE, SESSION_STORE, ArgumentsProtocol
|
|
9
9
|
from contree_cli.arguments import parser
|
|
10
10
|
from contree_cli.client import ApiError, client_from_profile
|
|
11
|
-
from contree_cli.config import Config
|
|
11
|
+
from contree_cli.config import SETTINGS, Config
|
|
12
12
|
from contree_cli.log import setup_logging
|
|
13
13
|
from contree_cli.output import FORMATTERS
|
|
14
14
|
from contree_cli.session import SessionStore, get_session_key
|
|
@@ -21,6 +21,9 @@ def main() -> None:
|
|
|
21
21
|
parser.print_help()
|
|
22
22
|
exit(0)
|
|
23
23
|
|
|
24
|
+
if SETTINGS.has_section("cli"):
|
|
25
|
+
parser.set_defaults(**SETTINGS["cli"])
|
|
26
|
+
|
|
24
27
|
args = parser.parse_args()
|
|
25
28
|
setup_logging(level=getattr(logging, args.log_level.upper(), logging.INFO))
|
|
26
29
|
|
|
@@ -38,14 +41,19 @@ def main() -> None:
|
|
|
38
41
|
if args.project:
|
|
39
42
|
profile = replace(profile, project=args.project)
|
|
40
43
|
|
|
41
|
-
#
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
# Local-only commands don't need a client or a configured profile:
|
|
45
|
+
# auth bootstraps its own; agent/man/skill operate purely on local files.
|
|
46
|
+
LOCAL_COMMANDS = ("auth", "agent", "man", "skill")
|
|
47
|
+
needs_client = args.command not in LOCAL_COMMANDS
|
|
48
|
+
|
|
49
|
+
if needs_client and profile.name not in cfg:
|
|
50
|
+
log.error(
|
|
51
|
+
"Profile %r does not exist. Run `contree auth` first.",
|
|
52
|
+
profile.name,
|
|
53
|
+
)
|
|
54
|
+
exit(1)
|
|
55
|
+
|
|
56
|
+
if needs_client:
|
|
49
57
|
try:
|
|
50
58
|
client = client_from_profile(profile)
|
|
51
59
|
except ValueError as exc:
|
|
@@ -57,6 +65,7 @@ def main() -> None:
|
|
|
57
65
|
|
|
58
66
|
session_key = get_session_key(profile.name, override=args.session_key)
|
|
59
67
|
db_path = profile.session_db_path
|
|
68
|
+
log.debug("Running in session: %s", session_key)
|
|
60
69
|
|
|
61
70
|
with SessionStore(db_path, session_key) as store:
|
|
62
71
|
PROFILE.set(profile)
|
|
@@ -95,7 +95,7 @@ through the ConTree REST API.
|
|
|
95
95
|
|
|
96
96
|
Authentication:
|
|
97
97
|
Bearer token + project ID. Default API URL:
|
|
98
|
-
https://api.
|
|
98
|
+
https://api.tokenfactory.nebius.com/sandboxes/
|
|
99
99
|
|
|
100
100
|
Use `contree auth --help` to configure persistent credentials.
|
|
101
101
|
|
|
@@ -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
|
|
@@ -49,17 +49,20 @@ from __future__ import annotations
|
|
|
49
49
|
import argparse
|
|
50
50
|
import base64
|
|
51
51
|
import fnmatch
|
|
52
|
+
import functools
|
|
52
53
|
import io
|
|
53
54
|
import json
|
|
54
55
|
import logging
|
|
55
56
|
import os
|
|
56
57
|
import re
|
|
57
58
|
import select
|
|
59
|
+
import shlex
|
|
58
60
|
import sys
|
|
59
61
|
import time
|
|
60
62
|
import uuid
|
|
61
63
|
from dataclasses import dataclass, field
|
|
62
64
|
from datetime import timedelta
|
|
65
|
+
from multiprocessing.pool import ThreadPool
|
|
63
66
|
|
|
64
67
|
from contree_cli import CLIENT, FORMATTER, SESSION_STORE, ArgumentsProtocol, SetupResult
|
|
65
68
|
from contree_cli.client import ApiError, ContreeClient, decode_stream, resolve_image
|
|
@@ -68,7 +71,7 @@ from contree_cli.output import (
|
|
|
68
71
|
DefaultFormatter,
|
|
69
72
|
OutputFormatter,
|
|
70
73
|
)
|
|
71
|
-
from contree_cli.session import SessionStore
|
|
74
|
+
from contree_cli.session import CONTREE_CONCURRENCY, SessionStore
|
|
72
75
|
from contree_cli.types import FLAGS
|
|
73
76
|
|
|
74
77
|
logger = logging.getLogger(__name__)
|
|
@@ -351,47 +354,82 @@ def _local_file_cache_kind(host_path: str) -> str:
|
|
|
351
354
|
return f"local_file:{digest}"
|
|
352
355
|
|
|
353
356
|
|
|
354
|
-
MAX_CACHE_AGE = 90 * 24 * 3600 # 90 days
|
|
357
|
+
MAX_CACHE_AGE = 90 * 24 * 3600 # 90 days - server retention is 6 months
|
|
355
358
|
|
|
356
359
|
|
|
357
|
-
def
|
|
358
|
-
|
|
359
|
-
mf: MappedFile,
|
|
360
|
-
store: SessionStore,
|
|
361
|
-
) -> str:
|
|
362
|
-
"""Upload a host file and return its UUID, reusing if already present."""
|
|
360
|
+
def cached_local_uuid(mf: MappedFile, store: SessionStore) -> str | None:
|
|
361
|
+
"""Return a cached UUID for *mf* if one was uploaded within MAX_CACHE_AGE."""
|
|
363
362
|
cache_kind = _local_file_cache_kind(mf.host_path)
|
|
364
363
|
cached = store.cache.get(("", cache_kind))
|
|
365
364
|
if isinstance(cached, dict) and cached.get("uuid"):
|
|
366
365
|
age = time.time() - cached.get("uploaded_at", 0)
|
|
367
366
|
if age < MAX_CACHE_AGE:
|
|
368
367
|
logger.debug(
|
|
369
|
-
"File %s reused from local cache (%s)",
|
|
368
|
+
"File %s reused from local cache (%s)",
|
|
369
|
+
mf.host_path,
|
|
370
|
+
cached["uuid"],
|
|
370
371
|
)
|
|
371
372
|
return str(cached["uuid"])
|
|
373
|
+
return None
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def record_local_uuid(mf: MappedFile, file_uuid: str, store: SessionStore) -> None:
|
|
377
|
+
"""Persist a host_path → file_uuid mapping in the local cache."""
|
|
378
|
+
cache_kind = _local_file_cache_kind(mf.host_path)
|
|
379
|
+
store.cache[("", cache_kind)] = {
|
|
380
|
+
"uuid": file_uuid,
|
|
381
|
+
"uploaded_at": time.time(),
|
|
382
|
+
}
|
|
383
|
+
|
|
372
384
|
|
|
385
|
+
def upload_one_remote(client: ContreeClient, mf: MappedFile) -> tuple[MappedFile, str]:
|
|
386
|
+
"""HTTP-only upload (sha256 dedup + POST /v1/files). Thread-safe."""
|
|
373
387
|
try:
|
|
374
388
|
resp = client.get("/v1/files", params={"sha256": mf.sha256()})
|
|
375
389
|
file_uuid = str(json.loads(resp.read())["uuid"])
|
|
376
|
-
logger.info("
|
|
377
|
-
|
|
378
|
-
return file_uuid
|
|
390
|
+
logger.info("Uploaded file: %s -> %s", mf.host_path, file_uuid)
|
|
391
|
+
return mf, file_uuid
|
|
379
392
|
except ApiError as exc:
|
|
380
393
|
if exc.status != 404:
|
|
381
394
|
raise
|
|
382
395
|
|
|
383
396
|
with open(mf.host_path, "rb") as fh:
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
file_uuid = str(json.loads(resp.read())["uuid"])
|
|
397
|
+
resp = client.request(
|
|
398
|
+
"POST",
|
|
399
|
+
"/v1/files",
|
|
400
|
+
body=fh,
|
|
401
|
+
headers={"Content-Type": "application/octet-stream"},
|
|
402
|
+
)
|
|
403
|
+
file_uuid = str(json.loads(resp.read())["uuid"])
|
|
392
404
|
logger.debug("Uploaded %s (%s)", mf.host_path, file_uuid)
|
|
393
|
-
|
|
394
|
-
|
|
405
|
+
return mf, file_uuid
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def upload_files(
|
|
409
|
+
client: ContreeClient,
|
|
410
|
+
files: list[MappedFile],
|
|
411
|
+
store: SessionStore,
|
|
412
|
+
) -> dict[str, str]:
|
|
413
|
+
"""Upload host files in parallel, returning host_path → file_uuid."""
|
|
414
|
+
uploaded: dict[str, str] = {}
|
|
415
|
+
pending: list[MappedFile] = []
|
|
416
|
+
for mf in files:
|
|
417
|
+
cached = cached_local_uuid(mf, store)
|
|
418
|
+
if cached:
|
|
419
|
+
uploaded[mf.host_path] = cached
|
|
420
|
+
else:
|
|
421
|
+
pending.append(mf)
|
|
422
|
+
|
|
423
|
+
if not pending:
|
|
424
|
+
return uploaded
|
|
425
|
+
|
|
426
|
+
workers = min(CONTREE_CONCURRENCY, len(pending))
|
|
427
|
+
upload = functools.partial(upload_one_remote, client)
|
|
428
|
+
with ThreadPool(workers) as pool:
|
|
429
|
+
for mf, file_uuid in pool.imap_unordered(upload, pending):
|
|
430
|
+
uploaded[mf.host_path] = file_uuid
|
|
431
|
+
record_local_uuid(mf, file_uuid, store)
|
|
432
|
+
return uploaded
|
|
395
433
|
|
|
396
434
|
|
|
397
435
|
def _build_payload(
|
|
@@ -403,8 +441,12 @@ def _build_payload(
|
|
|
403
441
|
) -> dict[str, object]:
|
|
404
442
|
"""Build the JSON payload for POST /v1/instances."""
|
|
405
443
|
if args.shell:
|
|
406
|
-
command
|
|
444
|
+
# In shell mode the API runs `sh -c <command>`, so we must
|
|
445
|
+
# rebuild the original argv into a shell-safe expression.
|
|
446
|
+
command = shlex.join(args.command_args)
|
|
407
447
|
else:
|
|
448
|
+
# In non-shell mode the API exec's command + args directly,
|
|
449
|
+
# JSON list elements preserve boundaries, no quoting needed.
|
|
408
450
|
parts = args.command_args
|
|
409
451
|
command = parts[0] if parts else ""
|
|
410
452
|
|
|
@@ -534,9 +576,7 @@ def cmd_run(args: RunArgs) -> int | None:
|
|
|
534
576
|
print(str(exc), file=sys.stderr)
|
|
535
577
|
return 1
|
|
536
578
|
|
|
537
|
-
uploaded
|
|
538
|
-
for mf in expanded_files:
|
|
539
|
-
uploaded[mf.host_path] = _upload_file(client, mf, store)
|
|
579
|
+
uploaded = upload_files(client, expanded_files, store)
|
|
540
580
|
|
|
541
581
|
# 2b. Include pending files from session store
|
|
542
582
|
pending = store.pending_files()
|
|
@@ -670,6 +710,14 @@ def cmd_run(args: RunArgs) -> int | None:
|
|
|
670
710
|
# 6. Cache terminal operation result
|
|
671
711
|
store.cache[(op_uuid, "operation")] = op
|
|
672
712
|
|
|
713
|
+
if op["status"] != "SUCCESS":
|
|
714
|
+
logger.fatal(
|
|
715
|
+
"Operation %s ended with status %s%s",
|
|
716
|
+
op_uuid,
|
|
717
|
+
op["status"],
|
|
718
|
+
f": {op['error']}" if op.get("error") else "",
|
|
719
|
+
)
|
|
720
|
+
|
|
673
721
|
# 7. Display result
|
|
674
722
|
_display_operation(op, formatter)
|
|
675
723
|
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import base64
|
|
4
4
|
import http.client
|
|
5
|
+
import io
|
|
5
6
|
import json
|
|
6
7
|
import logging
|
|
7
8
|
import platform
|
|
@@ -10,6 +11,7 @@ import time
|
|
|
10
11
|
from abc import ABC, abstractmethod
|
|
11
12
|
from collections.abc import Iterator
|
|
12
13
|
from importlib.metadata import PackageNotFoundError, version
|
|
14
|
+
from typing import IO, cast
|
|
13
15
|
from urllib.parse import urlencode, urlsplit
|
|
14
16
|
|
|
15
17
|
from contree_cli.config import AuthType, ConfigProfile
|
|
@@ -33,6 +35,93 @@ CLI_USER_AGENT = (
|
|
|
33
35
|
)
|
|
34
36
|
|
|
35
37
|
|
|
38
|
+
class BodyFormatter:
|
|
39
|
+
"""Lazy %s-arg for logging HTTP bodies — formats only on emit."""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
body: bytes | str | IO[bytes] | None,
|
|
44
|
+
content_type: str = "",
|
|
45
|
+
binary_max_size: int = 4096,
|
|
46
|
+
) -> None:
|
|
47
|
+
self.body = body
|
|
48
|
+
self.binary_max_size = binary_max_size
|
|
49
|
+
self.content_type = content_type
|
|
50
|
+
|
|
51
|
+
def __str__(self) -> str:
|
|
52
|
+
match self.body:
|
|
53
|
+
case None:
|
|
54
|
+
return self.format_none()
|
|
55
|
+
case bytes() | bytearray() as data:
|
|
56
|
+
return self.dispatch_bytes(bytes(data))
|
|
57
|
+
case str() as text:
|
|
58
|
+
return self.dispatch_bytes(text.encode("utf-8", errors="replace"))
|
|
59
|
+
case _:
|
|
60
|
+
return self.format_stream()
|
|
61
|
+
|
|
62
|
+
def format_none(self) -> str:
|
|
63
|
+
return "<none>"
|
|
64
|
+
|
|
65
|
+
def format_stream(self) -> str:
|
|
66
|
+
return "<stream>"
|
|
67
|
+
|
|
68
|
+
def dispatch_bytes(self, data: bytes) -> str:
|
|
69
|
+
if not data:
|
|
70
|
+
return "<empty>"
|
|
71
|
+
if self.content_type and (
|
|
72
|
+
"json" in self.content_type or "text" in self.content_type
|
|
73
|
+
):
|
|
74
|
+
return self.format_json(data)
|
|
75
|
+
if self.content_type:
|
|
76
|
+
return self.format_bytes(data)
|
|
77
|
+
return self.format_json(data)
|
|
78
|
+
|
|
79
|
+
def format_json(self, data: bytes) -> str:
|
|
80
|
+
truncated = data[: self.binary_max_size]
|
|
81
|
+
try:
|
|
82
|
+
text = truncated.decode("utf-8")
|
|
83
|
+
except UnicodeDecodeError:
|
|
84
|
+
return self.format_bytes(data)
|
|
85
|
+
if len(data) > self.binary_max_size:
|
|
86
|
+
return f"{text}... <truncated, {len(data)}B total>"
|
|
87
|
+
return text
|
|
88
|
+
|
|
89
|
+
def format_bytes(self, data: bytes) -> str:
|
|
90
|
+
if self.content_type:
|
|
91
|
+
return f"<binary {len(data)}B Content-Type={self.content_type!r}>"
|
|
92
|
+
return f"<binary {len(data)}B>"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class BufferedResponse:
|
|
96
|
+
"""Replay an HTTPResponse from buffered bytes (for debug body logging)."""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
status: int,
|
|
101
|
+
reason: str,
|
|
102
|
+
headers: list[tuple[str, str]],
|
|
103
|
+
data: bytes,
|
|
104
|
+
) -> None:
|
|
105
|
+
self.status = status
|
|
106
|
+
self.reason = reason
|
|
107
|
+
self.headers = headers
|
|
108
|
+
self.buf = io.BytesIO(data)
|
|
109
|
+
|
|
110
|
+
def read(self, amt: int | None = None) -> bytes:
|
|
111
|
+
if amt is None:
|
|
112
|
+
return self.buf.read()
|
|
113
|
+
return self.buf.read(amt)
|
|
114
|
+
|
|
115
|
+
def getheader(self, name: str, default: str | None = None) -> str | None:
|
|
116
|
+
for k, v in self.headers:
|
|
117
|
+
if k.lower() == name.lower():
|
|
118
|
+
return v
|
|
119
|
+
return default
|
|
120
|
+
|
|
121
|
+
def getheaders(self) -> list[tuple[str, str]]:
|
|
122
|
+
return list(self.headers)
|
|
123
|
+
|
|
124
|
+
|
|
36
125
|
class ApiError(Exception):
|
|
37
126
|
"""Raised when the contree API returns a non-2xx status."""
|
|
38
127
|
|
|
@@ -80,7 +169,7 @@ class ContreeClient(ABC):
|
|
|
80
169
|
method: str,
|
|
81
170
|
path: str,
|
|
82
171
|
*,
|
|
83
|
-
body: bytes | None = None,
|
|
172
|
+
body: bytes | IO[bytes] | None = None,
|
|
84
173
|
headers: dict[str, str] | None = None,
|
|
85
174
|
) -> http.client.HTTPResponse:
|
|
86
175
|
merged: dict[str, str] = {
|
|
@@ -96,6 +185,13 @@ class ContreeClient(ABC):
|
|
|
96
185
|
last_error: ApiError | None = None
|
|
97
186
|
attempts = len(RETRY_DELAYS) + 1
|
|
98
187
|
|
|
188
|
+
log.debug(
|
|
189
|
+
"%s %s body=%s",
|
|
190
|
+
method,
|
|
191
|
+
full_path,
|
|
192
|
+
BodyFormatter(body, content_type=merged.get("Content-Type", "")),
|
|
193
|
+
)
|
|
194
|
+
|
|
99
195
|
for attempt in range(attempts):
|
|
100
196
|
if last_error is not None:
|
|
101
197
|
delay = RETRY_DELAYS[attempt - 1]
|
|
@@ -106,12 +202,15 @@ class ContreeClient(ABC):
|
|
|
106
202
|
)
|
|
107
203
|
time.sleep(delay)
|
|
108
204
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
205
|
+
if attempt > 0 and hasattr(body, "seek"):
|
|
206
|
+
stream = cast(IO[bytes], body)
|
|
207
|
+
if not stream.seekable():
|
|
208
|
+
raise ApiError(
|
|
209
|
+
0,
|
|
210
|
+
"RetryNotSeekable",
|
|
211
|
+
"Cannot retry: streaming body is not seekable",
|
|
212
|
+
)
|
|
213
|
+
stream.seek(0)
|
|
115
214
|
try:
|
|
116
215
|
conn = self._connect()
|
|
117
216
|
conn.request(method, full_path, body, merged)
|
|
@@ -127,6 +226,8 @@ class ContreeClient(ABC):
|
|
|
127
226
|
resp.status,
|
|
128
227
|
resp.reason,
|
|
129
228
|
)
|
|
229
|
+
if log.isEnabledFor(logging.DEBUG):
|
|
230
|
+
return self.log_and_buffer(method, full_path, resp)
|
|
130
231
|
return resp
|
|
131
232
|
|
|
132
233
|
resp_body = resp.read().decode("utf-8", errors="replace")
|
|
@@ -138,6 +239,15 @@ class ContreeClient(ABC):
|
|
|
138
239
|
resp.reason,
|
|
139
240
|
len(resp_body),
|
|
140
241
|
)
|
|
242
|
+
log.debug(
|
|
243
|
+
"%s %s response body: %s",
|
|
244
|
+
method,
|
|
245
|
+
full_path,
|
|
246
|
+
BodyFormatter(
|
|
247
|
+
resp_body,
|
|
248
|
+
content_type=resp.getheader("Content-Type", "") or "",
|
|
249
|
+
),
|
|
250
|
+
)
|
|
141
251
|
error = ApiError(resp.status, resp.reason, resp_body)
|
|
142
252
|
|
|
143
253
|
if 500 <= resp.status < 600:
|
|
@@ -149,6 +259,40 @@ class ContreeClient(ABC):
|
|
|
149
259
|
assert last_error is not None
|
|
150
260
|
raise last_error
|
|
151
261
|
|
|
262
|
+
def log_and_buffer(
|
|
263
|
+
self,
|
|
264
|
+
method: str,
|
|
265
|
+
full_path: str,
|
|
266
|
+
resp: http.client.HTTPResponse,
|
|
267
|
+
) -> http.client.HTTPResponse:
|
|
268
|
+
"""Read & log a textual response body; pass binary streams through."""
|
|
269
|
+
content_type = resp.getheader("Content-Type", "") or ""
|
|
270
|
+
textual = not content_type or "json" in content_type or "text" in content_type
|
|
271
|
+
if not textual:
|
|
272
|
+
log.debug(
|
|
273
|
+
"%s %s response body: <stream Content-Type=%r>",
|
|
274
|
+
method,
|
|
275
|
+
full_path,
|
|
276
|
+
content_type,
|
|
277
|
+
)
|
|
278
|
+
return resp
|
|
279
|
+
data = resp.read()
|
|
280
|
+
log.debug(
|
|
281
|
+
"%s %s response body: %s",
|
|
282
|
+
method,
|
|
283
|
+
full_path,
|
|
284
|
+
BodyFormatter(data, content_type=content_type),
|
|
285
|
+
)
|
|
286
|
+
return cast(
|
|
287
|
+
http.client.HTTPResponse,
|
|
288
|
+
BufferedResponse(
|
|
289
|
+
status=resp.status,
|
|
290
|
+
reason=resp.reason,
|
|
291
|
+
headers=list(resp.getheaders()),
|
|
292
|
+
data=data,
|
|
293
|
+
),
|
|
294
|
+
)
|
|
295
|
+
|
|
152
296
|
# -- convenience methods --------------------------------------------------
|
|
153
297
|
|
|
154
298
|
def get(
|
|
@@ -217,7 +361,7 @@ class ContreeJWTClient(ContreeClient):
|
|
|
217
361
|
class ContreeIAMClient(ContreeClient):
|
|
218
362
|
"""Client using IAM authentication with a project header."""
|
|
219
363
|
|
|
220
|
-
DEFAULT_URL = "https://api.
|
|
364
|
+
DEFAULT_URL = "https://api.tokenfactory.nebius.com/sandboxes"
|
|
221
365
|
|
|
222
366
|
def __init__(
|
|
223
367
|
self,
|
|
@@ -1,16 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import configparser
|
|
2
4
|
import logging
|
|
3
5
|
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import stat
|
|
4
8
|
from collections.abc import Iterator, MutableMapping
|
|
5
9
|
from dataclasses import dataclass
|
|
6
10
|
from enum import Enum
|
|
7
11
|
from pathlib import Path
|
|
8
12
|
|
|
13
|
+
from .migrations import run_migrations
|
|
14
|
+
|
|
9
15
|
log = logging.getLogger(__name__)
|
|
10
16
|
|
|
11
|
-
|
|
17
|
+
|
|
18
|
+
def get_default_path(env: str, default: str | Path) -> Path:
|
|
19
|
+
return Path(os.getenv(env) or default).expanduser()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
XDG_CONFIG_HOME = get_default_path("XDG_CONFIG_HOME", "~/.config")
|
|
23
|
+
CONTREE_HOME = get_default_path("CONTREE_HOME", XDG_CONFIG_HOME / "contree")
|
|
12
24
|
CONFIG_DIR = CONTREE_HOME
|
|
13
|
-
CONFIG_FILE = CONTREE_HOME / "
|
|
25
|
+
CONFIG_FILE = CONTREE_HOME / "auth.ini"
|
|
26
|
+
CLI_CONFIG_FILE = CONTREE_HOME / "cli.ini"
|
|
27
|
+
|
|
28
|
+
# Parsed at import time. Paths are fixed by CONTREE_HOME (env), so the
|
|
29
|
+
# ``[cli]`` section is available before argparse runs and can supply
|
|
30
|
+
# defaults that beat hardcoded ones but still lose to flags.
|
|
31
|
+
SETTINGS = configparser.ConfigParser()
|
|
32
|
+
SETTINGS.read([CLI_CONFIG_FILE, CONFIG_FILE])
|
|
33
|
+
|
|
34
|
+
# Default editor for ``contree file edit`` when ``--editor`` is not given.
|
|
35
|
+
# Resolved once at import time. Priority: $EDITOR > cli.ini > vim > nano > vi.
|
|
36
|
+
EDITOR = (
|
|
37
|
+
os.environ.get("EDITOR")
|
|
38
|
+
or SETTINGS.get("cli", "editor", fallback=None)
|
|
39
|
+
or shutil.which("vim")
|
|
40
|
+
or shutil.which("nano")
|
|
41
|
+
or "vi"
|
|
42
|
+
)
|
|
14
43
|
|
|
15
44
|
|
|
16
45
|
class AuthType(str, Enum):
|
|
@@ -39,7 +68,7 @@ class ConfigProfile:
|
|
|
39
68
|
|
|
40
69
|
@property
|
|
41
70
|
def session_db_path(self) -> Path:
|
|
42
|
-
return CONTREE_HOME / f"
|
|
71
|
+
return CONTREE_HOME / "cli" / "sessions" / f"{self.name}.db"
|
|
43
72
|
|
|
44
73
|
def remove_session_db(self) -> None:
|
|
45
74
|
db = self.session_db_path
|
|
@@ -55,10 +84,11 @@ class Config(MutableMapping[str, ConfigProfile]):
|
|
|
55
84
|
``del cfg[name]``, ``name in cfg``, ``len(cfg)``, iteration.
|
|
56
85
|
"""
|
|
57
86
|
|
|
58
|
-
DEFAULT_IAM_URL = "https://api.
|
|
87
|
+
DEFAULT_IAM_URL = "https://api.tokenfactory.nebius.com/sandboxes"
|
|
59
88
|
PROFILE_PREFIX = "profile:"
|
|
60
89
|
|
|
61
90
|
def __init__(self, path: Path | None = None) -> None:
|
|
91
|
+
run_migrations(CONTREE_HOME)
|
|
62
92
|
self.__path = path or CONFIG_FILE
|
|
63
93
|
self.__profiles: dict[str, ConfigProfile] = {}
|
|
64
94
|
self.__active: str = "default"
|
|
@@ -68,8 +98,12 @@ class Config(MutableMapping[str, ConfigProfile]):
|
|
|
68
98
|
|
|
69
99
|
def _load(self) -> None:
|
|
70
100
|
cp = configparser.ConfigParser()
|
|
71
|
-
|
|
72
|
-
|
|
101
|
+
# Read cli.ini first, then auth.ini — later files override earlier
|
|
102
|
+
# ones, so secrets in auth.ini take precedence over any non-secret
|
|
103
|
+
# profile fields a user keeps in cli.ini (url, project, type, …).
|
|
104
|
+
sources = [CLI_CONFIG_FILE, self.__path]
|
|
105
|
+
log.debug("Loading config from %s", sources)
|
|
106
|
+
cp.read(sources)
|
|
73
107
|
self.__active = cp.defaults().get("profile", "default")
|
|
74
108
|
self.__profiles.clear()
|
|
75
109
|
for section in cp.sections():
|
|
@@ -106,8 +140,16 @@ class Config(MutableMapping[str, ConfigProfile]):
|
|
|
106
140
|
if profile.project is not None:
|
|
107
141
|
cp.set(section, "project", profile.project)
|
|
108
142
|
self.__path.parent.mkdir(parents=True, exist_ok=True)
|
|
109
|
-
with
|
|
143
|
+
# Create with 0o600 from the start so the token is never readable
|
|
144
|
+
# by other users — even between create() and chmod().
|
|
145
|
+
fd = os.open(
|
|
146
|
+
self.__path,
|
|
147
|
+
os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
|
|
148
|
+
stat.S_IRUSR | stat.S_IWUSR,
|
|
149
|
+
)
|
|
150
|
+
with os.fdopen(fd, "w") as f:
|
|
110
151
|
cp.write(f)
|
|
152
|
+
os.chmod(self.__path, stat.S_IRUSR | stat.S_IWUSR)
|
|
111
153
|
|
|
112
154
|
# -- MutableMapping interface --------------------------------------------
|
|
113
155
|
|
|
@@ -207,14 +207,17 @@ More: contree auth --help
|
|
|
207
207
|
Configuration
|
|
208
208
|
=============
|
|
209
209
|
|
|
210
|
-
Data directory:
|
|
210
|
+
Data directory: $XDG_CONFIG_HOME/contree/ (or ~/.config/contree/);
|
|
211
|
+
override with $CONTREE_HOME.
|
|
211
212
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
213
|
+
auth.ini profile credentials (mode 0600)
|
|
214
|
+
cli.ini optional CLI defaults
|
|
215
|
+
cli/sessions/{profile}.db per-profile sessions
|
|
216
|
+
cli/skills.db agent skill registry
|
|
215
217
|
|
|
216
218
|
Environment variables:
|
|
217
219
|
CONTREE_HOME data directory
|
|
220
|
+
XDG_CONFIG_HOME XDG base config dir, used to derive default CONTREE_HOME
|
|
218
221
|
CONTREE_TOKEN API token (overrides config)
|
|
219
222
|
CONTREE_URL API URL (overrides config)
|
|
220
223
|
CONTREE_PROJECT project ID (overrides config)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""One-shot data layout migrations for ``CONTREE_HOME``.
|
|
2
|
+
|
|
3
|
+
DELETE-ME: this entire module is transitional. It carries users of
|
|
4
|
+
``contree-cli <= 0.4.x`` (flat layout, ``~/.config/contree-cli/``) onto
|
|
5
|
+
the current layout (``~/.config/contree/`` with a ``cli/`` subdir).
|
|
6
|
+
|
|
7
|
+
After ~2 minor releases (target: ``0.7.0``) drop this file, the
|
|
8
|
+
``run_migrations`` import and call in ``config.py``, and
|
|
9
|
+
``tests/test_migrations.py``. Anyone still on the old layout at that
|
|
10
|
+
point can do the rename by hand from the changelog.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
LEGACY_DIRNAME = "contree-cli"
|
|
21
|
+
LEGACY_CONFIG_BASENAME = "config.ini"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def run_migrations(home: Path) -> None:
|
|
25
|
+
"""Idempotent layout migrations into ``home``."""
|
|
26
|
+
migrate_legacy_dir(home)
|
|
27
|
+
migrate_into_cli_subdir(home)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def migrate_legacy_dir(home: Path) -> None:
|
|
31
|
+
"""Move ``<parent>/contree-cli/*`` into ``home``.
|
|
32
|
+
|
|
33
|
+
Renames ``config.ini`` to ``auth.ini``. Skipped when ``home/auth.ini``
|
|
34
|
+
already exists, so freshly-created session DBs in ``home`` don't
|
|
35
|
+
block credential migration.
|
|
36
|
+
"""
|
|
37
|
+
legacy_dir = home.parent / LEGACY_DIRNAME
|
|
38
|
+
if not legacy_dir.is_dir():
|
|
39
|
+
return
|
|
40
|
+
if (home / "auth.ini").exists():
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
log.info("Migrating CONTREE_HOME: %s -> %s", legacy_dir, home)
|
|
44
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
|
|
46
|
+
for item in legacy_dir.iterdir():
|
|
47
|
+
target_name = "auth.ini" if item.name == LEGACY_CONFIG_BASENAME else item.name
|
|
48
|
+
target = home / target_name
|
|
49
|
+
if target.exists():
|
|
50
|
+
log.warning("Skipping %s: %s already exists", item, target)
|
|
51
|
+
continue
|
|
52
|
+
item.rename(target)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
legacy_dir.rmdir()
|
|
56
|
+
except OSError:
|
|
57
|
+
log.debug(
|
|
58
|
+
"Legacy dir %s not empty after migration; leaving in place", legacy_dir
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def migrate_into_cli_subdir(home: Path) -> None:
|
|
63
|
+
"""Move flat session/skill DBs into the ``cli/`` subdirectory.
|
|
64
|
+
|
|
65
|
+
- ``home/sessions-{name}.db*`` → ``home/cli/sessions/{name}.db*``
|
|
66
|
+
- ``home/skills.db*`` → ``home/cli/skills.db*``
|
|
67
|
+
"""
|
|
68
|
+
if not home.is_dir():
|
|
69
|
+
return
|
|
70
|
+
cli_dir = home / "cli"
|
|
71
|
+
sessions_dir = cli_dir / "sessions"
|
|
72
|
+
moved_any = False
|
|
73
|
+
|
|
74
|
+
for item in home.iterdir():
|
|
75
|
+
if not item.is_file():
|
|
76
|
+
continue
|
|
77
|
+
if item.name.startswith("sessions-"):
|
|
78
|
+
target = sessions_dir / item.name[len("sessions-") :]
|
|
79
|
+
sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
elif item.name.startswith("skills.db"):
|
|
81
|
+
target = cli_dir / item.name
|
|
82
|
+
cli_dir.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
else:
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
if target.exists():
|
|
87
|
+
log.warning("Skipping %s: %s already exists", item, target)
|
|
88
|
+
continue
|
|
89
|
+
if not moved_any:
|
|
90
|
+
log.info("Reorganising CLI state into %s", cli_dir)
|
|
91
|
+
moved_any = True
|
|
92
|
+
item.rename(target)
|
|
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import contextlib
|
|
6
6
|
import logging
|
|
7
|
-
import os
|
|
8
7
|
import re
|
|
9
8
|
import shlex
|
|
10
9
|
import sys
|
|
@@ -391,15 +390,7 @@ class ContreeShell:
|
|
|
391
390
|
print(f"Usage: {editor} <path>", file=sys.stderr)
|
|
392
391
|
return
|
|
393
392
|
resolved = self.resolve_path(args[0])
|
|
394
|
-
|
|
395
|
-
os.environ["EDITOR"] = editor
|
|
396
|
-
try:
|
|
397
|
-
self.dispatch_contree(["file", "edit", resolved])
|
|
398
|
-
finally:
|
|
399
|
-
if old_editor is None:
|
|
400
|
-
os.environ.pop("EDITOR", None)
|
|
401
|
-
else:
|
|
402
|
-
os.environ["EDITOR"] = old_editor
|
|
393
|
+
self.dispatch_contree(["file", "edit", "--editor", editor, resolved])
|
|
403
394
|
|
|
404
395
|
GLOB_CHARS = frozenset("*?[")
|
|
405
396
|
|
|
@@ -64,7 +64,7 @@ def default_claude_home() -> Path:
|
|
|
64
64
|
|
|
65
65
|
@contextmanager
|
|
66
66
|
def connect_registry() -> Iterator[sqlite3.Connection]:
|
|
67
|
-
db_path = config_mod.CONTREE_HOME / "skills.db"
|
|
67
|
+
db_path = config_mod.CONTREE_HOME / "cli" / "skills.db"
|
|
68
68
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
69
69
|
conn = sqlite3.connect(str(db_path), timeout=5.0)
|
|
70
70
|
conn.row_factory = sqlite3.Row
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|