dagnam 0.7.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 (153) hide show
  1. dagnam/NOTICE +8 -0
  2. dagnam/__init__.py +220 -0
  3. dagnam/_agent/__init__.py +11 -0
  4. dagnam/_agent/claude/agents/dagnam-runner.md +16 -0
  5. dagnam/_agent/claude/hooks/guard.json +15 -0
  6. dagnam/_agent/claude/plugin.json +8 -0
  7. dagnam/_agent/codex/agents/openai.yaml +4 -0
  8. dagnam/_agent/codex/hooks.json +7 -0
  9. dagnam/_agent/guardhook.py +74 -0
  10. dagnam/_agent/install.py +274 -0
  11. dagnam/_agent/runner.py +137 -0
  12. dagnam/_agent/skill/SKILL.md +83 -0
  13. dagnam/_agent/skill/reference/account.md +24 -0
  14. dagnam/_agent/skill/reference/cache.md +20 -0
  15. dagnam/_agent/skill/reference/codegen.md +32 -0
  16. dagnam/_agent/skill/reference/datasets.md +26 -0
  17. dagnam/_agent/skill/reference/deployments.md +31 -0
  18. dagnam/_agent/skill/reference/hub.md +29 -0
  19. dagnam/_agent/skill/reference/inference.md +21 -0
  20. dagnam/_agent/skill/reference/projects.md +35 -0
  21. dagnam/_agent/skill/reference/training.md +38 -0
  22. dagnam/_agent/skill/reference/troubleshooting.md +42 -0
  23. dagnam/_agent/skill/scripts/plan.py +14 -0
  24. dagnam/_agent/skill/scripts/watch_training.py +14 -0
  25. dagnam/_contracts/__init__.py +29 -0
  26. dagnam/_contracts/_architecture.py +36 -0
  27. dagnam/_contracts/_interpret.py +342 -0
  28. dagnam/_contracts/_schema.py +39 -0
  29. dagnam/_contracts/component-schema.json +2035 -0
  30. dagnam/_contracts/normalize.py +142 -0
  31. dagnam/_contracts/param-diagnostics.json +1277 -0
  32. dagnam/_contracts/validation-corpus.json +37391 -0
  33. dagnam/_core/__init__.py +27 -0
  34. dagnam/_core/_logging.py +88 -0
  35. dagnam/_core/_retry.py +263 -0
  36. dagnam/_core/aio/__init__.py +32 -0
  37. dagnam/_core/aio/account.py +310 -0
  38. dagnam/_core/aio/base.py +256 -0
  39. dagnam/_core/aio/checkpoints.py +90 -0
  40. dagnam/_core/aio/codegen.py +142 -0
  41. dagnam/_core/aio/datasets.py +238 -0
  42. dagnam/_core/aio/deployments.py +316 -0
  43. dagnam/_core/aio/hub.py +221 -0
  44. dagnam/_core/aio/inference.py +127 -0
  45. dagnam/_core/aio/projects.py +273 -0
  46. dagnam/_core/aio/training.py +411 -0
  47. dagnam/_core/auth.py +112 -0
  48. dagnam/_core/client/__init__.py +32 -0
  49. dagnam/_core/client/account.py +314 -0
  50. dagnam/_core/client/base.py +492 -0
  51. dagnam/_core/client/checkpoints.py +76 -0
  52. dagnam/_core/client/codegen.py +167 -0
  53. dagnam/_core/client/common.py +368 -0
  54. dagnam/_core/client/datasets.py +425 -0
  55. dagnam/_core/client/deployments.py +311 -0
  56. dagnam/_core/client/hub.py +234 -0
  57. dagnam/_core/client/inference.py +125 -0
  58. dagnam/_core/client/projects.py +297 -0
  59. dagnam/_core/client/training.py +348 -0
  60. dagnam/_core/config.py +138 -0
  61. dagnam/_core/exceptions.py +169 -0
  62. dagnam/_core/lro.py +243 -0
  63. dagnam/_core/metrics_uploader.py +184 -0
  64. dagnam/_core/naming.py +85 -0
  65. dagnam/_core/resolver.py +23 -0
  66. dagnam/_core/sse.py +304 -0
  67. dagnam/_types.py +166 -0
  68. dagnam/aio.py +11 -0
  69. dagnam/cli/__init__.py +8 -0
  70. dagnam/cli/_parser.py +209 -0
  71. dagnam/cli/account.py +400 -0
  72. dagnam/cli/account_data.py +85 -0
  73. dagnam/cli/account_keys.py +145 -0
  74. dagnam/cli/account_profile.py +206 -0
  75. dagnam/cli/account_security.py +167 -0
  76. dagnam/cli/account_settings.py +186 -0
  77. dagnam/cli/agent.py +85 -0
  78. dagnam/cli/cache.py +119 -0
  79. dagnam/cli/checkpoint.py +111 -0
  80. dagnam/cli/codegen.py +149 -0
  81. dagnam/cli/common.py +444 -0
  82. dagnam/cli/dataset.py +438 -0
  83. dagnam/cli/deployment.py +460 -0
  84. dagnam/cli/errors.py +418 -0
  85. dagnam/cli/hub.py +353 -0
  86. dagnam/cli/inference.py +161 -0
  87. dagnam/cli/login.py +175 -0
  88. dagnam/cli/main.py +139 -0
  89. dagnam/cli/presentation.py +112 -0
  90. dagnam/cli/project.py +469 -0
  91. dagnam/cli/register.py +99 -0
  92. dagnam/cli/training.py +598 -0
  93. dagnam/data/__init__.py +1 -0
  94. dagnam/data/_polars_utils.py +53 -0
  95. dagnam/data/cache.py +370 -0
  96. dagnam/data/dataset/__init__.py +7 -0
  97. dagnam/data/dataset/_typing.py +86 -0
  98. dagnam/data/dataset/base.py +466 -0
  99. dagnam/data/dataset/hooks.py +186 -0
  100. dagnam/data/dataset/to_flax.py +499 -0
  101. dagnam/data/dataset/to_polars.py +64 -0
  102. dagnam/data/dataset/to_pytorch.py +371 -0
  103. dagnam/data/dataset/to_tensorflow.py +471 -0
  104. dagnam/data/load.py +276 -0
  105. dagnam/data/loaders/__init__.py +49 -0
  106. dagnam/data/loaders/audio/__init__.py +17 -0
  107. dagnam/data/loaders/audio/dataset.py +192 -0
  108. dagnam/data/loaders/audio/io.py +137 -0
  109. dagnam/data/loaders/audio/transforms.py +323 -0
  110. dagnam/data/loaders/csv.py +218 -0
  111. dagnam/data/loaders/flax.py +157 -0
  112. dagnam/data/loaders/image_folder.py +406 -0
  113. dagnam/data/loaders/json_array.py +10 -0
  114. dagnam/data/loaders/media.py +402 -0
  115. dagnam/data/loaders/system/__init__.py +15 -0
  116. dagnam/data/loaders/system/bound_dataset.py +89 -0
  117. dagnam/data/loaders/system/column_store.py +68 -0
  118. dagnam/data/loaders/system/common.py +7 -0
  119. dagnam/data/loaders/system/decoders/__init__.py +28 -0
  120. dagnam/data/loaders/system/decoders/_helpers.py +109 -0
  121. dagnam/data/loaders/system/decoders/array.py +42 -0
  122. dagnam/data/loaders/system/decoders/audio_folder.py +89 -0
  123. dagnam/data/loaders/system/decoders/base.py +23 -0
  124. dagnam/data/loaders/system/decoders/image_folder.py +47 -0
  125. dagnam/data/loaders/system/decoders/image_mask_folder.py +72 -0
  126. dagnam/data/loaders/system/decoders/tabular.py +30 -0
  127. dagnam/data/loaders/system/decoders/text.py +46 -0
  128. dagnam/data/loaders/system/dispatch.py +285 -0
  129. dagnam/data/loaders/system/transform_executor.py +127 -0
  130. dagnam/data/loaders/text_lm.py +40 -0
  131. dagnam/data/loaders/tf.py +78 -0
  132. dagnam/data/loaders/torch_utils.py +31 -0
  133. dagnam/exceptions.py +66 -0
  134. dagnam/py.typed +0 -0
  135. dagnam/resources/__init__.py +32 -0
  136. dagnam/resources/account.py +369 -0
  137. dagnam/resources/checkpoints.py +151 -0
  138. dagnam/resources/codegen.py +176 -0
  139. dagnam/resources/datasets.py +177 -0
  140. dagnam/resources/deployments.py +564 -0
  141. dagnam/resources/hub.py +578 -0
  142. dagnam/resources/inference.py +120 -0
  143. dagnam/resources/projects.py +471 -0
  144. dagnam/resources/studio.py +36 -0
  145. dagnam/resources/training.py +434 -0
  146. dagnam/training.py +441 -0
  147. dagnam/training_attach.py +220 -0
  148. dagnam-0.7.0.dist-info/METADATA +557 -0
  149. dagnam-0.7.0.dist-info/RECORD +153 -0
  150. dagnam-0.7.0.dist-info/WHEEL +4 -0
  151. dagnam-0.7.0.dist-info/entry_points.txt +2 -0
  152. dagnam-0.7.0.dist-info/licenses/LICENSE +201 -0
  153. dagnam-0.7.0.dist-info/licenses/NOTICE +8 -0
dagnam/NOTICE ADDED
@@ -0,0 +1,8 @@
1
+ dagnam
2
+ Copyright 2026 Dagnam.AI
3
+
4
+ This product includes software developed by Dagnam.AI.
5
+
6
+ Dagnam.AI, Dagnam, and related names are trademarks or service marks of
7
+ Dagnam.AI. The Apache License 2.0 does not grant trademark rights except as
8
+ required for describing the origin of this work.
dagnam/__init__.py ADDED
@@ -0,0 +1,220 @@
1
+ """dagnam - Python client library for Dagnam.AI datasets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging as _logging
6
+
7
+ # Attach a NullHandler before any subpackage import so a DEBUG log emitted while
8
+ # dagnam.* wires itself up never falls through to logging.lastResort (the
9
+ # idiomatic PyPI-library logging contract — the app configures logging, not us).
10
+ # This one non-import statement must precede all imports below, so E402 is
11
+ # ignored file-wide in pyproject.toml with a documented reason.
12
+ _logging.getLogger("dagnam").addHandler(_logging.NullHandler())
13
+
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from dagnam._core._logging import enable_debug_logging
17
+ from dagnam._core.auth import configure, get_api_key, get_api_url
18
+ from dagnam._core.client import DagnamClient
19
+ from dagnam._core.config import get_config_value
20
+ from dagnam._core.lro import LongRunningOperation
21
+ from dagnam.data.cache import (
22
+ compute_file_checksum,
23
+ evict_lru,
24
+ get_cache_dir,
25
+ is_cached,
26
+ load_metadata,
27
+ save_checksum,
28
+ save_metadata,
29
+ touch_cache,
30
+ )
31
+ from dagnam.exceptions import (
32
+ APIError,
33
+ ArchitectureValidationError,
34
+ ArchitectureVersionNotFoundError,
35
+ AuthError,
36
+ CheckpointError,
37
+ CheckpointNotFoundError,
38
+ ChecksumError,
39
+ CodegenError,
40
+ CodegenValidationError,
41
+ DagnamError,
42
+ DatasetNotFoundError,
43
+ DeploymentNotFoundError,
44
+ DeploymentStateError,
45
+ DeploymentValidationError,
46
+ HubError,
47
+ HubModelNotFoundError,
48
+ LROFailedError,
49
+ LROTimeoutError,
50
+ ProjectNotFoundError,
51
+ QuotaExceededError,
52
+ ResponseError,
53
+ StreamError,
54
+ TaskNotFoundError,
55
+ TrainingJobNotFoundError,
56
+ UploadError,
57
+ )
58
+ from dagnam.resources import account, codegen, datasets, deployments, hub, projects, studio
59
+ from dagnam.resources.checkpoints import download_checkpoint
60
+ from dagnam.resources.datasets import (
61
+ delete_dataset,
62
+ preview_dataset,
63
+ update_dataset,
64
+ update_dataset_roles,
65
+ )
66
+ from dagnam.resources.inference import (
67
+ deployment_health,
68
+ inference,
69
+ inference_batch,
70
+ inference_schema,
71
+ inference_stream,
72
+ )
73
+ from dagnam.resources.projects import (
74
+ download_project_thumbnail,
75
+ upload_project_thumbnail,
76
+ )
77
+ from dagnam.resources.training import (
78
+ TrainingEvent,
79
+ allowed_strategies,
80
+ cancel_training_job,
81
+ create_training_job,
82
+ delete_training_jobs,
83
+ download_code,
84
+ download_dag,
85
+ estimate_resources,
86
+ get_training_job,
87
+ list_training_jobs,
88
+ restart,
89
+ restore_checkpoint,
90
+ stream_training,
91
+ training_logs,
92
+ training_metrics,
93
+ training_metrics_summary,
94
+ )
95
+ from dagnam.training import (
96
+ init,
97
+ report_error,
98
+ report_log,
99
+ report_metric,
100
+ report_progress,
101
+ report_system,
102
+ write_training_state,
103
+ )
104
+
105
+ __version__ = "0.7.0"
106
+
107
+ if TYPE_CHECKING:
108
+ # Declared for type checkers and ``__all__``; loaded lazily at runtime via
109
+ # ``__getattr__`` (see ``_LAZY_EXPORTS``) to keep import time low.
110
+ from dagnam._core.aio import AsyncDagnamClient
111
+ from dagnam.data.dataset import DagnamDataset
112
+ from dagnam.data.load import load_dataset
113
+
114
+ _LAZY_EXPORTS = {
115
+ "AsyncDagnamClient": ("dagnam._core.aio", "AsyncDagnamClient"),
116
+ "DagnamDataset": ("dagnam.data.dataset", "DagnamDataset"),
117
+ "load_dataset": ("dagnam.data.load", "load_dataset"),
118
+ }
119
+
120
+ __all__ = [
121
+ "APIError",
122
+ "ArchitectureValidationError",
123
+ "ArchitectureVersionNotFoundError",
124
+ "AsyncDagnamClient",
125
+ "AuthError",
126
+ "CheckpointError",
127
+ "CheckpointNotFoundError",
128
+ "ChecksumError",
129
+ "CodegenError",
130
+ "CodegenValidationError",
131
+ "DagnamClient",
132
+ "DagnamDataset",
133
+ "DagnamError",
134
+ "DatasetNotFoundError",
135
+ "DeploymentNotFoundError",
136
+ "DeploymentStateError",
137
+ "DeploymentValidationError",
138
+ "HubError",
139
+ "HubModelNotFoundError",
140
+ "LROFailedError",
141
+ "LROTimeoutError",
142
+ "LongRunningOperation",
143
+ "ProjectNotFoundError",
144
+ "QuotaExceededError",
145
+ "ResponseError",
146
+ "StreamError",
147
+ "TaskNotFoundError",
148
+ "TrainingEvent",
149
+ "TrainingJobNotFoundError",
150
+ "UploadError",
151
+ "__version__",
152
+ "account",
153
+ "allowed_strategies",
154
+ "cancel_training_job",
155
+ "codegen",
156
+ "compute_file_checksum",
157
+ "configure",
158
+ "create_training_job",
159
+ "datasets",
160
+ "delete_dataset",
161
+ "delete_training_jobs",
162
+ "deployment_health",
163
+ "deployments",
164
+ "download_checkpoint",
165
+ "download_code",
166
+ "download_dag",
167
+ "download_project_thumbnail",
168
+ "enable_debug_logging",
169
+ "estimate_resources",
170
+ "evict_lru",
171
+ "get_api_key",
172
+ "get_api_url",
173
+ "get_cache_dir",
174
+ "get_config_value",
175
+ "get_training_job",
176
+ "hub",
177
+ "inference",
178
+ "inference_batch",
179
+ "inference_schema",
180
+ "inference_stream",
181
+ "init",
182
+ "is_cached",
183
+ "list_training_jobs",
184
+ "load_dataset",
185
+ "load_metadata",
186
+ "preview_dataset",
187
+ "projects",
188
+ "report_error",
189
+ "report_log",
190
+ "report_metric",
191
+ "report_progress",
192
+ "report_system",
193
+ "restart",
194
+ "restore_checkpoint",
195
+ "save_checksum",
196
+ "save_metadata",
197
+ "stream_training",
198
+ "studio",
199
+ "touch_cache",
200
+ "training_logs",
201
+ "training_metrics",
202
+ "training_metrics_summary",
203
+ "update_dataset",
204
+ "update_dataset_roles",
205
+ "upload_project_thumbnail",
206
+ "write_training_state",
207
+ ]
208
+
209
+
210
+ def __getattr__(name: str) -> Any:
211
+ """Load optional/heavy public exports only when callers request them."""
212
+ if name not in _LAZY_EXPORTS:
213
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
214
+
215
+ from importlib import import_module
216
+
217
+ module_name, attr_name = _LAZY_EXPORTS[name]
218
+ value = getattr(import_module(module_name), attr_name)
219
+ globals()[name] = value
220
+ return value
@@ -0,0 +1,11 @@
1
+ """Agent-integration assets and installer.
2
+
3
+ This private package bundles the cross-platform Agent Skill (``skill/``) plus the
4
+ Claude Code and Codex adapter files (``claude/``, ``codex/``), and the importable
5
+ logic that powers them (``install.py``, ``runner.py``, ``guardhook.py``). It ships
6
+ as package data so ``dagnam agent install`` can locate it via ``importlib.resources``
7
+ after a pip install.
8
+
9
+ It must never import ``dagnam.cli`` (the CLI depends on this package, not the
10
+ reverse); the installed version is read via ``importlib.metadata`` instead.
11
+ """
@@ -0,0 +1,16 @@
1
+ ---
2
+ name: dagnam-runner
3
+ description: Runs long Dagnam train->watch->eval->deploy loops in an isolated context. Use when a training job or deployment must be driven to completion so its SSE metric stream and long-running-operation polling stay off the main conversation.
4
+ tools: Bash, Read
5
+ ---
6
+
7
+ You drive long-running Dagnam operations to completion in isolation.
8
+
9
+ - Stream training with `python <skill_dir>/scripts/watch_training.py <job_id>` (or
10
+ `dagnam stream <job_id> --json`) and report only the final outcome
11
+ (`complete` / `failed` / `cancelled`) plus a one-line metric summary — never paste the
12
+ full event stream back.
13
+ - Poll long-running operations with the SDK: `op.wait(timeout=...).result()`.
14
+ - Honor the GUARDRAIL: never start a costly action yourself (create a training job, create or
15
+ delete a deployment, delete a project/job, or publish to the hub). If the loop reaches such a
16
+ step, stop and return control with the proposed plan for the user to confirm.
@@ -0,0 +1,15 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "matcher": "Bash",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "python -m dagnam._agent.guardhook"
10
+ }
11
+ ]
12
+ }
13
+ ]
14
+ }
15
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "dagnam",
3
+ "version": "0.0.0",
4
+ "description": "Drive the Dagnam platform (datasets, projects, codegen, training, deployments, inference, hub) from Claude Code. Pairs with the `dagnam` skill installed at ~/.claude/skills/dagnam.",
5
+ "author": "Dagnam.AI",
6
+ "agents": ["./agents/dagnam-runner.md"],
7
+ "hooks": "./hooks/guard.json"
8
+ }
@@ -0,0 +1,4 @@
1
+ name: dagnam
2
+ version: 0.0.0
3
+ description: Drive the Dagnam platform from Codex (datasets, projects, codegen, training, deployments, inference, hub).
4
+ allow_implicit_invocation: true
@@ -0,0 +1,7 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ { "command": ["python", "-m", "dagnam._agent.guardhook"] }
5
+ ]
6
+ }
7
+ }
@@ -0,0 +1,74 @@
1
+ """Cross-platform PreToolUse hook: deny un-confirmed costly Dagnam commands.
2
+
3
+ Reads the harness hook event (JSON on stdin) and, if the Bash command runs a costly
4
+ Dagnam action (deployments create/delete, training create/delete, projects delete,
5
+ hub publish, or a public visibility flag) WITHOUT an explicit ``DAGNAM_CONFIRM=1``
6
+ prefix, emits a deny decision. Both the CLI verb shape (``dagnam deployments create``)
7
+ and the Python SDK shape (``python -c "import dagnam; dagnam.deployments.create(...)"``,
8
+ which ``SKILL.md`` recommends) are matched.
9
+
10
+ This hook is **non-authoritative, best-effort defense-in-depth ONLY.** It has known
11
+ blind spots -- command obfuscation (base64/eval, string-built attribute names),
12
+ aliasing, imports under a different module name, and any non-Bash tool the agent
13
+ might use -- and it fails OPEN on any error so it can never wedge the agent. The
14
+ authoritative gate is the ``SKILL.md`` behavioral rule plus server-side confirmation
15
+ enforcement; this hook only hardens them. The ``visibility=public`` heuristic is
16
+ deliberately broad and will occasionally deny a benign public create/upload -- an
17
+ accepted false positive, since a denial only asks the operator to re-run with a
18
+ ``DAGNAM_CONFIRM=1`` prefix.
19
+
20
+ Prints to stdout by design (the hook protocol), so it is T20-exempt.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import re
27
+ import sys
28
+
29
+ # Costly actions in BOTH the CLI verb shape and the Python SDK shape. re.DOTALL so a
30
+ # newline between ``dagnam`` and the action (a multi-line or line-continuation
31
+ # command) cannot hide it from the deny check.
32
+ _COSTLY = re.compile(
33
+ r"\bdagnam\b.*\b(?:"
34
+ r"deployments?\s+(?:create|delete)" # CLI: dagnam deployments create
35
+ r"|training\s+(?:create|delete)"
36
+ r"|projects?\s+delete"
37
+ r"|deployments?\s*\.\s*(?:create|delete)" # SDK: dagnam.deployments.create(
38
+ r"|training\s*\.\s*(?:create|delete)" # SDK: dagnam.training.create(
39
+ r"|projects?\s*\.\s*delete"
40
+ r"|hub\s*\.\s*create" # SDK: publish to the hub
41
+ r"|create_training_job"
42
+ r"|visibility\s*=\s*[\\'\"]*public" # any public create/upload (quotes/escapes optional)
43
+ r")",
44
+ re.DOTALL,
45
+ )
46
+ # Anchor the confirm token to a command-leading env assignment (start of line,
47
+ # or the first token after a shell separator). Matching it ANYWHERE let a
48
+ # hostile string echoed into an argument -- e.g. a dataset description
49
+ # "... DAGNAM_CONFIRM=1" surfaced via prompt injection -- silently satisfy the
50
+ # gate. It must be a real prefix the operator typed, not incidental text.
51
+ _CONFIRMED = re.compile(r"(?:^|[;&|\n]\s*)DAGNAM_CONFIRM=1\b")
52
+ _DENY_REASON = (
53
+ "Dagnam guardrail: this is a costly/irreversible action. Show the user an execution "
54
+ "plan (scripts/plan.py, or codegen.validate/preview + account.entitlements) and get "
55
+ "explicit confirmation, then re-run the command prefixed with DAGNAM_CONFIRM=1."
56
+ )
57
+
58
+
59
+ def main() -> int:
60
+ """Entry point wired by claude/hooks/guard.json and codex/hooks.json."""
61
+ try:
62
+ event = json.load(sys.stdin)
63
+ command = str(event.get("tool_input", {}).get("command", ""))
64
+ except (json.JSONDecodeError, AttributeError, ValueError):
65
+ return 0 # fail-open
66
+ if _COSTLY.search(command) and not _CONFIRMED.search(command):
67
+ json.dump(
68
+ {"permissionDecision": "deny", "permissionDecisionReason": _DENY_REASON}, sys.stdout
69
+ )
70
+ return 0
71
+
72
+
73
+ if __name__ == "__main__":
74
+ raise SystemExit(main())
@@ -0,0 +1,274 @@
1
+ """Detect installed agent harnesses and wire the Dagnam skill into each.
2
+
3
+ Pure logic: returns dataclasses describing planned/performed actions; it never prints
4
+ or prompts (``dagnam.cli.agent`` owns presentation). It must not import ``dagnam.cli``;
5
+ the installed version is read via ``importlib.metadata``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from importlib import resources
12
+ from importlib.metadata import PackageNotFoundError, version
13
+ import json
14
+ from pathlib import Path
15
+ import shutil
16
+ import sys
17
+
18
+ Harness = str # "claude" | "codex"
19
+
20
+ # The guard hook must run under the SAME interpreter that has ``dagnam``
21
+ # installed. A bare ``python`` on PATH may resolve to a different interpreter
22
+ # (pipx / uv-tool / venv installs), where ``-m dagnam._agent.guardhook`` raises
23
+ # ModuleNotFoundError and breaks every matched tool call. Pin ``sys.executable``.
24
+ _GUARD_HOOK_MODULE_ARGS = ["-m", "dagnam._agent.guardhook"]
25
+
26
+
27
+ def _guard_hook_entry() -> dict[str, object]:
28
+ """The Codex ``hooks.json`` guard entry, pinned to the current interpreter."""
29
+ return {"command": [sys.executable, *_GUARD_HOOK_MODULE_ARGS]}
30
+
31
+
32
+ def _is_guard_hook_entry(entry: object) -> bool:
33
+ """True if ``entry`` is our guard hook.
34
+
35
+ Matches regardless of which interpreter path it was written with, so
36
+ re-install stays idempotent and uninstall can find it even if the
37
+ interpreter moved.
38
+ """
39
+ if not isinstance(entry, dict):
40
+ return False
41
+ command = entry.get("command")
42
+ return isinstance(command, list) and command[-2:] == _GUARD_HOOK_MODULE_ARGS
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class InstallPlan:
47
+ """What ``install_harness`` will write for one harness (no side effects)."""
48
+
49
+ harness: Harness
50
+ skill_dest: Path
51
+ extra_dests: tuple[Path, ...] = ()
52
+
53
+
54
+ @dataclass
55
+ class InstallResult:
56
+ """What ``install_harness`` actually wrote for one harness."""
57
+
58
+ harness: Harness
59
+ skill_dest: Path
60
+ method: str # "symlink" | "copy"
61
+ wrote: list[Path] = field(default_factory=list)
62
+
63
+
64
+ def _home() -> Path:
65
+ return Path.home()
66
+
67
+
68
+ def _assets_root() -> Path:
69
+ """Filesystem path to the bundled ``dagnam/_agent`` assets (works post-pip-install)."""
70
+ return Path(str(resources.files("dagnam._agent")))
71
+
72
+
73
+ def package_version() -> str:
74
+ """Installed dagnam version, falling back to the in-tree value for source checkouts."""
75
+ try:
76
+ return version("dagnam")
77
+ except PackageNotFoundError:
78
+ from dagnam import __version__
79
+
80
+ return __version__
81
+
82
+
83
+ def detect_harnesses() -> list[Harness]:
84
+ """Return installed harnesses in stable order. Claude -> ~/.claude; Codex -> ~/.codex or ~/.agents."""
85
+ home = _home()
86
+ found: list[Harness] = []
87
+ if (home / ".claude").exists():
88
+ found.append("claude")
89
+ if (home / ".codex").exists() or (home / ".agents").exists():
90
+ found.append("codex")
91
+ return found
92
+
93
+
94
+ def _skill_dest(harness: Harness) -> Path:
95
+ home = _home()
96
+ if harness == "claude":
97
+ return home / ".claude" / "skills" / "dagnam"
98
+ return home / ".agents" / "skills" / "dagnam" # codex
99
+
100
+
101
+ def _claude_plugin_dir() -> Path:
102
+ return _home() / ".claude" / "plugins" / "dagnam"
103
+
104
+
105
+ def plan_for(harness: Harness) -> InstallPlan:
106
+ """Describe the install destinations for a harness without writing anything."""
107
+ extra: tuple[Path, ...]
108
+ if harness == "claude":
109
+ extra = (_claude_plugin_dir(),)
110
+ else:
111
+ extra = (_home() / ".codex" / "hooks.json",)
112
+ return InstallPlan(harness=harness, skill_dest=_skill_dest(harness), extra_dests=extra)
113
+
114
+
115
+ def _remove_dest(dest: Path) -> bool:
116
+ """Remove ``dest`` whether it is a symlink or a real directory. Returns True if removed."""
117
+ if dest.is_symlink():
118
+ dest.unlink()
119
+ return True
120
+ if dest.exists():
121
+ shutil.rmtree(dest)
122
+ return True
123
+ return False
124
+
125
+
126
+ def _try_symlink(dest: Path, src: Path) -> bool:
127
+ """Attempt a directory symlink; return False on OSError (e.g. unprivileged Windows)."""
128
+ try:
129
+ dest.symlink_to(src, target_is_directory=True)
130
+ except OSError:
131
+ return False
132
+ return True
133
+
134
+
135
+ def _copy_tree(src: Path, dest: Path) -> None:
136
+ if dest.exists():
137
+ shutil.rmtree(dest)
138
+ shutil.copytree(src, dest)
139
+
140
+
141
+ def _place_skill(method: str, dest: Path) -> str:
142
+ """Copy or symlink the bundled ``skill/`` into ``dest``. Returns the method used."""
143
+ src = _assets_root() / "skill"
144
+ _remove_dest(dest)
145
+ dest.parent.mkdir(parents=True, exist_ok=True)
146
+ if method == "symlink" and _try_symlink(dest, src):
147
+ return "symlink"
148
+ _copy_tree(src, dest)
149
+ return "copy"
150
+
151
+
152
+ def _stamp_version(path: Path, key: str = "version") -> None:
153
+ data = json.loads(path.read_text(encoding="utf-8"))
154
+ data[key] = package_version()
155
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
156
+
157
+
158
+ def _merge_codex_hook(wrote: list[Path]) -> None:
159
+ """Idempotently merge the guard hook into ``~/.codex/hooks.json``, preserving other entries."""
160
+ codex = _home() / ".codex"
161
+ codex.mkdir(parents=True, exist_ok=True)
162
+ hooks_path = codex / "hooks.json"
163
+ data: dict[str, object] = {}
164
+ if hooks_path.exists():
165
+ try:
166
+ loaded = json.loads(hooks_path.read_text(encoding="utf-8"))
167
+ data = loaded if isinstance(loaded, dict) else {}
168
+ except json.JSONDecodeError:
169
+ data = {}
170
+ hooks = data.setdefault("hooks", {})
171
+ if not isinstance(hooks, dict):
172
+ hooks = {}
173
+ data["hooks"] = hooks
174
+ pre = hooks.setdefault("PreToolUse", [])
175
+ if not isinstance(pre, list):
176
+ pre = []
177
+ hooks["PreToolUse"] = pre
178
+ if not any(_is_guard_hook_entry(entry) for entry in pre):
179
+ pre.append(_guard_hook_entry())
180
+ hooks_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
181
+ wrote.append(hooks_path)
182
+
183
+
184
+ def _install_codex_extras(wrote: list[Path]) -> None:
185
+ """Place + version-stamp the Codex skill metadata, then merge the guard hook."""
186
+ dest_agents = _skill_dest("codex") / "agents"
187
+ dest_agents.mkdir(parents=True, exist_ok=True)
188
+ src_yaml = _assets_root() / "codex" / "agents" / "openai.yaml"
189
+ yaml_text = src_yaml.read_text(encoding="utf-8").replace(
190
+ "version: 0.0.0", f"version: {package_version()}"
191
+ )
192
+ yaml_path = dest_agents / "openai.yaml"
193
+ yaml_path.write_text(yaml_text, encoding="utf-8")
194
+ wrote.append(yaml_path)
195
+ _merge_codex_hook(wrote)
196
+
197
+
198
+ def install_harness(harness: Harness, *, method: str = "copy") -> InstallResult:
199
+ """Install the skill (and adapter files) for one harness. Idempotent."""
200
+ dest = _skill_dest(harness)
201
+ used = _place_skill(method, dest)
202
+ wrote = [dest]
203
+ if harness == "claude":
204
+ plugin_dir = _claude_plugin_dir()
205
+ _copy_tree(_assets_root() / "claude", plugin_dir)
206
+ _stamp_version(plugin_dir / "plugin.json")
207
+ _stamp_claude_guard_interpreter(plugin_dir / "hooks" / "guard.json")
208
+ wrote.append(plugin_dir)
209
+ else: # codex
210
+ _install_codex_extras(wrote)
211
+ return InstallResult(harness=harness, skill_dest=dest, method=used, wrote=wrote)
212
+
213
+
214
+ def _stamp_claude_guard_interpreter(guard_path: Path) -> None:
215
+ """Rewrite the copied Claude guard hook to invoke the current interpreter.
216
+
217
+ The bundled asset ships a bare ``python`` command; pin it to
218
+ ``sys.executable`` (double-quoted so a path with spaces survives the shell)
219
+ for the same reason as the Codex entry — a different PATH ``python`` cannot
220
+ import ``dagnam``.
221
+ """
222
+ data = json.loads(guard_path.read_text(encoding="utf-8"))
223
+ command = f'"{sys.executable}" ' + " ".join(_GUARD_HOOK_MODULE_ARGS)
224
+ for group in data.get("hooks", {}).get("PreToolUse", []):
225
+ for hook in group.get("hooks", []):
226
+ if isinstance(hook, dict) and "-m dagnam._agent.guardhook" in str(hook.get("command")):
227
+ hook["command"] = command
228
+ guard_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
229
+
230
+
231
+ def _unmerge_codex_hook() -> list[Path]:
232
+ """Remove our guard entry from ``~/.codex/hooks.json``, leaving others intact.
233
+
234
+ Mirrors ``_merge_codex_hook`` so a codex uninstall — or a plain
235
+ ``pip uninstall dagnam`` follow-up — doesn't leave the hook firing against a
236
+ now-missing module.
237
+ """
238
+ hooks_path = _home() / ".codex" / "hooks.json"
239
+ if not hooks_path.exists():
240
+ return []
241
+ try:
242
+ data = json.loads(hooks_path.read_text(encoding="utf-8"))
243
+ except json.JSONDecodeError:
244
+ return []
245
+ if not isinstance(data, dict):
246
+ return []
247
+ hooks = data.get("hooks")
248
+ if not isinstance(hooks, dict):
249
+ return []
250
+ pre = hooks.get("PreToolUse")
251
+ if not isinstance(pre, list):
252
+ return []
253
+ kept = [entry for entry in pre if not _is_guard_hook_entry(entry)]
254
+ if len(kept) == len(pre):
255
+ return []
256
+ hooks["PreToolUse"] = kept
257
+ hooks_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
258
+ return [hooks_path]
259
+
260
+
261
+ def uninstall_harness(harness: Harness) -> list[Path]:
262
+ """Remove what ``install_harness`` wrote for a harness. Returns removed paths. Idempotent."""
263
+ removed: list[Path] = []
264
+ dest = _skill_dest(harness)
265
+ if _remove_dest(dest):
266
+ removed.append(dest)
267
+ if harness == "claude":
268
+ plugin_dir = _claude_plugin_dir()
269
+ if plugin_dir.exists():
270
+ shutil.rmtree(plugin_dir)
271
+ removed.append(plugin_dir)
272
+ else: # codex
273
+ removed.extend(_unmerge_codex_hook())
274
+ return removed