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.
Files changed (45) hide show
  1. {contree_cli-0.4.4 → contree_cli-0.5.0}/PKG-INFO +1 -1
  2. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/__main__.py +18 -9
  3. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/arguments.py +1 -1
  4. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/file.py +14 -14
  5. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/run.py +74 -26
  6. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/client.py +152 -8
  7. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/config.py +49 -7
  8. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/manual.md +7 -4
  9. contree_cli-0.5.0/contree_cli/migrations.py +92 -0
  10. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/session.py +2 -0
  11. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/shell/repl.py +1 -10
  12. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/skill.py +1 -1
  13. {contree_cli-0.4.4 → contree_cli-0.5.0}/pyproject.toml +1 -1
  14. {contree_cli-0.4.4 → contree_cli-0.5.0}/LICENSE +0 -0
  15. {contree_cli-0.4.4 → contree_cli-0.5.0}/README.md +0 -0
  16. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/__init__.py +0 -0
  17. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/agent.md +0 -0
  18. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/__init__.py +0 -0
  19. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/agent.py +0 -0
  20. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/auth.py +0 -0
  21. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/cat.py +0 -0
  22. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/cd.py +0 -0
  23. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/cp.py +0 -0
  24. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/env.py +0 -0
  25. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/images.py +0 -0
  26. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/kill.py +0 -0
  27. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/ls.py +0 -0
  28. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/ps.py +0 -0
  29. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/session.py +0 -0
  30. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/show.py +0 -0
  31. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/skill.py +0 -0
  32. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/tag.py +0 -0
  33. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/cli/use.py +0 -0
  34. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/log.py +0 -0
  35. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/man.py +0 -0
  36. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/mapped_file.py +0 -0
  37. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/output.py +0 -0
  38. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/py.typed +0 -0
  39. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/shell/__init__.py +0 -0
  40. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/shell/completer.py +0 -0
  41. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/shell/history.py +0 -0
  42. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/shell/parser.py +0 -0
  43. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/shell/trie.py +0 -0
  44. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/skill_body.md +0 -0
  45. {contree_cli-0.4.4 → contree_cli-0.5.0}/contree_cli/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: contree-cli
3
- Version: 0.4.4
3
+ Version: 0.5.0
4
4
  Summary: CLI client for the ConTree sandbox platform
5
5
  Keywords: sandbox,cli,vm,ai-agent,contree
6
6
  Author: Dmitry Orlov
@@ -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
- # 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)
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.studio.nebius.com/sandboxes/
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
- 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
@@ -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 server retention is 6 months
357
+ MAX_CACHE_AGE = 90 * 24 * 3600 # 90 days - server retention is 6 months
355
358
 
356
359
 
357
- def _upload_file(
358
- client: ContreeClient,
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)", mf.host_path, cached["uuid"]
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("File %s already uploaded (%s)", mf.host_path, file_uuid)
377
- store.cache[("", cache_kind)] = {"uuid": file_uuid, "uploaded_at": time.time()}
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
- data = fh.read()
385
- resp = client.request(
386
- "POST",
387
- "/v1/files",
388
- body=data,
389
- headers={"Content-Type": "application/octet-stream"},
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
- store.cache[("", cache_kind)] = {"uuid": file_uuid, "uploaded_at": time.time()}
394
- return file_uuid
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 = " ".join(args.command_args)
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: dict[str, str] = {}
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
- log.debug(
110
- "%s %s body=%s",
111
- method,
112
- full_path,
113
- f"{len(body)}B" if body is not None else "none",
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.studio.nebius.com/sandboxes"
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
- CONTREE_HOME = Path(os.getenv("CONTREE_HOME", "~/.config/contree-cli")).expanduser()
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 / "config.ini"
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"sessions-{self.name}.db"
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.studio.nebius.com/sandboxes"
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
- log.debug("Loading config from %s", self.__path)
72
- cp.read(self.__path)
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 open(self.__path, "w") as f:
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: ~/.config/contree-cli/ (override: $CONTREE_HOME)
210
+ Data directory: $XDG_CONFIG_HOME/contree/ (or ~/.config/contree/);
211
+ override with $CONTREE_HOME.
211
212
 
212
- config.ini profile credentials
213
- sessions-{profile}.db per-profile sessions
214
- skills.db agent skill registry
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)
@@ -13,6 +13,8 @@ from dataclasses import dataclass
13
13
  from functools import cached_property
14
14
  from pathlib import Path, PurePosixPath
15
15
 
16
+ CONTREE_CONCURRENCY = int(os.getenv("CONTREE_CONCURRENCY", "8"))
17
+
16
18
 
17
19
  @dataclass(frozen=True)
18
20
  class Session:
@@ -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
- old_editor = os.environ.get("EDITOR")
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "contree-cli"
3
- version = "0.4.4"
3
+ version = "0.5.0"
4
4
  description = "CLI client for the ConTree sandbox platform"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
File without changes
File without changes