codestrain 0.1.2__tar.gz → 0.1.4__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.
@@ -5,6 +5,10 @@ on:
5
5
  pull_request:
6
6
  branches: [main]
7
7
 
8
+ # Opt into Node.js 24 for JS-based actions; default flips on 2026-06-02.
9
+ env:
10
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
11
+
8
12
  jobs:
9
13
  test:
10
14
  name: Test (${{ matrix.os }}, Python ${{ matrix.python-version }})
@@ -0,0 +1,125 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ # Opt into Node.js 24 for JS-based actions; default will flip on 2026-06-02.
9
+ # See https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/
10
+ env:
11
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
12
+
13
+ jobs:
14
+ build:
15
+ name: Build distributions
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - name: Checkout
19
+ uses: actions/checkout@v4
20
+
21
+ - name: Set up Python
22
+ uses: actions/setup-python@v5
23
+ with:
24
+ python-version: "3.12"
25
+
26
+ - name: Install build tooling
27
+ run: python -m pip install --upgrade build
28
+
29
+ - name: Build sdist and wheel
30
+ working-directory: .
31
+ run: python -m build
32
+
33
+ - name: Upload distributions
34
+ uses: actions/upload-artifact@v4
35
+ with:
36
+ name: dist
37
+ path: dist/
38
+
39
+ publish:
40
+ name: Publish to PyPI
41
+ needs: build
42
+ runs-on: ubuntu-latest
43
+ environment: pypi
44
+ permissions:
45
+ id-token: write
46
+ steps:
47
+ - name: Download distributions
48
+ uses: actions/download-artifact@v4
49
+ with:
50
+ name: dist
51
+ path: dist/
52
+
53
+ - name: Publish to PyPI via Trusted Publisher
54
+ uses: pypa/gh-action-pypi-publish@release/v1
55
+
56
+ bump-homebrew:
57
+ name: Bump Homebrew Formula
58
+ needs: publish
59
+ runs-on: ubuntu-latest
60
+ if: ${{ !contains(github.ref_name, 'rc') && !contains(github.ref_name, 'a') && !contains(github.ref_name, 'b') }}
61
+ steps:
62
+ # mislav/bump-homebrew-formula-action proved flaky against PyPI's
63
+ # /packages/source/ redirect (HTTP 404 on its HEAD probe even after
64
+ # the file is reachable via GET). Rolling our own bump is more
65
+ # reliable and easier to debug: query PyPI's JSON API for the
66
+ # canonical URL + sha256, then sed-update the formula and push.
67
+
68
+ - name: Wait for PyPI propagation
69
+ run: sleep 60
70
+
71
+ - name: Fetch PyPI sdist metadata
72
+ id: pypi
73
+ run: |
74
+ set -eu
75
+ VERSION="${GITHUB_REF_NAME#v}"
76
+ echo "Looking up codestrain==$VERSION on PyPI..."
77
+ # Retry up to 6 times (1 min total) in case the metadata
78
+ # endpoint lags behind the file CDN.
79
+ for i in 1 2 3 4 5 6; do
80
+ BODY=$(curl -fsS "https://pypi.org/pypi/codestrain/$VERSION/json" 2>/dev/null) && break
81
+ echo " attempt $i failed; sleeping 10s"
82
+ sleep 10
83
+ done
84
+ if [ -z "${BODY:-}" ]; then
85
+ echo "::error::PyPI metadata for codestrain==$VERSION never appeared"
86
+ exit 1
87
+ fi
88
+ URL=$(echo "$BODY" | jq -r '.urls[] | select(.packagetype=="sdist") | .url')
89
+ SHA=$(echo "$BODY" | jq -r '.urls[] | select(.packagetype=="sdist") | .digests.sha256')
90
+ echo " url=$URL"
91
+ echo " sha=$SHA"
92
+ {
93
+ echo "version=$VERSION"
94
+ echo "url=$URL"
95
+ echo "sha=$SHA"
96
+ } >> "$GITHUB_OUTPUT"
97
+
98
+ - name: Update Formula in codestrain/homebrew-tap
99
+ env:
100
+ COMMITTER_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
101
+ VERSION: ${{ steps.pypi.outputs.version }}
102
+ URL: ${{ steps.pypi.outputs.url }}
103
+ SHA: ${{ steps.pypi.outputs.sha }}
104
+ run: |
105
+ set -eu
106
+ git clone --depth 1 \
107
+ "https://x-access-token:${COMMITTER_TOKEN}@github.com/codestrain/homebrew-tap.git" tap
108
+ cd tap
109
+ # sed has to use a delimiter that doesn't appear in URLs (|).
110
+ sed -i -E "s|^ url \".*\"| url \"${URL}\"|" Formula/codestrain.rb
111
+ sed -i -E "s|^ sha256 \".*\"| sha256 \"${SHA}\"|" Formula/codestrain.rb
112
+ echo "=== updated formula ==="
113
+ head -12 Formula/codestrain.rb
114
+ git config user.email "actions@github.com"
115
+ git config user.name "github-actions[bot]"
116
+ git add Formula/codestrain.rb
117
+ if git diff --cached --quiet; then
118
+ echo "no formula change — skipping commit"
119
+ exit 0
120
+ fi
121
+ git commit -m "codestrain ${VERSION}
122
+
123
+ Auto-bumped from PyPI sdist for tag ${GITHUB_REF_NAME}.
124
+ Source: https://github.com/codestrain/codestrain-cli/releases/tag/${GITHUB_REF_NAME}"
125
+ git push
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codestrain
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Your AI coding recovery score, from the terminal.
5
5
  Project-URL: Homepage, https://codestrain.dev
6
6
  Project-URL: Repository, https://github.com/codestrain/codestrain-cli
@@ -707,6 +707,49 @@ def print_project_breakdown(project_stats, anonymize=False):
707
707
  print()
708
708
 
709
709
 
710
+ # ── Share encoding ───────────────────────────────────────────────────────────
711
+ #
712
+ # `codestrain --share` produces a single self-contained URL that anyone can
713
+ # open to see the same anonymized report. No server, no upload, no cookies —
714
+ # the whole report is gzip+base64-encoded into the URL query string. Decoded
715
+ # client-side by codestrain.dev/s/index.html.
716
+
717
+ SHARE_BASE_URL = "https://codestrain.dev/s/"
718
+
719
+
720
+ def build_share_url(text: str, base: str = SHARE_BASE_URL) -> str:
721
+ """Encode `text` into a self-contained share URL.
722
+
723
+ Pipeline: utf-8 → gzip (level 9, deterministic) → base64-urlsafe (strip `=`
724
+ padding). The frontend reverses this with DecompressionStream('gzip').
725
+ Average codestrain --all output (~1.5 KB raw) compresses to ~600 B → ~800 B
726
+ base64 → final URL ~ 850 B. Well within all known browser URL limits
727
+ (Chrome / Safari / Firefox / curl all accept up to ~32 KB).
728
+ """
729
+ import base64
730
+ import gzip
731
+ payload = gzip.compress(text.encode("utf-8"), compresslevel=9, mtime=0)
732
+ encoded = base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
733
+ return f"{base.rstrip('/')}/?d={encoded}"
734
+
735
+
736
+ def _copy_to_clipboard(text: str) -> bool:
737
+ """Best-effort clipboard copy. Returns True on success, False if no
738
+ suitable system command is available. Never raises.
739
+ """
740
+ import shutil
741
+ import subprocess
742
+ for cmd in (["pbcopy"], ["xclip", "-selection", "clipboard"], ["wl-copy"]):
743
+ if shutil.which(cmd[0]):
744
+ try:
745
+ p = subprocess.run(cmd, input=text.encode("utf-8"), timeout=2)
746
+ if p.returncode == 0:
747
+ return True
748
+ except (OSError, subprocess.TimeoutExpired):
749
+ continue
750
+ return False
751
+
752
+
710
753
  # ── Main ─────────────────────────────────────────────────────────────────────
711
754
 
712
755
  def main():
@@ -766,9 +809,21 @@ examples:
766
809
  help="Logo variant: auto (default, adapts to terminal width), "
767
810
  "big (5-line ASCII), small (one-line), or none (skip logo)",
768
811
  )
812
+ parser.add_argument(
813
+ "--share",
814
+ action="store_true",
815
+ help="Generate a shareable codestrain.dev URL with anonymized stats "
816
+ "(implies --anonymize --no-color; nothing is uploaded — all data "
817
+ "is encoded in the URL itself)",
818
+ )
769
819
 
770
820
  args = parser.parse_args()
771
821
 
822
+ # --share is a shortcut that anonymizes, strips color, then prints a URL.
823
+ if args.share:
824
+ args.anonymize = True
825
+ args.no_color = True
826
+
772
827
  if args.no_color:
773
828
  global _colors_on
774
829
  _colors_on = False
@@ -852,23 +907,43 @@ examples:
852
907
  project_stats[project_name] = []
853
908
  project_stats[project_name].append(stats)
854
909
 
855
- # Display results
856
- print_header_adaptive(args.logo)
857
-
858
- time_label = "Today" if not args.all else "All Time"
859
- if args.project and not args.anonymize:
860
- time_label += f" (project: {args.project})"
861
- elif args.project and args.anonymize:
862
- time_label += " (filtered)"
863
-
864
- print_divider(time_label)
865
- print()
866
- print_session_summary(all_stats)
867
-
868
- if len(project_stats) > 1 and not args.no_breakdown:
869
- print_project_breakdown(project_stats, anonymize=args.anonymize)
910
+ # Display results — capture stdout into a buffer if --share, so we can
911
+ # encode the rendered report into a URL after the run.
912
+ import contextlib
913
+ import io as _io
914
+
915
+ def _render_to(stream):
916
+ with contextlib.redirect_stdout(stream):
917
+ print_header_adaptive(args.logo)
918
+ time_label = "Today" if not args.all else "All Time"
919
+ if args.project and not args.anonymize:
920
+ time_label += f" (project: {args.project})"
921
+ elif args.project and args.anonymize:
922
+ time_label += " (filtered)"
923
+ print_divider(time_label)
924
+ print()
925
+ print_session_summary(all_stats)
926
+ if len(project_stats) > 1 and not args.no_breakdown:
927
+ print_project_breakdown(project_stats, anonymize=args.anonymize)
928
+ print()
870
929
 
871
- print()
930
+ if args.share:
931
+ buf = _io.StringIO()
932
+ _render_to(buf)
933
+ report_text = buf.getvalue()
934
+ # Print the report to stdout so the user sees what they're sharing
935
+ sys.stdout.write(report_text)
936
+ share_url = build_share_url(report_text)
937
+ print()
938
+ print(f" Shareable URL ({len(share_url)} chars):")
939
+ print(f" {share_url}")
940
+ print()
941
+ # Best-effort clipboard copy on macOS / Linux
942
+ if _copy_to_clipboard(share_url):
943
+ print(" (copied to clipboard)")
944
+ print()
945
+ else:
946
+ _render_to(sys.stdout)
872
947
 
873
948
 
874
949
  if __name__ == "__main__":
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codestrain"
3
- version = "0.1.2"
3
+ version = "0.1.4"
4
4
  description = "Your AI coding recovery score, from the terminal."
5
5
  requires-python = ">=3.9"
6
6
  readme = "README.md"
@@ -107,6 +107,13 @@ out=$("$PY" "$CLI" --path "$FIXTURES" --all --project projectA --no-color 2>&1)
107
107
  # format_tokens compacts to "3.8K"-style — assert the K
108
108
  contains "$out" "3.8K" "expected ~3800 input tokens for projectA (compact = 3.8K)"
109
109
 
110
+ # 9. --share emits a codestrain.dev/s/ URL and implies --anonymize.
111
+ out=$("$PY" "$CLI" --path "$FIXTURES" --all --share 2>&1)
112
+ contains "$out" "https://codestrain.dev/s/?d=" "--share prints shareable URL"
113
+ contains "$out" "project-1" "--share implies --anonymize (project-1)"
114
+ not_contains "$out" "projectA" "--share scrubs real project names"
115
+ not_contains "$out" "projectB" "--share scrubs real project names"
116
+
110
117
  # ----------------------------------------------------------------------------
111
118
  # summary
112
119
  # ----------------------------------------------------------------------------
@@ -145,3 +145,54 @@ def test_format_cost_dollars(cli, cost, expected_prefix):
145
145
  def test_format_tokens_compact(cli, n, expected_substr):
146
146
  out = cli.format_tokens(n)
147
147
  assert expected_substr in out
148
+
149
+
150
+ # ─── Share encoding ─────────────────────────────────────────────────────────
151
+
152
+ def test_share_url_round_trip(cli):
153
+ """gzip + base64-urlsafe encoder must round-trip a real report string."""
154
+ import base64
155
+ import gzip
156
+ sample = (
157
+ "--- All Time ----\n"
158
+ " Sessions: 1454\n"
159
+ " Duration: 137h 21m\n"
160
+ " Cost: $21948.61\n"
161
+ )
162
+ url = cli.build_share_url(sample)
163
+ assert url.startswith("https://codestrain.dev/s/?d=")
164
+ encoded = url.split("?d=", 1)[1]
165
+ # Restore stripped padding + base64-urlsafe → bytes → gunzip → text.
166
+ pad = "=" * (-len(encoded) % 4)
167
+ raw = base64.urlsafe_b64decode(encoded + pad)
168
+ decoded = gzip.decompress(raw).decode("utf-8")
169
+ assert decoded == sample
170
+
171
+
172
+ def test_share_url_unicode_safe(cli):
173
+ """Non-ASCII content (Cyrillic, emoji, currency) must survive the trip."""
174
+ import base64
175
+ import gzip
176
+ sample = "Сессий: 1454 · стоимость €18 950 / $21 948 · 🟢 recovery 82%"
177
+ url = cli.build_share_url(sample)
178
+ encoded = url.split("?d=", 1)[1]
179
+ pad = "=" * (-len(encoded) % 4)
180
+ raw = base64.urlsafe_b64decode(encoded + pad)
181
+ assert gzip.decompress(raw).decode("utf-8") == sample
182
+
183
+
184
+ def test_share_url_compact_for_typical_report(cli):
185
+ """A realistic 1.5 KB report should encode to under ~1 KB URL."""
186
+ # 30 lines × 50 chars = 1500 bytes (close to real --all output).
187
+ sample = "\n".join(f" line {i:02d}: project-{i} 12h 34m $123.45" for i in range(30))
188
+ url = cli.build_share_url(sample)
189
+ # Comfortably under 32 KB (the conservative browser URL limit).
190
+ assert len(url) < 2_000
191
+ # Should compress reasonably — the line template is highly repetitive.
192
+ assert len(url) < len(sample)
193
+
194
+
195
+ def test_share_url_deterministic(cli):
196
+ """Same input → same URL across calls (mtime=0 in gzip header)."""
197
+ text = "hello world\n"
198
+ assert cli.build_share_url(text) == cli.build_share_url(text)
@@ -1,80 +0,0 @@
1
- name: Release
2
-
3
- on:
4
- push:
5
- tags:
6
- - "v*"
7
-
8
- jobs:
9
- build:
10
- name: Build distributions
11
- runs-on: ubuntu-latest
12
- steps:
13
- - name: Checkout
14
- uses: actions/checkout@v4
15
-
16
- - name: Set up Python
17
- uses: actions/setup-python@v5
18
- with:
19
- python-version: "3.12"
20
-
21
- - name: Install build tooling
22
- run: python -m pip install --upgrade build
23
-
24
- - name: Build sdist and wheel
25
- working-directory: .
26
- run: python -m build
27
-
28
- - name: Upload distributions
29
- uses: actions/upload-artifact@v4
30
- with:
31
- name: dist
32
- path: dist/
33
-
34
- publish:
35
- name: Publish to PyPI
36
- needs: build
37
- runs-on: ubuntu-latest
38
- environment: pypi
39
- permissions:
40
- id-token: write
41
- steps:
42
- - name: Download distributions
43
- uses: actions/download-artifact@v4
44
- with:
45
- name: dist
46
- path: dist/
47
-
48
- - name: Publish to PyPI via Trusted Publisher
49
- uses: pypa/gh-action-pypi-publish@release/v1
50
-
51
- bump-homebrew:
52
- name: Bump Homebrew Formula
53
- needs: publish
54
- runs-on: ubuntu-latest
55
- if: ${{ !contains(github.ref_name, 'rc') && !contains(github.ref_name, 'a') && !contains(github.ref_name, 'b') }}
56
- steps:
57
- # Wait briefly so the new sdist is queryable on PyPI's CDN.
58
- - name: Wait for PyPI propagation
59
- run: sleep 30
60
-
61
- - name: Open PR against codestrain/homebrew-tap
62
- uses: mislav/bump-homebrew-formula-action@v3
63
- with:
64
- formula-name: codestrain
65
- formula-path: Formula/codestrain.rb
66
- homebrew-tap: codestrain/homebrew-tap
67
- base-branch: main
68
- # Compute download URL from the just-published PyPI sdist.
69
- # mislav/bump-homebrew-formula-action auto-strips `v` prefix from
70
- # github.ref_name, so v0.1.2 → 0.1.2 in the URL.
71
- download-url: https://files.pythonhosted.org/packages/source/c/codestrain/codestrain-${{ github.ref_name }}.tar.gz
72
- commit-message: |
73
- codestrain {{version}}
74
-
75
- Auto-bumped by mislav/bump-homebrew-formula-action.
76
- Source: https://github.com/codestrain/codestrain-cli/releases/tag/${{ github.ref_name }}
77
- env:
78
- # Fine-grained PAT with `Contents: write` on codestrain/homebrew-tap.
79
- # Stored as a repo secret in codestrain/codestrain-cli.
80
- COMMITTER_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes