delegate-agent-cli 0.11.0__py3-none-any.whl

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 (54) hide show
  1. delegate_agent/__init__.py +5 -0
  2. delegate_agent/archived_logs.py +44 -0
  3. delegate_agent/argv_builders.py +423 -0
  4. delegate_agent/argv_utils.py +36 -0
  5. delegate_agent/capability_commands.py +99 -0
  6. delegate_agent/cli.py +987 -0
  7. delegate_agent/cli_parser.py +1627 -0
  8. delegate_agent/command_errors.py +29 -0
  9. delegate_agent/command_help.py +1264 -0
  10. delegate_agent/config.py +1117 -0
  11. delegate_agent/config_commands.py +143 -0
  12. delegate_agent/constants.py +50 -0
  13. delegate_agent/describe_payload.py +1018 -0
  14. delegate_agent/errors.py +32 -0
  15. delegate_agent/git_utils.py +180 -0
  16. delegate_agent/harness_events.py +719 -0
  17. delegate_agent/inspection_commands.py +104 -0
  18. delegate_agent/isolation.py +316 -0
  19. delegate_agent/json_types.py +18 -0
  20. delegate_agent/log_output.py +78 -0
  21. delegate_agent/private_io.py +109 -0
  22. delegate_agent/profile_commands.py +42 -0
  23. delegate_agent/profile_guard.py +116 -0
  24. delegate_agent/profiles.py +389 -0
  25. delegate_agent/prompt_instructions.py +39 -0
  26. delegate_agent/prompt_transport.py +12 -0
  27. delegate_agent/reasoning.py +916 -0
  28. delegate_agent/redaction.py +193 -0
  29. delegate_agent/rendering.py +573 -0
  30. delegate_agent/request_build.py +1681 -0
  31. delegate_agent/request_models.py +191 -0
  32. delegate_agent/retention.py +321 -0
  33. delegate_agent/run_metadata.py +78 -0
  34. delegate_agent/run_output_commands.py +645 -0
  35. delegate_agent/run_registry.py +747 -0
  36. delegate_agent/run_status.py +300 -0
  37. delegate_agent/runner.py +1830 -0
  38. delegate_agent/safe_workspace.py +821 -0
  39. delegate_agent/snapshot_view.py +229 -0
  40. delegate_agent/wait_cancel_commands.py +559 -0
  41. delegate_agent/worktree_commands.py +218 -0
  42. delegate_agent/worktree_execution.py +654 -0
  43. delegate_agent/worktree_gc.py +529 -0
  44. delegate_agent/worktree_mgmt.py +782 -0
  45. delegate_agent/worktree_records.py +232 -0
  46. delegate_agent/worktree_remove.py +547 -0
  47. delegate_agent/worktree_summary.py +242 -0
  48. delegate_agent/wsl.py +65 -0
  49. delegate_agent_cli-0.11.0.dist-info/METADATA +325 -0
  50. delegate_agent_cli-0.11.0.dist-info/RECORD +54 -0
  51. delegate_agent_cli-0.11.0.dist-info/WHEEL +5 -0
  52. delegate_agent_cli-0.11.0.dist-info/entry_points.txt +2 -0
  53. delegate_agent_cli-0.11.0.dist-info/licenses/LICENSE +21 -0
  54. delegate_agent_cli-0.11.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import TextIO
6
+
7
+ from delegate_agent import command_errors, redaction, run_registry, snapshot_view
8
+ from delegate_agent import rendering as delegate_rendering
9
+ from delegate_agent.json_types import JsonObject
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class SnapshotCommand:
14
+ handle: str | None
15
+ latest_harness: str | None = None
16
+ no_redact: bool = False
17
+ json_mode: bool = False
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class RunsCommand:
22
+ active: bool = False
23
+ running: bool = False
24
+ stale: bool = False
25
+ harness: str | None = None
26
+ group: str | None = None
27
+ limit: int | None = None
28
+ json_mode: bool = False
29
+
30
+
31
+ class InspectionError(command_errors.CommandError):
32
+ pass
33
+
34
+
35
+ def emit_snapshot(command: SnapshotCommand, *, workspace_path: str, stdout: TextIO) -> int:
36
+ workspace = Path(workspace_path)
37
+ registry_root = run_registry.registry_root_if_exists(workspace)
38
+ if registry_root is None:
39
+ if command.latest_harness is None and command.handle is None:
40
+ raise InspectionError("missing_handle", "snapshot requires a run handle or --latest.")
41
+ registry_root = run_registry.registry_root(workspace)
42
+ target = run_registry.resolve_run_target(
43
+ registry_root,
44
+ handle=command.handle,
45
+ latest_harness=command.latest_harness,
46
+ )
47
+ if isinstance(target, run_registry.RunTargetLookupError):
48
+ raise InspectionError(target.error, target.message)
49
+ run_id, alias = target.run_id, target.alias
50
+ snapshot = run_registry.load_run_snapshot(registry_root, run_id)
51
+ view = snapshot_view.merge_snapshot_view(
52
+ registry_root,
53
+ run_id,
54
+ snapshot,
55
+ redact=not command.no_redact,
56
+ )
57
+ if target.resolution_kind != "literal":
58
+ view["requestedHandle"] = target.requested_handle
59
+ view["resolvedHandle"] = target.resolved_handle or alias or run_id
60
+ view["resolutionKind"] = target.resolution_kind
61
+ if command.json_mode:
62
+ delegate_rendering.print_json(snapshot_view.snapshot_json_payload(view), stdout)
63
+ else:
64
+ delegate_rendering.render_snapshot_text(view, stdout)
65
+ return 0
66
+
67
+
68
+ def emit_runs(command: RunsCommand, *, workspace_path: str, stdout: TextIO) -> int:
69
+ registry_root = run_registry.registry_root_if_exists(Path(workspace_path))
70
+ limit = command.limit or run_registry.DEFAULT_RUNS_LIMIT
71
+ if command.running:
72
+ mode = "running"
73
+ status_filter = run_registry.STATUS_FILTER_RUNNING
74
+ elif command.stale:
75
+ mode = "stale"
76
+ status_filter = run_registry.STATUS_FILTER_STALE
77
+ elif command.active:
78
+ mode = "active"
79
+ status_filter = None
80
+ else:
81
+ mode = "recent"
82
+ status_filter = None
83
+ if registry_root is None:
84
+ summaries: list[JsonObject] = []
85
+ else:
86
+ index = run_registry.load_index(registry_root)
87
+ summaries = run_registry.list_run_summaries(
88
+ registry_root,
89
+ index,
90
+ active=command.active,
91
+ status_filter=status_filter,
92
+ harness=command.harness,
93
+ group=command.group,
94
+ limit=limit,
95
+ )
96
+ summaries = [redaction.redact_value(summary) for summary in summaries]
97
+ if command.json_mode:
98
+ delegate_rendering.print_json(
99
+ delegate_rendering.runs_json_payload(summaries, limit=limit, mode=mode),
100
+ stdout,
101
+ )
102
+ else:
103
+ delegate_rendering.render_runs_text(summaries, stdout, mode=mode)
104
+ return 0
@@ -0,0 +1,316 @@
1
+ """Worktree isolation planning and creation helpers.
2
+
3
+ This module owns effective-isolation resolution, source Git validation,
4
+ persistent worktree creation, and prompt context injection. Worktree removal
5
+ lives in worktree_mgmt.py.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import re
12
+ import subprocess # nosec B404 - isolation helpers intentionally run fixed git argv with shell=False.
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ # Re-export isolation constants from config for convenience
17
+ from delegate_agent.config import ( # noqa: F401 # re-exported
18
+ ISOLATION_AUTO,
19
+ ISOLATION_NONE,
20
+ ISOLATION_WORKTREE,
21
+ VALID_ISOLATION_VALUES,
22
+ ConfigError,
23
+ )
24
+ from delegate_agent.constants import KNOWN_ENGINES
25
+ from delegate_agent.git_utils import (
26
+ GIT_QUICK_TIMEOUT_SECONDS,
27
+ GIT_TIMEOUT_RETURN_CODE,
28
+ )
29
+ from delegate_agent.git_utils import (
30
+ run_git as _run_git,
31
+ )
32
+ from delegate_agent.json_types import JsonObject
33
+ from delegate_agent.prompt_instructions import SKILL_REVIEW_PREFIX
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class IsolationContext:
38
+ """Resolved isolation metadata shared by dry-run output, run metadata, and launch."""
39
+
40
+ source_workspace: str
41
+ effective_isolation: str
42
+ isolation_mode: str
43
+ isolation_lifecycle: str
44
+ preserved_workspace: bool
45
+ planned_branch: str | None = None
46
+ planned_execution_cwd: str | None = None
47
+ source_git_root: str | None = None
48
+ source_git_common_dir: str | None = None
49
+ source_head_oid: str | None = None
50
+ source_head_ref: str | None = None
51
+ source_branch: str | None = None
52
+ safe_workspace_method: str | None = None
53
+ include_dirty: bool = False
54
+ warnings: tuple[str, ...] = ()
55
+
56
+
57
+ def compute_repo_fingerprint_from_common_dir(git_common_dir: str) -> str:
58
+ """Return a stable 12-char hash of the resolved Git common directory path."""
59
+ resolved = Path(git_common_dir).resolve(strict=True).as_posix()
60
+ return hashlib.sha256(resolved.encode("utf-8")).hexdigest()[:12]
61
+
62
+
63
+ def _raise_if_git_timed_out(result: subprocess.CompletedProcess[str], action: str) -> None:
64
+ if result.returncode == GIT_TIMEOUT_RETURN_CODE:
65
+ raise IsolationExecutionError("git_timeout", f"{action} timed out: {result.stderr}")
66
+
67
+
68
+ def short_run_id(run_id: str) -> str:
69
+ """Return a run-id segment safe for branch and path names."""
70
+ s = run_id
71
+ if s.startswith("del_"):
72
+ s = s[4:]
73
+ s = "".join(ch if ch.isalnum() or ch in "._-" else "-" for ch in s)
74
+ return s[:32]
75
+
76
+
77
+ def branch_label(engine: str, model_alias: str | None) -> str:
78
+ """Return the engine/model segment used in Delegate worktree branch names."""
79
+ if engine == "cursor":
80
+ return "cursor"
81
+ if engine == "codex":
82
+ return "codex"
83
+ if engine == "kimi":
84
+ return "kimi"
85
+ if engine == "claude":
86
+ return "claude"
87
+ if engine == "droid":
88
+ if not model_alias:
89
+ return "droid"
90
+ slug = model_alias.lower()
91
+ slug = re.sub(r"[^a-z0-9-]", "-", slug)
92
+ slug = re.sub(r"-+", "-", slug)
93
+ slug = slug.strip("-")
94
+ return f"droid-{slug}" if slug else "droid"
95
+ return engine
96
+
97
+
98
+ def plan_branch_name(label: str, short_id: str) -> str:
99
+ return f"delegate/{label}-{short_id}"
100
+
101
+
102
+ def plan_worktree_path(data_home: Path, fingerprint: str, label: str, short_id: str) -> Path:
103
+ return data_home / fingerprint / f"{label}-{short_id}"
104
+
105
+
106
+ def worktrees_data_home(config: JsonObject) -> Path:
107
+ """Return the persistent worktree root, defaulting to ~/.delegate/worktrees."""
108
+ cfg = config.get("worktrees", {})
109
+ if not isinstance(cfg, dict):
110
+ return Path.home() / ".delegate" / "worktrees"
111
+ data_home = cfg.get("dataHome")
112
+ if isinstance(data_home, str) and data_home:
113
+ expanded = Path(data_home).expanduser()
114
+ if not expanded.is_absolute():
115
+ raise ConfigError(
116
+ "invalid_worktrees_config",
117
+ "worktrees.dataHome must be an absolute path or start with ~/.",
118
+ )
119
+ return expanded
120
+ return Path.home() / ".delegate" / "worktrees"
121
+
122
+
123
+ def _map_auto_isolation(engine: str, mode: str) -> str:
124
+ """Preserve safe-mode isolation for local engines while leaving work mode in-place."""
125
+ if mode == "safe" and engine in KNOWN_ENGINES:
126
+ return ISOLATION_WORKTREE
127
+ return ISOLATION_NONE
128
+
129
+
130
+ def _isolation_lifecycle(isolation_mode: str, mode: str) -> str:
131
+ if isolation_mode == ISOLATION_NONE:
132
+ return "none"
133
+ if isolation_mode == ISOLATION_WORKTREE:
134
+ if mode == "work":
135
+ return "persistent"
136
+ return "temporary"
137
+ return "none"
138
+
139
+
140
+ def build_isolation_context(
141
+ source_workspace: str,
142
+ resolved_isolation: str,
143
+ *,
144
+ engine: str = "",
145
+ mode: str = "",
146
+ model_alias: str | None = None,
147
+ source_git_root: str | None = None,
148
+ source_git_common_dir: str | None = None,
149
+ source_head_oid: str | None = None,
150
+ source_head_ref: str | None = None,
151
+ source_branch: str | None = None,
152
+ config: JsonObject | None = None,
153
+ run_short_id: str | None = None,
154
+ include_dirty: bool = False,
155
+ ) -> IsolationContext:
156
+ """Resolve isolation into launch metadata and planned persistent-worktree paths."""
157
+ # isolation_mode preserves the raw resolved value (auto/none/worktree).
158
+ # effective_isolation is the mapped behavior (none/worktree, never auto).
159
+ isolation_mode_raw = resolved_isolation
160
+ if resolved_isolation == ISOLATION_AUTO:
161
+ effective = _map_auto_isolation(engine, mode)
162
+ else:
163
+ effective = resolved_isolation
164
+
165
+ lifecycle = _isolation_lifecycle(effective, mode)
166
+ preserved = lifecycle == "persistent"
167
+
168
+ # Compute named paths only for persistent worktree runs. Temporary safe-mode
169
+ # isolation uses an ephemeral detached worktree or directory copy, not a
170
+ # Delegate-managed branch under the persistent worktree data home.
171
+ planned_branch: str | None = None
172
+ planned_execution_cwd: str | None = None
173
+ if lifecycle == "persistent" and source_git_common_dir is not None:
174
+ try:
175
+ fp = compute_repo_fingerprint_from_common_dir(source_git_common_dir)
176
+ except (FileNotFoundError, OSError):
177
+ fp = None
178
+ if fp is not None:
179
+ short_id = run_short_id if run_short_id else "<short-run-id-placeholder>"
180
+ label = branch_label(engine, model_alias)
181
+ planned_branch = plan_branch_name(label, short_id)
182
+ dh = worktrees_data_home(config or {})
183
+ planned_execution_cwd = str(plan_worktree_path(dh, fp, label, short_id))
184
+
185
+ return IsolationContext(
186
+ source_workspace=source_workspace,
187
+ effective_isolation=effective,
188
+ isolation_mode=isolation_mode_raw,
189
+ isolation_lifecycle=lifecycle,
190
+ preserved_workspace=preserved,
191
+ planned_branch=planned_branch,
192
+ planned_execution_cwd=planned_execution_cwd,
193
+ source_git_root=source_git_root,
194
+ source_git_common_dir=source_git_common_dir,
195
+ source_head_oid=source_head_oid,
196
+ source_head_ref=source_head_ref,
197
+ source_branch=source_branch,
198
+ include_dirty=include_dirty,
199
+ )
200
+
201
+
202
+ class IsolationExecutionError(Exception):
203
+ """Machine-readable isolation failure converted to DelegateError by cli.py."""
204
+
205
+ def __init__(self, error: str, message: str) -> None:
206
+ super().__init__(message)
207
+ self.error = error
208
+ self.message = message
209
+
210
+
211
+ def require_valid_head(source_git_root: str) -> str:
212
+ """Return HEAD's OID or raise missing_git_head for an unborn repository."""
213
+ result = _run_git(
214
+ source_git_root,
215
+ ["rev-parse", "--verify", "HEAD"],
216
+ timeout_seconds=GIT_QUICK_TIMEOUT_SECONDS,
217
+ )
218
+ _raise_if_git_timed_out(result, "git rev-parse HEAD")
219
+ if result.returncode != 0:
220
+ raise IsolationExecutionError(
221
+ "missing_git_head",
222
+ "--isolation worktree requires a Git workspace with at least one commit.",
223
+ )
224
+ return result.stdout.strip()
225
+
226
+
227
+ def require_clean_source(source_git_root: str) -> None:
228
+ """Require no staged, unstaged, untracked, or submodule dirtiness."""
229
+ result = _run_git(
230
+ source_git_root,
231
+ ["status", "--porcelain=v1", "--untracked-files=normal", "--ignore-submodules=none"],
232
+ timeout_seconds=GIT_QUICK_TIMEOUT_SECONDS,
233
+ )
234
+ _raise_if_git_timed_out(result, "git status")
235
+ if result.returncode != 0:
236
+ raise IsolationExecutionError(
237
+ "dirty_source_check_failed",
238
+ f"git status failed with code {result.returncode}: {result.stderr.strip()}",
239
+ )
240
+ if result.stdout.strip():
241
+ raise IsolationExecutionError(
242
+ "dirty_source_workspace",
243
+ (
244
+ "--isolation worktree for work mode requires a clean source workspace. "
245
+ "Commit/stash/delete local changes, run in-place with --isolation none, "
246
+ "or relaunch with --include-dirty to copy tracked edits and untracked "
247
+ "non-ignored files into the new worktree."
248
+ ),
249
+ )
250
+
251
+
252
+ def create_persistent_worktree(
253
+ source_git_root: str,
254
+ branch: str,
255
+ worktree_path: str,
256
+ base_oid: str,
257
+ ) -> None:
258
+ """Create a persistent Git worktree on a new branch from base_oid."""
259
+ branch_probe = _run_git(
260
+ source_git_root,
261
+ ["show-ref", "--verify", "--quiet", f"refs/heads/{branch}"],
262
+ timeout_seconds=GIT_QUICK_TIMEOUT_SECONDS,
263
+ )
264
+ _raise_if_git_timed_out(branch_probe, "git branch availability check")
265
+ if branch_probe.returncode == 0:
266
+ raise IsolationExecutionError(
267
+ "branch_collision",
268
+ f"Branch '{branch}' already exists. This indicates registry corruption or a clock anomaly.",
269
+ )
270
+ if branch_probe.returncode != 1:
271
+ raise IsolationExecutionError(
272
+ "worktree_create_failed",
273
+ f"Failed to verify branch availability for '{branch}': {branch_probe.stderr.strip()}",
274
+ )
275
+ Path(worktree_path).parent.mkdir(parents=True, exist_ok=True)
276
+ result = _run_git(
277
+ source_git_root,
278
+ ["worktree", "add", "-b", branch, worktree_path, base_oid],
279
+ )
280
+ _raise_if_git_timed_out(result, "git worktree add")
281
+ if result.returncode != 0:
282
+ stderr = result.stderr.strip()
283
+ raise IsolationExecutionError(
284
+ "worktree_create_failed",
285
+ f"Failed to create git worktree: {stderr}",
286
+ )
287
+
288
+
289
+ # Keep this text stable; child prompts and tests rely on the exact wording.
290
+ PERSISTENT_WORKTREE_CONTEXT_NOTE = (
291
+ "You are running in a Delegate-created isolated Git worktree. "
292
+ "Make changes in this execution workspace only. "
293
+ "Do not attempt to modify, merge into, or clean the source checkout. "
294
+ "Do not delete, rename, or `git worktree remove` this workspace; "
295
+ "the orchestrator manages worktree lifecycle. "
296
+ "Report changed files, verification, and suggested integration steps. "
297
+ "Your orchestrator can inspect this run via `delegate worktree show <alias>` "
298
+ "and retire it via `delegate worktree remove <alias>` "
299
+ "(refuses on dirty or unmerged), "
300
+ "`delegate worktree remove <alias> --force-branch` "
301
+ "(allow unmerged-branch deletion), "
302
+ "`delegate worktree remove <alias> --discard-uncommitted` "
303
+ "(DISCARDS uncommitted edits), "
304
+ "`delegate worktree remove <alias> --force` "
305
+ "(shorthand for both destructive overrides), "
306
+ "or `delegate worktree prune --merged` for bulk integrated entries."
307
+ "\n\n"
308
+ )
309
+
310
+
311
+ def prepend_persistent_worktree_context(prompt: str) -> str:
312
+ """Insert the worktree note after the skill prefix so the user prompt stays last."""
313
+ if prompt.startswith(SKILL_REVIEW_PREFIX):
314
+ insert_at = len(SKILL_REVIEW_PREFIX)
315
+ return prompt[:insert_at] + PERSISTENT_WORKTREE_CONTEXT_NOTE + prompt[insert_at:]
316
+ return PERSISTENT_WORKTREE_CONTEXT_NOTE + prompt
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TypeAlias
4
+
5
+ JsonScalar: TypeAlias = str | int | float | bool | None
6
+ JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
7
+ JsonObject: TypeAlias = dict[str, JsonValue]
8
+
9
+
10
+ def is_non_negative_int(value: object) -> bool:
11
+ return isinstance(value, int) and not isinstance(value, bool) and value >= 0
12
+
13
+
14
+ def first_string(*values: object) -> str | None:
15
+ for value in values:
16
+ if isinstance(value, str) and value:
17
+ return value
18
+ return None
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ RUN_OUTPUT_DEFAULT_MAX_CHARS = 60_000
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class LogOutput:
11
+ content: str
12
+ truncated: bool
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class CharCapResult:
17
+ content: str
18
+ char_truncated: bool
19
+ returned_chars: int
20
+ omitted_chars: int
21
+
22
+
23
+ def cap_content_by_chars(content: str, max_chars: int) -> CharCapResult:
24
+ if max_chars < 1:
25
+ raise ValueError("max_chars must be at least 1")
26
+ if len(content) <= max_chars:
27
+ return CharCapResult(
28
+ content=content,
29
+ char_truncated=False,
30
+ returned_chars=len(content),
31
+ omitted_chars=0,
32
+ )
33
+ omitted = len(content) - max_chars
34
+ return CharCapResult(
35
+ content=content[-max_chars:],
36
+ char_truncated=True,
37
+ returned_chars=max_chars,
38
+ omitted_chars=omitted,
39
+ )
40
+
41
+
42
+ def tail_file_output(path: Path, lines: int) -> LogOutput:
43
+ if lines < 1:
44
+ raise ValueError("tail lines must be at least 1")
45
+ if not path.exists():
46
+ return LogOutput(content="", truncated=False)
47
+ with path.open("rb") as handle:
48
+ handle.seek(0, 2)
49
+ end = handle.tell()
50
+ if end == 0:
51
+ return LogOutput(content="", truncated=False)
52
+ block = 4096
53
+ chunks: list[bytes] = []
54
+ position = end
55
+ newline_count = 0
56
+ while position > 0 and newline_count <= lines:
57
+ read_size = min(block, position)
58
+ position -= read_size
59
+ handle.seek(position)
60
+ chunk = handle.read(read_size)
61
+ chunks.insert(0, chunk)
62
+ newline_count += chunk.count(b"\n")
63
+ text = b"".join(chunks).decode("utf-8", errors="replace")
64
+ split = text.splitlines()
65
+ content = "\n".join(split[-lines:]) + ("\n" if split else "")
66
+ return LogOutput(content=content, truncated=position > 0 or len(split) > lines)
67
+
68
+
69
+ def read_log_output(path: Path, *, tail: int | None, raw: bool) -> LogOutput:
70
+ if not path.exists():
71
+ return LogOutput(content="", truncated=False)
72
+ if raw:
73
+ return LogOutput(
74
+ content=path.read_text(encoding="utf-8", errors="replace"), truncated=False
75
+ )
76
+ if tail is None:
77
+ raise ValueError("add --tail N or --raw to read stdout/stderr log output")
78
+ return tail_file_output(path, tail)
@@ -0,0 +1,109 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import secrets
6
+ from contextlib import suppress
7
+ from pathlib import Path
8
+
9
+ from delegate_agent.json_types import JsonObject, JsonValue
10
+
11
+ PRIVATE_DIR_MODE = 0o700
12
+ PRIVATE_FILE_MODE = 0o600
13
+
14
+
15
+ class RegistryJsonError(ValueError):
16
+ """Raised when an existing registry JSON file cannot be trusted."""
17
+
18
+
19
+ def supports_private_modes() -> bool:
20
+ """Return whether chmod-style private mode hardening is available."""
21
+ return os.name == "posix"
22
+
23
+
24
+ def ensure_private_dir(path: Path) -> None:
25
+ """Create a registry directory and make it owner-only on POSIX."""
26
+ path.mkdir(parents=True, exist_ok=True)
27
+ if supports_private_modes():
28
+ os.chmod(path, PRIVATE_DIR_MODE)
29
+
30
+
31
+ def ensure_private_file(path: Path) -> None:
32
+ """Make an existing registry file owner-read/write on POSIX."""
33
+ if supports_private_modes():
34
+ os.chmod(path, PRIVATE_FILE_MODE)
35
+
36
+
37
+ def write_private_text(path: Path, text: str, *, encoding: str = "utf-8") -> None:
38
+ """Create/truncate a registry text file with owner-only permissions."""
39
+ ensure_private_dir(path.parent)
40
+ fd = os.open(path, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, PRIVATE_FILE_MODE)
41
+ with os.fdopen(fd, "w", encoding=encoding) as handle:
42
+ handle.write(text)
43
+ ensure_private_file(path)
44
+
45
+
46
+ def write_private_bytes(path: Path, payload: bytes) -> None:
47
+ """Create/truncate a registry byte file with owner-only permissions."""
48
+ ensure_private_dir(path.parent)
49
+ fd = os.open(path, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, PRIVATE_FILE_MODE)
50
+ with os.fdopen(fd, "wb") as handle:
51
+ handle.write(payload)
52
+ ensure_private_file(path)
53
+
54
+
55
+ def write_text_atomic(path: Path, text: str, *, encoding: str = "utf-8") -> None:
56
+ """Atomically replace a non-private text file while preserving existing mode."""
57
+ temp = path.with_name(f".{path.name}.{os.getpid()}.{secrets.token_hex(4)}.tmp")
58
+ existing_mode: int | None = None
59
+ try:
60
+ existing_mode = path.stat().st_mode & 0o777
61
+ except OSError:
62
+ existing_mode = None
63
+ fd = os.open(temp, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, existing_mode or 0o666)
64
+ try:
65
+ with os.fdopen(fd, "w", encoding=encoding) as handle:
66
+ handle.write(text)
67
+ if existing_mode is not None:
68
+ os.chmod(temp, existing_mode)
69
+ os.replace(temp, path)
70
+ except OSError:
71
+ with suppress(OSError):
72
+ os.unlink(temp)
73
+ raise
74
+
75
+
76
+ def write_json_atomic(path: Path, payload: JsonObject) -> None:
77
+ temp = path.with_name(f".{path.name}.{os.getpid()}.{secrets.token_hex(4)}.tmp")
78
+ try:
79
+ write_private_text(
80
+ temp,
81
+ json.dumps(payload, indent=2, sort_keys=True) + "\n",
82
+ )
83
+ os.replace(temp, path)
84
+ ensure_private_file(path)
85
+ except OSError:
86
+ with suppress(OSError):
87
+ os.unlink(temp)
88
+ raise
89
+
90
+
91
+ def read_json_object(path: Path) -> JsonObject | None:
92
+ if not path.exists():
93
+ return None
94
+ try:
95
+ data: JsonValue = json.loads(path.read_text(encoding="utf-8"))
96
+ except json.JSONDecodeError as exc:
97
+ raise RegistryJsonError(f"invalid JSON in {path}: {exc}") from exc
98
+ except OSError as exc:
99
+ raise RegistryJsonError(f"could not read JSON file {path}: {exc}") from exc
100
+ if not isinstance(data, dict):
101
+ raise RegistryJsonError(f"{path} root must be a JSON object")
102
+ return data
103
+
104
+
105
+ def read_json_object_or_none(path: Path) -> JsonObject | None:
106
+ try:
107
+ return read_json_object(path)
108
+ except RegistryJsonError:
109
+ return None
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TextIO
5
+
6
+ from delegate_agent import redaction, rendering
7
+ from delegate_agent.json_types import JsonObject
8
+ from delegate_agent.profiles import ProfileResolution
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class ProfilesCommand:
13
+ json_mode: bool = False
14
+
15
+
16
+ def emit(
17
+ command: ProfilesCommand,
18
+ *,
19
+ resolution: ProfileResolution,
20
+ config_source: str,
21
+ stdout: TextIO,
22
+ ) -> int:
23
+ env = redaction.redact_env_map(resolution.env)
24
+ payload: JsonObject = {
25
+ "ok": True,
26
+ "profile": resolution.name,
27
+ "source": resolution.source,
28
+ "envKeys": sorted(env),
29
+ "env": env,
30
+ "warnings": list(resolution.warnings),
31
+ "configSource": config_source,
32
+ }
33
+ if command.json_mode:
34
+ rendering.print_json(payload, stdout)
35
+ else:
36
+ print(f"profile: {resolution.name or '(none)'}", file=stdout)
37
+ print(f"source: {resolution.source or '(none)'}", file=stdout)
38
+ keys = ", ".join(payload["envKeys"]) if payload["envKeys"] else "(none)"
39
+ print(f"envKeys: {keys}", file=stdout)
40
+ for warning in resolution.warnings:
41
+ print(f"warning: {warning}", file=stdout)
42
+ return 0