contree-cli 0.5.1__tar.gz → 0.6.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 (69) hide show
  1. {contree_cli-0.5.1 → contree_cli-0.6.0}/PKG-INFO +1 -1
  2. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/agent.md +75 -1
  3. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/arguments.py +12 -0
  4. contree_cli-0.6.0/contree_cli/cli/build.py +224 -0
  5. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/file.py +149 -3
  6. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/images.py +12 -7
  7. contree_cli-0.6.0/contree_cli/cli/operation.py +157 -0
  8. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/ps.py +23 -13
  9. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/run.py +29 -12
  10. contree_cli-0.6.0/contree_cli/docker/__init__.py +42 -0
  11. contree_cli-0.6.0/contree_cli/docker/context.py +161 -0
  12. contree_cli-0.6.0/contree_cli/docker/dockerignore.py +102 -0
  13. contree_cli-0.6.0/contree_cli/docker/keyword.py +94 -0
  14. contree_cli-0.6.0/contree_cli/docker/kw_add.py +132 -0
  15. contree_cli-0.6.0/contree_cli/docker/kw_arg.py +41 -0
  16. contree_cli-0.6.0/contree_cli/docker/kw_copy.py +178 -0
  17. contree_cli-0.6.0/contree_cli/docker/kw_env.py +33 -0
  18. contree_cli-0.6.0/contree_cli/docker/kw_from.py +119 -0
  19. contree_cli-0.6.0/contree_cli/docker/kw_run.py +190 -0
  20. contree_cli-0.6.0/contree_cli/docker/kw_skipped.py +30 -0
  21. contree_cli-0.6.0/contree_cli/docker/kw_user.py +31 -0
  22. contree_cli-0.6.0/contree_cli/docker/kw_workdir.py +37 -0
  23. contree_cli-0.6.0/contree_cli/docker/local_context.py +159 -0
  24. contree_cli-0.6.0/contree_cli/docker/parser.py +93 -0
  25. contree_cli-0.6.0/contree_cli/docker/url_fetch.py +303 -0
  26. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/manual.md +2 -1
  27. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/mapped_file.py +11 -1
  28. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/session.py +63 -3
  29. contree_cli-0.6.0/contree_cli/shell/argmap.py +69 -0
  30. contree_cli-0.6.0/contree_cli/shell/argspec.py +343 -0
  31. contree_cli-0.6.0/contree_cli/shell/cache.py +109 -0
  32. contree_cli-0.6.0/contree_cli/shell/completer.py +371 -0
  33. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/shell/repl.py +256 -54
  34. contree_cli-0.6.0/contree_cli/shell/sources.py +495 -0
  35. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/skill_body.md +92 -4
  36. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/types.py +5 -0
  37. {contree_cli-0.5.1 → contree_cli-0.6.0}/pyproject.toml +1 -1
  38. contree_cli-0.5.1/contree_cli/shell/completer.py +0 -485
  39. {contree_cli-0.5.1 → contree_cli-0.6.0}/LICENSE +0 -0
  40. {contree_cli-0.5.1 → contree_cli-0.6.0}/README.md +0 -0
  41. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/__init__.py +0 -0
  42. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/__main__.py +0 -0
  43. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/__init__.py +0 -0
  44. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/agent.py +0 -0
  45. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/auth.py +0 -0
  46. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/cat.py +0 -0
  47. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/cd.py +0 -0
  48. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/cp.py +0 -0
  49. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/env.py +0 -0
  50. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/kill.py +0 -0
  51. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/ls.py +0 -0
  52. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/session.py +0 -0
  53. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/show.py +0 -0
  54. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/skill.py +0 -0
  55. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/tag.py +0 -0
  56. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/cli/use.py +0 -0
  57. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/client.py +0 -0
  58. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/config.py +0 -0
  59. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/log.py +0 -0
  60. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/man.py +0 -0
  61. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/migrations.py +0 -0
  62. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/output.py +0 -0
  63. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/py.typed +0 -0
  64. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/shell/__init__.py +0 -0
  65. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/shell/history.py +0 -0
  66. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/shell/parser.py +0 -0
  67. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/shell/trie.py +0 -0
  68. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/skill.py +0 -0
  69. {contree_cli-0.5.1 → contree_cli-0.6.0}/contree_cli/update_check.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: contree-cli
3
- Version: 0.5.1
3
+ Version: 0.6.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
@@ -134,7 +134,28 @@ Tag conventions:
134
134
  Always search before building:
135
135
  contree images --prefix=python-dev
136
136
 
137
- More: contree images --help, contree tag --help
137
+ Building from a Dockerfile:
138
+ When a project already ships a Dockerfile, prefer `contree build`
139
+ over hand-running each step. It executes FROM/RUN/COPY/WORKDIR/ENV
140
+ /ARG/USER against the API and caches every layer as a branch so
141
+ rebuilds are fast.
142
+
143
+ Layer cache is keyed by abspath(context), shared across invocations:
144
+ contree build . build ./Dockerfile, no tag
145
+ contree build . --tag myapp:dev build + tag the final image
146
+ contree build ./app --dockerfile ./app/Dockerfile.prod --tag svc:prod
147
+ contree build . --build-arg VERSION=1.2
148
+ contree build . --no-cache force rebuild
149
+
150
+ Supported directives: FROM, RUN, COPY, ADD (local paths only),
151
+ WORKDIR, ENV, ARG, USER. CMD/ENTRYPOINT/LABEL/EXPOSE/VOLUME/etc.
152
+ are parsed but skipped with a warning. Multi-stage (AS / --from)
153
+ is not yet supported.
154
+
155
+ .dockerignore is applied to every COPY/ADD walk on top of the
156
+ default exclude list (.git, __pycache__, node_modules, etc.).
157
+
158
+ More: contree build --help, contree images --help, contree tag --help
138
159
 
139
160
  Files and directories
140
161
  =====================
@@ -177,6 +198,32 @@ Pending files are injected into the next non-disposable run.
177
198
  Explicit --file takes priority over pending files at same path.
178
199
  Pending files are branch-aware.
179
200
 
201
+ Listing uploaded files:
202
+ contree file ls list all uploaded files in the project
203
+ contree file ls --since 1d narrow by upload time
204
+ contree file ls -q uuid + sha256 + source only (quiet)
205
+ contree -f json file ls JSON output for jq
206
+
207
+ Output joins remote files (uuid, sha256, size, created_at) with the
208
+ local upload cache. The SOURCE column shows whatever this machine
209
+ used to produce the file:
210
+ - absolute host path for files uploaded via `run --file` / `COPY`;
211
+ - https://... URL for files fetched via `ADD URL`.
212
+
213
+ IMPORTANT: SOURCE is resolved ONLY for files uploaded from this
214
+ specific machine. The mapping lives in the local SQLite cache (per
215
+ profile, under $CONTREE_HOME/cli/sessions/<profile>.db) keyed by
216
+ path+inode+mtime+size (for host paths) or by the URL itself (for
217
+ URL fetches), and is NOT shared between hosts. Rows show empty
218
+ SOURCE when:
219
+ - the file was uploaded from a different machine or by a teammate;
220
+ - the host file has been moved, renamed, or its inode/mtime/size
221
+ changed since upload (the cache key no longer matches);
222
+ - the upload happened before tracking landed (older entries
223
+ backfill on the next match).
224
+ An agent must not assume SOURCE is authoritative across hosts;
225
+ for cross-machine identity always use the remote UUID or sha256.
226
+
180
227
  More: contree run --help, contree file --help
181
228
 
182
229
  Execution modes
@@ -211,10 +258,31 @@ Piped stdin:
211
258
  Detached mode (-d):
212
259
  contree run -d -- long-running-task
213
260
  contree ps check status
261
+ contree ps -a -S FAILED --since=1h recent failures
214
262
  contree show UUID view result
215
263
  contree session wait block until done
216
264
  contree session wait UUID1 UUID2 wait for specific ops
217
265
 
266
+ Monitoring background operations:
267
+ Use the `operation` namespace (alias `op`) when juggling several
268
+ detached runs. `op ls` is `ps`; `op show` and `op cancel` accept
269
+ multiple UUIDs in one call.
270
+
271
+ contree op ls list operations (= ps)
272
+ contree op ls -a -S EXECUTING filter active runs
273
+ contree op show UUID1 UUID2 UUID3 inspect a batch in one call
274
+ contree op cancel UUID1 UUID2 cancel selected operations
275
+ contree op cancel --all cancel every active op (rare)
276
+
277
+ Fan-out + join pattern:
278
+ A=$(contree run -d -- make -C /work/a build | jq -r .uuid)
279
+ B=$(contree run -d -- make -C /work/b build | jq -r .uuid)
280
+ contree session wait "$A" "$B"
281
+ contree op show "$A" "$B"
282
+
283
+ Background checks are cheap: terminal results are cached locally,
284
+ so repeated `op show` / `show` calls do not re-hit the API.
285
+
218
286
  Disposable mode (-D) — no image checkpoint:
219
287
  contree run -D -- rm -rf /tmp/*
220
288
  contree run -D -- cat /etc/passwd
@@ -352,11 +420,16 @@ All commands
352
420
 
353
421
  use [IMAGE] Set or show session image (aliases: ci)
354
422
  run [-- CMD] Spawn sandbox instance (aliases: r)
423
+ build [CONTEXT] Build image from Dockerfile (aliases: bd)
355
424
  images List/import images (aliases: i, img)
356
425
  tag [IMAGE] TAG Tag image (aliases: t)
357
426
  ps List operations
358
427
  kill UUID Cancel operation
359
428
  show UUID Show operation result
429
+ operation list List operations (aliases: op ls)
430
+ operation show UUID... Show one or more operation results (aliases: op)
431
+ operation cancel UUID...
432
+ Cancel one or more operations (or --all)
360
433
  ls [PATH] List files in image (no VM)
361
434
  cat PATH Show file content (no VM)
362
435
  cp PATH DEST Download file from image
@@ -364,6 +437,7 @@ All commands
364
437
  env [KEY=VALUE ...] Session env vars (-d to unset)
365
438
  file edit PATH Edit remote file via $EDITOR
366
439
  file cp SRC DEST Stage local file for next run
440
+ file ls [-q] List uploaded files + local path (aliases: list)
367
441
  session list List sessions (aliases: ls)
368
442
  session branch [NAME] Create/list branches (aliases: br)
369
443
  session checkout BRANCH Switch branch (aliases: co)
@@ -6,6 +6,7 @@ from pathlib import Path
6
6
  from contree_cli.cli import (
7
7
  agent,
8
8
  auth,
9
+ build,
9
10
  cat,
10
11
  cd,
11
12
  cp,
@@ -14,6 +15,7 @@ from contree_cli.cli import (
14
15
  images,
15
16
  kill,
16
17
  ls,
18
+ operation,
17
19
  ps,
18
20
  run,
19
21
  session,
@@ -44,6 +46,9 @@ examples:
44
46
  contree run --file ./src:/app/src -- make -C /app/src
45
47
  contree images --prefix=ubuntu
46
48
  contree ps -q
49
+ contree op ls same as `contree ps`
50
+ contree op show UUID1 UUID2 multi-UUID show
51
+ contree op cancel UUID1 UUID2 multi-UUID cancel (or --all)
47
52
  contree show OPERATION_UUID
48
53
  contree tag IMAGE_UUID latest
49
54
  contree ls /etc list files in session image
@@ -206,11 +211,18 @@ def register(
206
211
 
207
212
  register("use", "Set or show current session image", use.setup_parser, aliases=["ci"])
208
213
  register("run", "Spawn a sandbox instance", run.setup_parser, aliases=["r"])
214
+ register("build", "Build image from Dockerfile", build.setup_parser, aliases=["bd"])
209
215
  register("images", "List and import images", images.setup_parser, aliases=["i", "img"])
210
216
  register("tag", "Tag an image", tag.setup_parser, aliases=["t"])
211
217
  register("ps", "List operations/instances", ps.setup_parser)
212
218
  register("kill", "Cancel an operation", kill.setup_parser)
213
219
  register("show", "Show operation result", show.setup_parser)
220
+ register(
221
+ "operation",
222
+ "Manage operations (list/show/cancel)",
223
+ operation.setup_parser,
224
+ aliases=["op"],
225
+ )
214
226
  register("ls", "List files in image", ls.setup_parser)
215
227
  register("cat", "Show file content from image", cat.setup_parser)
216
228
  register("cp", "Copy file from image to local path", cp.setup_parser)
@@ -0,0 +1,224 @@
1
+ """Build an image from a Dockerfile.
2
+
3
+ Reads the Dockerfile at the given path (default ``<CONTEXT>/Dockerfile``)
4
+ and applies each directive against an isolated build session keyed by
5
+ the absolute path of the context directory. Successful layers are
6
+ materialised as branches named ``layer:<chain-hash>`` so that
7
+ re-running the same Dockerfile reuses prior work.
8
+
9
+ Supported directives (MVP): FROM, RUN, COPY, ADD (without URL/tar),
10
+ WORKDIR, ENV, ARG, USER. Other Dockerfile directives parse cleanly
11
+ but are skipped with a warning (CMD, ENTRYPOINT, LABEL, EXPOSE,
12
+ VOLUME, STOPSIGNAL, MAINTAINER, HEALTHCHECK, ONBUILD, SHELL).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import hashlib
19
+ import logging
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+
23
+ from contree_cli import (
24
+ CLIENT,
25
+ FORMATTER,
26
+ PROFILE,
27
+ SESSION_STORE,
28
+ ArgumentsProtocol,
29
+ SetupResult,
30
+ )
31
+ from contree_cli.docker import (
32
+ ArgKeyword,
33
+ BuildContext,
34
+ DockerKeyword,
35
+ FromKeyword,
36
+ LocalContext,
37
+ RunKeyword,
38
+ parse_dockerfile,
39
+ )
40
+ from contree_cli.docker.context import BUILD_TIMEOUT_DEFAULT
41
+ from contree_cli.session import SessionStore
42
+ from contree_cli.types import FLAGS
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ EPILOG = """\
47
+ examples:
48
+ contree build .
49
+ contree build . --tag myimage:latest
50
+ contree build --dockerfile ./Dockerfile.test ./app
51
+ contree build --build-arg VERSION=1.2 .
52
+ contree build --no-cache .
53
+
54
+ for coding agents:
55
+ mutating command, may create operations against the API
56
+ layer cache is per-context (session keyed by abspath(context))
57
+ use --no-cache to bypass cached layers and rebuild from scratch
58
+ """
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class BuildArgs(ArgumentsProtocol):
63
+ context: str = "."
64
+ dockerfile: str = ""
65
+ tag: str = ""
66
+ build_args: tuple[str, ...] = field(default_factory=tuple)
67
+ no_cache: bool = False
68
+ timeout: int = BUILD_TIMEOUT_DEFAULT
69
+
70
+ @classmethod
71
+ def from_args(cls, ns: argparse.Namespace) -> BuildArgs:
72
+ return cls(
73
+ context=ns.context or ".",
74
+ dockerfile=ns.dockerfile or "",
75
+ tag=ns.tag or "",
76
+ build_args=tuple(ns.build_arg or ()),
77
+ no_cache=bool(ns.no_cache),
78
+ timeout=ns.timeout,
79
+ )
80
+
81
+
82
+ def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
83
+ p.add_argument(
84
+ "context",
85
+ nargs="?",
86
+ default=".",
87
+ help="Build context directory",
88
+ )
89
+ p.add_argument(
90
+ *FLAGS["dockerfile"],
91
+ default="",
92
+ metavar="PATH",
93
+ help="Dockerfile path (default: <context>/Dockerfile)",
94
+ )
95
+ p.add_argument(
96
+ *FLAGS["tag_name"],
97
+ default="",
98
+ metavar="NAME[:TAG]",
99
+ help="Tag the final image",
100
+ )
101
+ p.add_argument(
102
+ *FLAGS["build_arg"],
103
+ action="append",
104
+ default=[],
105
+ metavar="KEY=VALUE",
106
+ help="Build-time variable (repeatable)",
107
+ )
108
+ p.add_argument(
109
+ *FLAGS["no_cache"],
110
+ action="store_true",
111
+ help="Ignore cached layers and rebuild",
112
+ )
113
+ p.add_argument(
114
+ *FLAGS["timeout"],
115
+ type=int,
116
+ default=BUILD_TIMEOUT_DEFAULT,
117
+ help="Timeout in seconds for each RUN step",
118
+ )
119
+ return cmd_build, BuildArgs
120
+
121
+
122
+ def cmd_build(args: BuildArgs) -> int | None:
123
+ context_dir = Path(args.context).expanduser().resolve()
124
+ if not context_dir.is_dir():
125
+ logger.error("context %s is not a directory", context_dir)
126
+ return 1
127
+
128
+ dockerfile_path = (
129
+ Path(args.dockerfile).expanduser()
130
+ if args.dockerfile
131
+ else context_dir / "Dockerfile"
132
+ )
133
+ if not dockerfile_path.is_file():
134
+ logger.error("Dockerfile %s not found", dockerfile_path)
135
+ return 1
136
+
137
+ text = dockerfile_path.read_text()
138
+ try:
139
+ directives = parse_dockerfile(text)
140
+ except ValueError as exc:
141
+ logger.error("Dockerfile parse error: %s", exc)
142
+ return 1
143
+
144
+ if not validate_first_directive(directives):
145
+ logger.error("Dockerfile must contain a FROM directive")
146
+ return 1
147
+
148
+ build_args = parse_build_args(args.build_args)
149
+
150
+ profile = PROFILE.get()
151
+ client = CLIENT.get()
152
+ session_key = make_session_key(context_dir)
153
+ store = SessionStore(profile.session_db_path, session_key)
154
+ SESSION_STORE.set(store)
155
+
156
+ ctx = BuildContext(
157
+ client=client,
158
+ store=store,
159
+ local=LocalContext.from_dir(context_dir),
160
+ build_args=build_args,
161
+ no_cache=args.no_cache,
162
+ timeout=args.timeout,
163
+ )
164
+
165
+ try:
166
+ for kw in directives:
167
+ kw.execute(ctx)
168
+ finalize_pending(ctx)
169
+ except Exception as exc:
170
+ logger.error("build failed: %s", exc)
171
+ return 1
172
+
173
+ if not ctx.last_image:
174
+ logger.error("build produced no image")
175
+ return 1
176
+
177
+ if args.tag:
178
+ client.patch_json(
179
+ f"/v1/images/{ctx.last_image}/tag",
180
+ {"tag": args.tag},
181
+ )
182
+ logger.info("tagged %s as %s", ctx.last_image, args.tag)
183
+
184
+ formatter = FORMATTER.get()
185
+ formatter(
186
+ image=ctx.last_image,
187
+ tag=args.tag,
188
+ session=session_key,
189
+ )
190
+ formatter.flush()
191
+ return None
192
+
193
+
194
+ def validate_first_directive(directives: list[DockerKeyword]) -> bool:
195
+ for d in directives:
196
+ if isinstance(d, FromKeyword):
197
+ return True
198
+ if isinstance(d, ArgKeyword):
199
+ continue
200
+ return False
201
+ return False
202
+
203
+
204
+ def parse_build_args(items: tuple[str, ...]) -> dict[str, str]:
205
+ out: dict[str, str] = {}
206
+ for item in items:
207
+ if "=" not in item:
208
+ raise ValueError(f"--build-arg expected KEY=VALUE, got {item!r}")
209
+ k, _, v = item.partition("=")
210
+ out[k] = v
211
+ return out
212
+
213
+
214
+ def make_session_key(context_dir: Path) -> str:
215
+ digest = hashlib.sha256(str(context_dir).encode()).hexdigest()
216
+ return f"build:{digest[:16]}"
217
+
218
+
219
+ def finalize_pending(ctx: BuildContext) -> None:
220
+ """If COPY/ADD left files pending, commit them via a trivial RUN."""
221
+ if not ctx.pending:
222
+ return
223
+ closer = RunKeyword(parts=(":",), shell_form=True)
224
+ closer.execute(ctx)
@@ -23,13 +23,26 @@ import shlex
23
23
  import subprocess
24
24
  import tempfile
25
25
  from dataclasses import dataclass
26
+ from datetime import datetime
26
27
  from pathlib import Path
27
28
 
28
- from contree_cli import CLIENT, SESSION_STORE, ArgumentsProtocol, SetupResult
29
+ from contree_cli import (
30
+ CLIENT,
31
+ FORMATTER,
32
+ SESSION_STORE,
33
+ ArgumentsProtocol,
34
+ SetupResult,
35
+ )
29
36
  from contree_cli.client import ApiError, ContreeClient, resolve_image, stream_response
30
37
  from contree_cli.config import EDITOR
31
38
  from contree_cli.session import SessionStore
32
- from contree_cli.types import FLAGS
39
+ from contree_cli.types import (
40
+ FLAGS,
41
+ isoformat_datetime,
42
+ parse_datetime,
43
+ parse_interval,
44
+ positive_int,
45
+ )
33
46
 
34
47
  logger = logging.getLogger(__name__)
35
48
 
@@ -61,6 +74,27 @@ class FileCpArgs(ArgumentsProtocol):
61
74
  return cls(src=ns.src, dest=ns.dest)
62
75
 
63
76
 
77
+ FILE_LIST_LIMIT_DEFAULT = 1000
78
+ FILE_LIST_PAGE_SIZE = 1000
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class FileListArgs(ArgumentsProtocol):
83
+ since: datetime | None = None
84
+ until: datetime | None = None
85
+ limit: int = FILE_LIST_LIMIT_DEFAULT
86
+ quiet: bool = False
87
+
88
+ @classmethod
89
+ def from_args(cls, ns: argparse.Namespace) -> FileListArgs:
90
+ return cls(
91
+ since=getattr(ns, "since", None),
92
+ until=getattr(ns, "until", None),
93
+ limit=getattr(ns, "limit", FILE_LIST_LIMIT_DEFAULT),
94
+ quiet=bool(getattr(ns, "quiet", False)),
95
+ )
96
+
97
+
64
98
  def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
65
99
  sub = p.add_subparsers(dest="file_action", required=True)
66
100
  edit_p = sub.add_parser(
@@ -102,6 +136,58 @@ def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
102
136
  cp_p.add_argument("dest", help="Destination path inside image")
103
137
  cp_p.set_defaults(handler=cmd_file_cp, load_args=FileCpArgs)
104
138
 
139
+ ls_p = sub.add_parser(
140
+ "ls",
141
+ aliases=["list"],
142
+ help="List uploaded files (joined with local cache)",
143
+ description=(
144
+ "List remote files uploaded to the project and, when present in"
145
+ " the local upload cache, show what produced them under the"
146
+ " 'source' column: either an absolute host path (for run --file"
147
+ " / COPY uploads) or a URL (for ADD URL).\n"
148
+ "\n"
149
+ "source is THIS-MACHINE ONLY: the mapping lives in the local"
150
+ " CLI cache ($CONTREE_HOME/cli/sessions/<profile>.db) and is"
151
+ " never synced. Files uploaded from a different host, by a"
152
+ " teammate, or before tracking landed will show an empty source"
153
+ " -- that is expected, not a bug. Use the remote uuid or sha256"
154
+ " for cross-machine identity."
155
+ ),
156
+ epilog=(
157
+ "examples:\n"
158
+ " contree file ls\n"
159
+ " contree file ls --since 1d\n"
160
+ " contree file ls --limit 5000\n"
161
+ " contree file ls -q # uuid + sha256 + source\n"
162
+ " contree -f json file ls\n"
163
+ ),
164
+ )
165
+ ls_p.add_argument(
166
+ *FLAGS["since"],
167
+ type=parse_interval,
168
+ help=parse_interval.__doc__,
169
+ )
170
+ ls_p.add_argument(
171
+ *FLAGS["until"],
172
+ type=parse_interval,
173
+ help="Show files before. " + str(parse_interval.__doc__),
174
+ )
175
+ ls_p.add_argument(
176
+ *FLAGS["limit"],
177
+ type=positive_int,
178
+ default=FILE_LIST_LIMIT_DEFAULT,
179
+ help="Stop after this many files and warn if more are available",
180
+ )
181
+ ls_p.add_argument(
182
+ *FLAGS["quiet"],
183
+ action="store_true",
184
+ help=(
185
+ "Emit only uuid, sha256, and source columns. source is populated"
186
+ " only for files uploaded from this very machine."
187
+ ),
188
+ )
189
+ ls_p.set_defaults(handler=cmd_file_ls, load_args=FileListArgs)
190
+
105
191
  return cmd_file_edit, FileEditArgs
106
192
 
107
193
 
@@ -123,7 +209,7 @@ def _upload_and_record(
123
209
  """Upload a local file (with dedup) and record as pending."""
124
210
  sha = _file_sha256(local_path)
125
211
  try:
126
- resp = client.get("/v1/files", params={"sha256": sha})
212
+ resp = client.get(f"/v1/files/{sha}")
127
213
  file_uuid = json.loads(resp.read())["uuid"]
128
214
  logger.info("File already exists on server (%s)", file_uuid)
129
215
  except ApiError as exc:
@@ -222,3 +308,63 @@ def cmd_file_cp(args: FileCpArgs) -> int | None:
222
308
  title=f"Change file {args.dest}",
223
309
  )
224
310
  return None
311
+
312
+
313
+ def cmd_file_ls(args: FileListArgs) -> int | None:
314
+ client = CLIENT.get()
315
+ store = SESSION_STORE.get()
316
+ formatter = FORMATTER.get()
317
+
318
+ sources = store.cache.local_file_paths()
319
+
320
+ params: dict[str, str] = {}
321
+ if args.since is not None:
322
+ params["since"] = isoformat_datetime(args.since)
323
+ if args.until is not None:
324
+ params["until"] = isoformat_datetime(args.until)
325
+
326
+ offset = 0
327
+ emitted = 0
328
+ while emitted < args.limit:
329
+ page_size = min(FILE_LIST_PAGE_SIZE, args.limit - emitted)
330
+ page = {**params, "offset": str(offset), "limit": str(page_size)}
331
+ resp = client.get("/v1/files", params=page)
332
+ data = json.loads(resp.read())
333
+ files = data.get("files", [])
334
+ if not files:
335
+ return None
336
+ for entry in files:
337
+ uuid_str = entry.get("uuid")
338
+ source = sources.get(uuid_str, "") if isinstance(uuid_str, str) else ""
339
+ if args.quiet:
340
+ formatter(
341
+ uuid=uuid_str,
342
+ sha256=entry.get("sha256", ""),
343
+ source=source,
344
+ )
345
+ continue
346
+ row: dict[str, object] = {}
347
+ for key, value in entry.items():
348
+ if isinstance(value, (dict, list)):
349
+ continue
350
+ if key in {"created_at", "updated_at"} and isinstance(value, str):
351
+ value = parse_datetime(value)
352
+ row[key] = value
353
+ row["source"] = source
354
+ formatter(**row)
355
+ emitted += len(files)
356
+ if len(files) < page_size:
357
+ return None
358
+ offset += len(files)
359
+
360
+ probe = {**params, "offset": str(offset), "limit": "1"}
361
+ resp = client.get("/v1/files", params=probe)
362
+ data = json.loads(resp.read())
363
+ if data.get("files"):
364
+ formatter.flush()
365
+ logger.warning(
366
+ "Output truncated at --limit=%d files; more results are"
367
+ " available. Raise --limit or narrow with --since/--until.",
368
+ args.limit,
369
+ )
370
+ return None
@@ -202,7 +202,8 @@ def _add_list_args(p: argparse.ArgumentParser) -> None:
202
202
 
203
203
 
204
204
  def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
205
- # Parent-level args for backward compat (contree images --prefix …)
205
+ # Parent-level list args mirror the subcommand so `contree images
206
+ # --prefix …` works without typing `list`.
206
207
  _add_list_args(p)
207
208
 
208
209
  sub = p.add_subparsers(dest="images_action")
@@ -286,12 +287,16 @@ def cmd_images(args: ImagesArgs) -> None:
286
287
  if not images:
287
288
  return
288
289
  for image in images:
289
- created_at = parse_datetime(image["created_at"])
290
- formatter(
291
- uuid=image["uuid"],
292
- created_at=created_at,
293
- tag=image.get("tag") or "",
294
- )
290
+ row: dict[str, object] = {}
291
+ for key, value in image.items():
292
+ if isinstance(value, (dict, list)):
293
+ continue
294
+ if key == "created_at" and isinstance(value, str):
295
+ value = parse_datetime(value)
296
+ if key == "tag" and value is None:
297
+ value = ""
298
+ row[key] = value
299
+ formatter(**row)
295
300
  emitted += len(images)
296
301
  if len(images) < page_size:
297
302
  return