Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,9 @@ contree --help
contree auth
```

You'll be prompted to enter your API token and project ID. The CLI verifies the token and saves credentials to `~/.config/contree-cli/config.ini`.
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`).

If `NEBIUS_API_KEY` and `NEBIUS_AI_PROJECT` environment variables are set and no CLI flags are passed, they are picked up automatically instead of prompting.
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.

### 2. Install agent skills (optional)

Expand Down Expand Up @@ -285,17 +285,24 @@ contree auth switch staging # switch active profile

### Environment variables

Read at runtime (any command):

| Variable | Purpose |
|---|---|
| `CONTREE_HOME` | Data directory (default `~/.config/contree-cli`) |
| `CONTREE_TOKEN` | API bearer token (overrides config) |
| `CONTREE_URL` | API base URL (overrides config) |
| `CONTREE_PROJECT` | Project ID (overrides config) |
| `CONTREE_PROFILE` | Active profile name |
| `CONTREE_HOME` | Data directory (default `$XDG_CONFIG_HOME/contree`, or `~/.config/contree`) |
| `CONTREE_PROFILE` | Active profile name (selects which profile commands use) |
| `CONTREE_SESSION` | Explicit session key (for multi-terminal workflows) |
| `CONTREE_SESSION_DB` | Path to session SQLite database |

Environment variables take precedence over the config file. `--token` and `--url` flags override everything.
Read only by `contree auth` (registration-time fallbacks for omitted flags):

| Variable | Used for |
|---|---|
| `CONTREE_TOKEN` / `NEBIUS_API_KEY` | `--token` |
| `CONTREE_URL` | `--url` |
| `CONTREE_PROJECT` / `NEBIUS_AI_PROJECT` | `--project` |

Credentials come strictly from the saved profile at runtime. `--token`, `--url`, `--project` CLI flags override profile fields for a single invocation.

## Zero Dependencies

Expand Down
17 changes: 17 additions & 0 deletions contree_cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import logging
import sys
from collections.abc import Callable
from contextlib import suppress
from dataclasses import replace

import contree_cli.config as config_mod
Expand All @@ -12,6 +13,7 @@
from contree_cli.log import setup_logging
from contree_cli.output import FORMATTERS
from contree_cli.session import SessionStore, get_session_key
from contree_cli.update_check import UpdateChecker

log = logging.getLogger(__name__)

Expand All @@ -27,6 +29,21 @@ def main() -> None:
args = parser.parse_args()
setup_logging(level=getattr(logging, args.log_level.upper(), logging.INFO))

# Update check runs only after argparse so it skips --help / --version
# / no-command paths and so the warning respects --log-level. refresh()
# is best-effort; check() is a pure predicate.
checker = UpdateChecker()
with suppress(Exception):
checker.refresh()
if not checker.is_latest():
log.warning(
"A new version of contree-cli is available: %s (installed: %s)."
" Upgrade with `uv tool install -U contree-cli` or"
" `pip install -U contree-cli`.",
checker.state.latest_version,
checker.current_version,
)

config_mod.CONFIG_FILE = args.config_path
config_mod.CONFIG_DIR = args.config_path.parent

Expand Down
19 changes: 12 additions & 7 deletions contree_cli/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,18 +328,23 @@ Per-command -p is useful for cross-project operations:
contree -p project-a images --prefix=base
contree -p project-b images import tag:base:latest

Data directory: ~/.config/contree-cli/
config.ini profile credentials
sessions-{profile}.db per-profile sessions, history, cache
skills.db installed agent skill registry
Data directory: $XDG_CONFIG_HOME/contree/ (or ~/.config/contree/)
auth.ini profile credentials (mode 0600)
cli.ini optional CLI defaults
cli/sessions/{profile}.db per-profile sessions, history, cache
cli/skills.db installed agent skill registry
cli/version_check.json cached PyPI update-check state

Environment variables:
CONTREE_HOME data directory override
CONTREE_TOKEN API token (overrides config)
CONTREE_URL API URL (overrides config)
CONTREE_PROFILE active profile (overrides config)
CONTREE_PROFILE active profile (selects which profile commands use)
CONTREE_SESSION explicit session key

Comment thread
mosquito marked this conversation as resolved.
Read only by `contree auth` (registration-time fallbacks):
CONTREE_TOKEN / NEBIUS_API_KEY token when --token is omitted
CONTREE_URL URL when --url is omitted
CONTREE_PROJECT / NEBIUS_AI_PROJECT project ID when --project is omitted

More: contree auth --help

All commands
Expand Down
33 changes: 16 additions & 17 deletions contree_cli/arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,20 +71,19 @@
contree use IMAGE | run -- CMD | file edit PATH | file cp SRC DEST
contree tag UUID TAG | kill UUID | cd PATH | session checkout BRANCH
environment variables (advanced overrides; most users can ignore):
CONTREE_TOKEN API bearer token (overrides config file)
CONTREE_URL API base URL (overrides config file)
CONTREE_PROJECT Project ID for IAM auth (overrides config file)
CONTREE_PROFILE Active config profile (overrides config file)
CONTREE_SESSION Explicit session name (for multi-terminal workflows).
If unset, contree auto-generates <cwd>+<8hex> (derived from
profile+ppid+tty); export your own for stable reuse.
You can also pass -S/--session instead of exporting env.
CONTREE_SESSION_DB Path to session SQLite database
nebius shortcuts (used by `contree auth` as fallback when flags are omitted):
NEBIUS_API_KEY Fallback token for auth registration
NEBIUS_AI_PROJECT Fallback project ID for IAM auth registration
environment variables:
CONTREE_PROFILE Active config profile (selects which profile to use)
CONTREE_SESSION Explicit session name (for multi-terminal workflows).
If unset, contree auto-generates <cwd>+<8hex> (derived
from profile+ppid+tty); export your own for stable
reuse. You can also pass -S/--session instead.
CONTREE_SESSION_DB Path to session SQLite database
CONTREE_NO_UPDATE_CHECK Set to any value to disable PyPI update checks
registration-time fallbacks (only read by `contree auth`, not at runtime):
CONTREE_TOKEN / NEBIUS_API_KEY Token used when --token is omitted
CONTREE_URL URL used when --url is omitted
CONTREE_PROJECT / NEBIUS_AI_PROJECT Project ID used when --project is omitted
"""

DESCRIPTION = """\
Expand Down Expand Up @@ -121,7 +120,7 @@
parser.add_argument(
*FLAGS["token"],
default=None,
help="API token (overrides config and env)",
help="API token (overrides profile for this invocation)",
)


Expand All @@ -133,12 +132,12 @@ def _strip_trailing_slashes(value: str) -> str:
*FLAGS["url"],
default=None,
type=_strip_trailing_slashes,
help="API base URL (overrides config and env)",
help="API base URL (overrides profile for this invocation)",
)
parser.add_argument(
*FLAGS["project"],
default=None,
help="Project ID (overrides config and env)",
help="Project ID (overrides profile for this invocation)",
)
parser.add_argument(
*FLAGS["config"],
Expand Down
92 changes: 68 additions & 24 deletions contree_cli/cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@
iam (default) — bearer token + project ID, default URL provided
jwt (legacy) — bearer token only, URL must be specified

Nebius environment variable shortcuts:
NEBIUS_API_KEY used as token fallback during registration
NEBIUS_AI_PROJECT used as project fallback during IAM registration
Environment variable fallbacks during registration:
CONTREE_TOKEN / NEBIUS_API_KEY used when --token is omitted
CONTREE_URL used when --url is omitted
CONTREE_PROJECT / NEBIUS_AI_PROJECT used when --project is omitted (IAM)

Other commands ignore these variables; only ``contree auth`` reads
them. ``CONTREE_PROFILE`` selects the profile for any command.

Subcommands:
profiles List saved profiles (* marks active)
Expand All @@ -22,6 +26,7 @@
import argparse
import getpass
import hashlib
import json
import logging
import os
from dataclasses import dataclass
Expand All @@ -35,6 +40,7 @@
logger = logging.getLogger(__name__)
PROFILE_CHECK_TIMEOUT = 2.0
PROFILE_CHECK_CONCURRENCY = 4
REQUIRED_PERMISSION = "list"

EPILOG = """\
for coding agents:
Expand Down Expand Up @@ -169,6 +175,24 @@ def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
return cmd_auth, AuthArgs


def env_fallback(names: tuple[str, ...], *, what: str) -> str | None:
for name in names:
value = os.environ.get(name)
if value:
logger.info("Using %s from %s", what, name)
return value
return None


def check_permission(payload: object, permission: str) -> bool:
if not isinstance(payload, dict):
return False
perms = payload.get("permissions")
if not isinstance(perms, dict):
return False
return bool(perms.get(permission))


def cmd_auth(args: AuthArgs) -> int | None:
cfg = Config()
exists = args.profile in cfg
Expand All @@ -188,18 +212,16 @@ def cmd_auth(args: AuthArgs) -> int | None:
print("Aborted.")
return 1

# Token: --token > NEBIUS_API_KEY > interactive prompt
token = args.token
# Token: --token > CONTREE_TOKEN > NEBIUS_API_KEY > interactive prompt
token = args.token or env_fallback(
("CONTREE_TOKEN", "NEBIUS_API_KEY"),
what="token",
)
if token is None:
nebius_key = os.environ.get("NEBIUS_API_KEY")
if nebius_key:
logger.info("Using token from NEBIUS_API_KEY")
token = nebius_key
else:
token = getpass.getpass("Token: ")
token = getpass.getpass("Token: ")

# URL: --url > type-specific default > interactive prompt
url = args.url
# URL: --url > CONTREE_URL > type-specific default > interactive prompt
url = args.url or env_fallback(("CONTREE_URL",), what="URL")
if url is None:
if args.auth_type == AuthType.IAM:
url = Config.DEFAULT_IAM_URL
Expand All @@ -209,17 +231,15 @@ def cmd_auth(args: AuthArgs) -> int | None:
logger.error("URL is required for JWT auth")
return 1

# Project (IAM only): --project > NEBIUS_AI_PROJECT > interactive prompt
# Project (IAM only): --project > CONTREE_PROJECT > NEBIUS_AI_PROJECT > prompt
project: str | None = None
if args.auth_type == AuthType.IAM:
project = args.project
project = args.project or env_fallback(
("CONTREE_PROJECT", "NEBIUS_AI_PROJECT"),
what="project",
)
if project is None:
nebius_project = os.environ.get("NEBIUS_AI_PROJECT")
if nebius_project:
logger.info("Using project from NEBIUS_AI_PROJECT")
project = nebius_project
else:
project = input("Project ID: ").strip()
project = input("Project ID: ").strip()
profile = ConfigProfile(
name=args.profile,
token=token,
Expand All @@ -235,15 +255,33 @@ def cmd_auth(args: AuthArgs) -> int | None:
return 1

try:
client.get("/v1/whoami")
resp = client.get("/v1/whoami")
whoami = json.loads(resp.read() or b"{}")
except ApiError as exc:
# Logs the API error message, not the token itself.
# nosemgrep: python-logger-credential-disclosure
logger.error("Token verification failed: %s. Profile not changed.", exc)
return 1
except ValueError as exc:
logger.error("Could not parse /v1/whoami response: %s", exc)
return 1

if not check_permission(whoami, REQUIRED_PERMISSION):
project_label = profile.project or profile.url
logger.warning(
"Warning: token is valid but sandboxes are disabled on %s"
" (no %r permission). The profile will be saved but no commands"
" will work until the service is enabled.",
project_label,
REQUIRED_PERMISSION,
)

cfg[args.profile] = profile
logger.info("Token verified and saved to profile %r", args.profile)
logger.info(
"auth accepted, profile %r saved to -> %s",
args.profile,
cfg.path,
)
return None


Expand Down Expand Up @@ -284,11 +322,17 @@ def check_status(

try:
resp = client.get("/v1/whoami")
resp.read()
payload = resp.read()
except TimeoutError:
return profile, "timeout"
except Exception:
return profile, "error"
try:
whoami = json.loads(payload or b"{}")
except ValueError:
return profile, "error"
if not check_permission(whoami, REQUIRED_PERMISSION):
return profile, "inactive"
return profile, "ok"

formatter = FORMATTER.get()
Expand Down
Loading
Loading