atif-make 0.1.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 (41) hide show
  1. atif_make-0.1.0/.github/workflows/publish.yml +35 -0
  2. atif_make-0.1.0/.github/workflows/test.yml +28 -0
  3. atif_make-0.1.0/.gitignore +7 -0
  4. atif_make-0.1.0/PKG-INFO +206 -0
  5. atif_make-0.1.0/README.md +196 -0
  6. atif_make-0.1.0/pyproject.toml +32 -0
  7. atif_make-0.1.0/src/atif_make/__init__.py +15 -0
  8. atif_make-0.1.0/src/atif_make/archive.py +109 -0
  9. atif_make-0.1.0/src/atif_make/atif.py +353 -0
  10. atif_make-0.1.0/src/atif_make/cli.py +279 -0
  11. atif_make-0.1.0/src/atif_make/convert.py +43 -0
  12. atif_make-0.1.0/src/atif_make/corpus.py +149 -0
  13. atif_make-0.1.0/src/atif_make/detect.py +126 -0
  14. atif_make-0.1.0/src/atif_make/parsers/__init__.py +0 -0
  15. atif_make-0.1.0/src/atif_make/parsers/atif_json.py +60 -0
  16. atif_make-0.1.0/src/atif_make/parsers/claude_code.py +575 -0
  17. atif_make-0.1.0/src/atif_make/parsers/codex.py +385 -0
  18. atif_make-0.1.0/src/atif_make/parsers/copilot.py +130 -0
  19. atif_make-0.1.0/src/atif_make/parsers/har.py +403 -0
  20. atif_make-0.1.0/tests/conftest.py +13 -0
  21. atif_make-0.1.0/tests/fixtures/claude-code-stream.jsonl +15 -0
  22. atif_make-0.1.0/tests/fixtures/claude-code-transcript/subagents/agent-abc123.jsonl +4 -0
  23. atif_make-0.1.0/tests/fixtures/claude-code-transcript/subagents/agent-abc123.meta.json +1 -0
  24. atif_make-0.1.0/tests/fixtures/claude-code-transcript.jsonl +8 -0
  25. atif_make-0.1.0/tests/fixtures/codex-exec.jsonl +5 -0
  26. atif_make-0.1.0/tests/fixtures/codex-rollout.jsonl +8 -0
  27. atif_make-0.1.0/tests/fixtures/copilot-cli.jsonl +14 -0
  28. atif_make-0.1.0/tests/fixtures/har-anthropic-multiturn.har +47 -0
  29. atif_make-0.1.0/tests/fixtures/har-anthropic-stream.har +29 -0
  30. atif_make-0.1.0/tests/fixtures/har-anthropic-whole.har +29 -0
  31. atif_make-0.1.0/tests/fixtures/har-openai-chat.har +29 -0
  32. atif_make-0.1.0/tests/fixtures/har-openai-responses.har +29 -0
  33. atif_make-0.1.0/tests/test_archive.py +120 -0
  34. atif_make-0.1.0/tests/test_claude_code.py +80 -0
  35. atif_make-0.1.0/tests/test_codex.py +56 -0
  36. atif_make-0.1.0/tests/test_copilot.py +31 -0
  37. atif_make-0.1.0/tests/test_detect.py +54 -0
  38. atif_make-0.1.0/tests/test_har.py +53 -0
  39. atif_make-0.1.0/tests/test_multimodal.py +147 -0
  40. atif_make-0.1.0/tests/test_spec_compliance.py +84 -0
  41. atif_make-0.1.0/uv.lock +2204 -0
@@ -0,0 +1,35 @@
1
+ name: publish
2
+
3
+ # Publishes to PyPI when a version tag is pushed:
4
+ #
5
+ # git tag v0.1.0 && git push origin v0.1.0
6
+ #
7
+ # Authentication uses PyPI Trusted Publishing (OIDC), so no API token is stored
8
+ # anywhere. GitHub mints a short-lived identity for this workflow and PyPI
9
+ # verifies it against the publisher configured for the project.
10
+ on:
11
+ push:
12
+ tags: ["v*"]
13
+
14
+ jobs:
15
+ publish:
16
+ runs-on: ubuntu-latest
17
+ environment: pypi
18
+ permissions:
19
+ # Required for trusted publishing; nothing else needs write access.
20
+ id-token: write
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+ - uses: astral-sh/setup-uv@v5
24
+
25
+ - name: Refuse to publish a tag that disagrees with the version
26
+ run: |
27
+ tag="${GITHUB_REF_NAME#v}"
28
+ pkg=$(uv version --short)
29
+ if [ "$tag" != "$pkg" ]; then
30
+ echo "tag $tag does not match project version $pkg" >&2
31
+ exit 1
32
+ fi
33
+
34
+ - run: uv build
35
+ - run: uv publish --trusted-publishing always
@@ -0,0 +1,28 @@
1
+ name: test
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ python-version: ["3.12", "3.13"]
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: astral-sh/setup-uv@v5
18
+ with:
19
+ enable-cache: true
20
+ - run: uv python install ${{ matrix.python-version }}
21
+ - name: Run tests
22
+ run: uv run --python ${{ matrix.python-version }} pytest -q
23
+
24
+ # The spec suite validates output against harbor's reference ATIF models.
25
+ # It pulls a large dependency tree, so it runs on one version only.
26
+ - name: Validate against the ATIF reference models
27
+ if: matrix.python-version == '3.12'
28
+ run: uv run --python 3.12 --extra spec pytest -q tests/test_spec_compliance.py
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ # Never commit converted transcripts — they contain real session content.
6
+ *.trajectory.json
7
+ dist/
@@ -0,0 +1,206 @@
1
+ Metadata-Version: 2.5
2
+ Name: atif-make
3
+ Version: 0.1.0
4
+ Summary: Convert agent logs (Claude Code, Codex, Copilot, HAR) into ATIF v1.7 trajectories.
5
+ License: MIT
6
+ Requires-Python: >=3.12
7
+ Provides-Extra: spec
8
+ Requires-Dist: harbor>=0.21.0; extra == 'spec'
9
+ Description-Content-Type: text/markdown
10
+
11
+ # atif-make
12
+
13
+ Make [ATIF v1.7](https://github.com/harbor-framework/harbor/blob/main/rfcs/0001-trajectory-format.md)
14
+ trajectories from agent logs — Claude Code, Codex, Copilot CLI, and HAR captures.
15
+
16
+ Zero runtime dependencies. Python 3.12+.
17
+
18
+ ## Install
19
+
20
+ ```sh
21
+ uv tool install atif-make # puts `atif-make` on your PATH
22
+ ```
23
+
24
+ Or as a library in a project:
25
+
26
+ ```sh
27
+ uv add atif-make
28
+ ```
29
+
30
+ For a browser view of what you convert, see the companion
31
+ [`atif-view`](https://github.com/jammastergirish/atif-view), which depends on this package.
32
+
33
+ `uv tool install` builds an isolated environment, so nothing lands in your
34
+ project or system Python. To follow local edits instead, use
35
+ `uv tool install --editable .`; to remove it, `uv tool uninstall atif-make`.
36
+
37
+ Running from a checkout without installing works too: `uv run atif-make ...`.
38
+
39
+ ```sh
40
+ atif-make ~/.claude/projects/my-project/session.jsonl # convert one log
41
+ atif-make index --stats # what do I have, across agents?
42
+ ```
43
+
44
+ ## Commands
45
+
46
+ ```
47
+ atif-make <file> convert (shorthand for `atif-make convert`)
48
+ atif-make convert <file> convert one log
49
+ atif-make convert <dir|archive> convert every log inside
50
+ atif-make index [roots...] scan for sessions across agents
51
+ atif-make formats list supported input formats
52
+ ```
53
+
54
+ | Flag | Command | Meaning |
55
+ |---|---|---|
56
+ | `-o, --output` | convert | output path (default `<input>.trajectory.json`) |
57
+ | `-f, --format` | convert | force the input format instead of detecting it |
58
+ | `--json` | convert | write one self-contained document to stdout |
59
+ | `--bundle OUT.zip` | convert | zip the trajectory with its images and subagents |
60
+ | `--split-subagents` | convert | write subagents as sibling files, not embedded |
61
+ | `--indent N` | convert | JSON indent (default 2) |
62
+ | `-q, --quiet` | convert | suppress progress output |
63
+ | `--stats` | index | print a per-agent summary |
64
+ | `--add` | index | merge into the existing index instead of replacing it |
65
+
66
+ ## Supported inputs
67
+
68
+ Most agents write *two* unrelated log shapes — what the CLI streams, and what it
69
+ persists on disk — and they are not interchangeable. atif-make reads both.
70
+
71
+ | Format | Source |
72
+ |---|---|
73
+ | `claude-code-transcript` | `~/.claude/projects/<project>/<session>.jsonl` |
74
+ | `claude-code-stream` | `claude -p --output-format stream-json` |
75
+ | `codex-rollout` | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` |
76
+ | `codex-exec` | `codex exec --json` |
77
+ | `copilot-cli` | Copilot CLI session logs |
78
+ | `har` | Anthropic Messages, OpenAI Chat Completions, OpenAI Responses |
79
+ | `atif` | An ATIF trajectory that is already converted |
80
+
81
+ Directories, `.zip` and `.tar.gz` (also `.tgz`, `.tar.bz2`, `.tar.xz`) are read
82
+ as containers: every log inside is found and converted. That closes the loop on
83
+ `--bundle` — the zip atif-make hands you to send someone opens again in atif-make,
84
+ images and all.
85
+
86
+ Archives are extracted to a temporary directory, once per run. Members naming
87
+ absolute paths or climbing out with `..` are refused rather than quietly
88
+ sanitised, and an archive that expands past 8 GB or 20,000 entries is rejected
89
+ outright.
90
+
91
+ Format is detected from content, never from the extension. `atif` exists so a
92
+ trajectory someone sends you opens like anything else — it is loaded, not
93
+ reparsed, and unknown fields from a newer ATIF minor version are dropped rather
94
+ than rejected.
95
+
96
+ ## What it gets right
97
+
98
+ These are the things that are easy to get wrong, and that silently corrupt a
99
+ trajectory when you do:
100
+
101
+ **Split messages.** Claude Code writes one API response as several JSONL lines
102
+ that share a `message.id` — thinking, text, and each parallel `tool_use` arrive
103
+ separately, with the *same* `usage` object repeated on every line. Treating those
104
+ as separate turns inflates step counts and multiplies token totals. atif-make
105
+ coalesces them and counts usage once.
106
+
107
+ **Out-of-order tool results.** Parallel calls come back interleaved, and a slow
108
+ call can return several turns after it was issued. Pairing results to calls by
109
+ *position* drops some and misattributes others. atif-make pairs by `tool_use_id`.
110
+
111
+ **Byte-capped detection.** A JSONL preamble (hook events, rate-limit notices) can
112
+ push the identifying line kilobytes into a file. atif-make scans whole lines.
113
+
114
+ **Subagent structure.** Claude Code links a delegated agent through a `.meta.json`
115
+ sidecar (`toolUseId`) and an `agentId` field on the result line — not through
116
+ anything in the result *text*. atif-make links by call id, so refs actually resolve
117
+ instead of leaving orphaned subagents.
118
+
119
+ **Images.** Codex embeds screenshots as base64 data URLs and Claude Code as
120
+ base64 content blocks — 24 of the sessions on one test machine carried them, and
121
+ a single Codex session held 65 images totalling 14 MB. Dropping them loses the
122
+ thing the agent was actually looking at, and inlining them makes an unreadable
123
+ document. atif-make writes them to `images/` and references them by relative path,
124
+ which is what the spec asks for.
125
+
126
+ **Malformed timestamps.** ATIF requires ISO 8601. A truncated or hand-edited log
127
+ can carry something else, and passing it through would make the whole trajectory
128
+ fail validation, so an unparseable timestamp is dropped rather than emitted.
129
+
130
+ **HAR tool results.** In a HAR capture a tool's output is not in the response that
131
+ called it — it appears in the *next* request's message history. atif-make harvests
132
+ results across entries and pairs them back by id, while emitting the shared
133
+ conversation prefix only once.
134
+
135
+ ## Output — a trajectory is a directory, not a file
136
+
137
+ ATIF references images and split subagents by path *relative to the trajectory
138
+ file*, so anything with attachments is inherently multi-file:
139
+
140
+ ```
141
+ session.trajectory.json the document
142
+ session.trajectory.<agent-id>.json subagents, with --split-subagents
143
+ images/<sha>.png images, referenced as "images/<sha>.png"
144
+ ```
145
+
146
+ Images are de-duplicated by content hash, so the same screenshot pasted five
147
+ times is stored once.
148
+
149
+ Three ways out, depending on where it's going:
150
+
151
+ ```sh
152
+ atif-make session.jsonl -o out/t.json # directory form: t.json + images/
153
+ atif-make session.jsonl --json # one self-contained doc; images inlined as data: URIs
154
+ atif-make session.jsonl --bundle send.zip # zip of the whole directory — for sending someone
155
+ ```
156
+
157
+ `--json` is the exception that proves the rule: stdout has no directory to put
158
+ siblings in, so images become data URIs to keep the document standalone.
159
+
160
+ Output carries `timestamp`, `reasoning_content`, per-step `metrics`, multimodal
161
+ `ContentPart` message content, and subagents either embedded
162
+ (`subagent_trajectories`) or split into sibling files with resolvable
163
+ `trajectory_path` refs.
164
+
165
+ ## Viewing
166
+
167
+ Conversion is all this package does. To browse trajectories in a browser,
168
+ install the companion [`atif-view`](https://github.com/jammastergirish/atif-view), which depends on this
169
+ package and reads the index below.
170
+
171
+ ## Where sessions come from
172
+
173
+ `atif-make index` scans your own machine — nothing is uploaded, and no network call
174
+ is ever made. By default it looks in:
175
+
176
+ ```
177
+ ~/.claude/projects/ Claude Code sessions
178
+ ~/.codex/sessions/ Codex sessions
179
+ ```
180
+
181
+ The result is cached at `~/.atif-make/index.json`. Indexing reads only each file's
182
+ leading lines, so a 143 MB rollout costs the same as a 4 KB one; full conversion
183
+ happens lazily, when you actually open a session.
184
+
185
+ Scan somewhere else, or add a single file someone sent you:
186
+
187
+ ```sh
188
+ atif-make index ~/work/logs # replace the index with this scan
189
+ atif-make index --add received.trajectory.json # merge one file into the existing index
190
+ atif-make index --add received-bundle.zip # or a whole bundle
191
+ ```
192
+
193
+ `--add` merges; without it, `index` replaces. Both raw logs and already-converted
194
+ ATIF trajectories can be added.
195
+
196
+ ## Tests
197
+
198
+ ```sh
199
+ uv run pytest # unit tests, all synthetic fixtures
200
+ uv sync --extra spec # pulls harbor (large)
201
+ uv run --extra spec pytest # + validate against the reference ATIF models
202
+ ```
203
+
204
+ The `spec` suite validates every fixture against harbor's own pydantic models —
205
+ ground truth for whether the output is really ATIF, rather than what atif-make
206
+ believes ATIF to be.
@@ -0,0 +1,196 @@
1
+ # atif-make
2
+
3
+ Make [ATIF v1.7](https://github.com/harbor-framework/harbor/blob/main/rfcs/0001-trajectory-format.md)
4
+ trajectories from agent logs — Claude Code, Codex, Copilot CLI, and HAR captures.
5
+
6
+ Zero runtime dependencies. Python 3.12+.
7
+
8
+ ## Install
9
+
10
+ ```sh
11
+ uv tool install atif-make # puts `atif-make` on your PATH
12
+ ```
13
+
14
+ Or as a library in a project:
15
+
16
+ ```sh
17
+ uv add atif-make
18
+ ```
19
+
20
+ For a browser view of what you convert, see the companion
21
+ [`atif-view`](https://github.com/jammastergirish/atif-view), which depends on this package.
22
+
23
+ `uv tool install` builds an isolated environment, so nothing lands in your
24
+ project or system Python. To follow local edits instead, use
25
+ `uv tool install --editable .`; to remove it, `uv tool uninstall atif-make`.
26
+
27
+ Running from a checkout without installing works too: `uv run atif-make ...`.
28
+
29
+ ```sh
30
+ atif-make ~/.claude/projects/my-project/session.jsonl # convert one log
31
+ atif-make index --stats # what do I have, across agents?
32
+ ```
33
+
34
+ ## Commands
35
+
36
+ ```
37
+ atif-make <file> convert (shorthand for `atif-make convert`)
38
+ atif-make convert <file> convert one log
39
+ atif-make convert <dir|archive> convert every log inside
40
+ atif-make index [roots...] scan for sessions across agents
41
+ atif-make formats list supported input formats
42
+ ```
43
+
44
+ | Flag | Command | Meaning |
45
+ |---|---|---|
46
+ | `-o, --output` | convert | output path (default `<input>.trajectory.json`) |
47
+ | `-f, --format` | convert | force the input format instead of detecting it |
48
+ | `--json` | convert | write one self-contained document to stdout |
49
+ | `--bundle OUT.zip` | convert | zip the trajectory with its images and subagents |
50
+ | `--split-subagents` | convert | write subagents as sibling files, not embedded |
51
+ | `--indent N` | convert | JSON indent (default 2) |
52
+ | `-q, --quiet` | convert | suppress progress output |
53
+ | `--stats` | index | print a per-agent summary |
54
+ | `--add` | index | merge into the existing index instead of replacing it |
55
+
56
+ ## Supported inputs
57
+
58
+ Most agents write *two* unrelated log shapes — what the CLI streams, and what it
59
+ persists on disk — and they are not interchangeable. atif-make reads both.
60
+
61
+ | Format | Source |
62
+ |---|---|
63
+ | `claude-code-transcript` | `~/.claude/projects/<project>/<session>.jsonl` |
64
+ | `claude-code-stream` | `claude -p --output-format stream-json` |
65
+ | `codex-rollout` | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` |
66
+ | `codex-exec` | `codex exec --json` |
67
+ | `copilot-cli` | Copilot CLI session logs |
68
+ | `har` | Anthropic Messages, OpenAI Chat Completions, OpenAI Responses |
69
+ | `atif` | An ATIF trajectory that is already converted |
70
+
71
+ Directories, `.zip` and `.tar.gz` (also `.tgz`, `.tar.bz2`, `.tar.xz`) are read
72
+ as containers: every log inside is found and converted. That closes the loop on
73
+ `--bundle` — the zip atif-make hands you to send someone opens again in atif-make,
74
+ images and all.
75
+
76
+ Archives are extracted to a temporary directory, once per run. Members naming
77
+ absolute paths or climbing out with `..` are refused rather than quietly
78
+ sanitised, and an archive that expands past 8 GB or 20,000 entries is rejected
79
+ outright.
80
+
81
+ Format is detected from content, never from the extension. `atif` exists so a
82
+ trajectory someone sends you opens like anything else — it is loaded, not
83
+ reparsed, and unknown fields from a newer ATIF minor version are dropped rather
84
+ than rejected.
85
+
86
+ ## What it gets right
87
+
88
+ These are the things that are easy to get wrong, and that silently corrupt a
89
+ trajectory when you do:
90
+
91
+ **Split messages.** Claude Code writes one API response as several JSONL lines
92
+ that share a `message.id` — thinking, text, and each parallel `tool_use` arrive
93
+ separately, with the *same* `usage` object repeated on every line. Treating those
94
+ as separate turns inflates step counts and multiplies token totals. atif-make
95
+ coalesces them and counts usage once.
96
+
97
+ **Out-of-order tool results.** Parallel calls come back interleaved, and a slow
98
+ call can return several turns after it was issued. Pairing results to calls by
99
+ *position* drops some and misattributes others. atif-make pairs by `tool_use_id`.
100
+
101
+ **Byte-capped detection.** A JSONL preamble (hook events, rate-limit notices) can
102
+ push the identifying line kilobytes into a file. atif-make scans whole lines.
103
+
104
+ **Subagent structure.** Claude Code links a delegated agent through a `.meta.json`
105
+ sidecar (`toolUseId`) and an `agentId` field on the result line — not through
106
+ anything in the result *text*. atif-make links by call id, so refs actually resolve
107
+ instead of leaving orphaned subagents.
108
+
109
+ **Images.** Codex embeds screenshots as base64 data URLs and Claude Code as
110
+ base64 content blocks — 24 of the sessions on one test machine carried them, and
111
+ a single Codex session held 65 images totalling 14 MB. Dropping them loses the
112
+ thing the agent was actually looking at, and inlining them makes an unreadable
113
+ document. atif-make writes them to `images/` and references them by relative path,
114
+ which is what the spec asks for.
115
+
116
+ **Malformed timestamps.** ATIF requires ISO 8601. A truncated or hand-edited log
117
+ can carry something else, and passing it through would make the whole trajectory
118
+ fail validation, so an unparseable timestamp is dropped rather than emitted.
119
+
120
+ **HAR tool results.** In a HAR capture a tool's output is not in the response that
121
+ called it — it appears in the *next* request's message history. atif-make harvests
122
+ results across entries and pairs them back by id, while emitting the shared
123
+ conversation prefix only once.
124
+
125
+ ## Output — a trajectory is a directory, not a file
126
+
127
+ ATIF references images and split subagents by path *relative to the trajectory
128
+ file*, so anything with attachments is inherently multi-file:
129
+
130
+ ```
131
+ session.trajectory.json the document
132
+ session.trajectory.<agent-id>.json subagents, with --split-subagents
133
+ images/<sha>.png images, referenced as "images/<sha>.png"
134
+ ```
135
+
136
+ Images are de-duplicated by content hash, so the same screenshot pasted five
137
+ times is stored once.
138
+
139
+ Three ways out, depending on where it's going:
140
+
141
+ ```sh
142
+ atif-make session.jsonl -o out/t.json # directory form: t.json + images/
143
+ atif-make session.jsonl --json # one self-contained doc; images inlined as data: URIs
144
+ atif-make session.jsonl --bundle send.zip # zip of the whole directory — for sending someone
145
+ ```
146
+
147
+ `--json` is the exception that proves the rule: stdout has no directory to put
148
+ siblings in, so images become data URIs to keep the document standalone.
149
+
150
+ Output carries `timestamp`, `reasoning_content`, per-step `metrics`, multimodal
151
+ `ContentPart` message content, and subagents either embedded
152
+ (`subagent_trajectories`) or split into sibling files with resolvable
153
+ `trajectory_path` refs.
154
+
155
+ ## Viewing
156
+
157
+ Conversion is all this package does. To browse trajectories in a browser,
158
+ install the companion [`atif-view`](https://github.com/jammastergirish/atif-view), which depends on this
159
+ package and reads the index below.
160
+
161
+ ## Where sessions come from
162
+
163
+ `atif-make index` scans your own machine — nothing is uploaded, and no network call
164
+ is ever made. By default it looks in:
165
+
166
+ ```
167
+ ~/.claude/projects/ Claude Code sessions
168
+ ~/.codex/sessions/ Codex sessions
169
+ ```
170
+
171
+ The result is cached at `~/.atif-make/index.json`. Indexing reads only each file's
172
+ leading lines, so a 143 MB rollout costs the same as a 4 KB one; full conversion
173
+ happens lazily, when you actually open a session.
174
+
175
+ Scan somewhere else, or add a single file someone sent you:
176
+
177
+ ```sh
178
+ atif-make index ~/work/logs # replace the index with this scan
179
+ atif-make index --add received.trajectory.json # merge one file into the existing index
180
+ atif-make index --add received-bundle.zip # or a whole bundle
181
+ ```
182
+
183
+ `--add` merges; without it, `index` replaces. Both raw logs and already-converted
184
+ ATIF trajectories can be added.
185
+
186
+ ## Tests
187
+
188
+ ```sh
189
+ uv run pytest # unit tests, all synthetic fixtures
190
+ uv sync --extra spec # pulls harbor (large)
191
+ uv run --extra spec pytest # + validate against the reference ATIF models
192
+ ```
193
+
194
+ The `spec` suite validates every fixture against harbor's own pydantic models —
195
+ ground truth for whether the output is really ATIF, rather than what atif-make
196
+ believes ATIF to be.
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "atif-make"
3
+ version = "0.1.0"
4
+ description = "Convert agent logs (Claude Code, Codex, Copilot, HAR) into ATIF v1.7 trajectories."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = { text = "MIT" }
8
+ # Deliberately zero runtime dependencies: this runs against local session logs
9
+ # and should stay trivially installable anywhere.
10
+ dependencies = []
11
+
12
+ [project.scripts]
13
+ atif-make = "atif_make.cli:main"
14
+
15
+ [project.optional-dependencies]
16
+ # `harbor` carries the reference ATIF pydantic models. It pulls a large tree
17
+ # (litellm, fastapi, supabase), so it is kept out of the default test path and
18
+ # used only by the spec-compliance suite.
19
+ spec = ["harbor>=0.21.0"]
20
+
21
+ [dependency-groups]
22
+ dev = ["pytest>=8.0"]
23
+
24
+ [build-system]
25
+ requires = ["hatchling"]
26
+ build-backend = "hatchling.build"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/atif_make"]
30
+
31
+ [tool.pytest.ini_options]
32
+ testpaths = ["tests"]
@@ -0,0 +1,15 @@
1
+ """atif-make — convert agent logs into ATIF v1.7 trajectories."""
2
+
3
+ from .atif import SCHEMA_VERSION, Trajectory
4
+ from .convert import AGENTS, PARSERS, convert
5
+ from .detect import FORMATS, detect_format
6
+
7
+ __all__ = [
8
+ "SCHEMA_VERSION",
9
+ "Trajectory",
10
+ "convert",
11
+ "detect_format",
12
+ "FORMATS",
13
+ "PARSERS",
14
+ "AGENTS",
15
+ ]
@@ -0,0 +1,109 @@
1
+ """Reading logs out of archives and directories.
2
+
3
+ mkatif produces zip bundles, so it has to be able to open them again — and a
4
+ colleague is as likely to send a `.tar.gz` of a session directory as a bare
5
+ file. Archives are extracted to a temporary directory once per process and
6
+ reused, since a viewer may open several trajectories from the same bundle.
7
+
8
+ Extraction is the dangerous part of this file. Archive members can name absolute
9
+ paths or climb out with `..` (the "zip slip" / CVE-2007-4559 family), and a
10
+ small archive can expand to fill a disk. Both are refused rather than sanitised
11
+ quietly, so a malformed bundle fails loudly instead of writing somewhere odd.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import shutil
17
+ import tarfile
18
+ import tempfile
19
+ import zipfile
20
+ from pathlib import Path
21
+
22
+ # A session log can legitimately be very large — a 143 MB rollout is real — so
23
+ # the ceiling is high enough for a genuine bundle and low enough to stop a bomb.
24
+ MAX_TOTAL_BYTES = 8 * 1024 * 1024 * 1024
25
+ MAX_MEMBERS = 20_000
26
+
27
+ TAR_SUFFIXES = (".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz")
28
+
29
+ _cache: dict[Path, Path] = {}
30
+ _temp_dirs: list[tempfile.TemporaryDirectory] = []
31
+
32
+
33
+ def is_archive(path: Path) -> bool:
34
+ name = path.name.lower()
35
+ if name.endswith(".zip"):
36
+ return zipfile.is_zipfile(path)
37
+ if any(name.endswith(s) for s in TAR_SUFFIXES):
38
+ return tarfile.is_tarfile(path)
39
+ # Fall back to content sniffing for archives with an unhelpful name.
40
+ try:
41
+ if zipfile.is_zipfile(path):
42
+ return True
43
+ return tarfile.is_tarfile(path)
44
+ except (OSError, tarfile.TarError):
45
+ return False
46
+
47
+
48
+ def _safe_target(root: Path, member: str) -> Path:
49
+ """Resolve an archive member under root, refusing anything that escapes."""
50
+ if member.startswith("/") or ".." in Path(member).parts:
51
+ raise ValueError(f"unsafe path in archive: {member}")
52
+ target = (root / member).resolve()
53
+ if not str(target).startswith(str(root.resolve())):
54
+ raise ValueError(f"archive member escapes destination: {member}")
55
+ return target
56
+
57
+
58
+ def extract(path: Path) -> Path:
59
+ """Extract an archive to a temporary directory and return its root."""
60
+ resolved = path.resolve()
61
+ cached = _cache.get(resolved)
62
+ if cached is not None and cached.exists():
63
+ return cached
64
+
65
+ holder = tempfile.TemporaryDirectory(prefix="mkatif-")
66
+ _temp_dirs.append(holder) # keep alive for the life of the process
67
+ root = Path(holder.name)
68
+
69
+ if zipfile.is_zipfile(path):
70
+ with zipfile.ZipFile(path) as archive:
71
+ members = archive.infolist()
72
+ if len(members) > MAX_MEMBERS:
73
+ raise ValueError(f"archive has too many entries ({len(members)})")
74
+ total = sum(m.file_size for m in members)
75
+ if total > MAX_TOTAL_BYTES:
76
+ raise ValueError(f"archive expands to {total} bytes, refusing")
77
+ for member in members:
78
+ if member.is_dir():
79
+ continue
80
+ target = _safe_target(root, member.filename)
81
+ target.parent.mkdir(parents=True, exist_ok=True)
82
+ with archive.open(member) as src, target.open("wb") as dst:
83
+ shutil.copyfileobj(src, dst)
84
+ else:
85
+ with tarfile.open(path) as archive:
86
+ members = archive.getmembers()
87
+ if len(members) > MAX_MEMBERS:
88
+ raise ValueError(f"archive has too many entries ({len(members)})")
89
+ total = sum(m.size for m in members if m.isreg())
90
+ if total > MAX_TOTAL_BYTES:
91
+ raise ValueError(f"archive expands to {total} bytes, refusing")
92
+ for member in members:
93
+ if not (member.isreg() or member.isdir()):
94
+ # Skip links and devices outright; nothing here needs them.
95
+ continue
96
+ _safe_target(root, member.name)
97
+ # `filter="data"` is Python's own hardening for the same class of bug.
98
+ archive.extractall(root, filter="data")
99
+
100
+ _cache[resolved] = root
101
+ return root
102
+
103
+
104
+ def cleanup() -> None:
105
+ """Drop every extracted directory. Mainly for tests."""
106
+ for holder in _temp_dirs:
107
+ holder.cleanup()
108
+ _temp_dirs.clear()
109
+ _cache.clear()