Skip to content

Commit 6aa35fd

Browse files
GokhanKabarclaude
andcommitted
feat: audit fixes — shared MODEL_PRICING, cache FNV hash, stats --json, cache subcommand, precompiled Homebrew formula
- src/lib.rs: add shared MODEL_PRICING constant (dedup across stats/token_cost/mcp) - src/cache.rs: replace DefaultHasher with stable FNV-1a hash, add size_bytes/entry_count/clear - src/cli.rs: add Stats{json} flag and Cache{Stats,Clear} subcommand - src/main.rs: wire cache/stats subcommands, evict_old(30) on startup - src/stats.rs: add run_json(), use MODEL_PRICING from lib - src/mcp.rs: use MODEL_PRICING from lib - src/token_cost.rs: use MODEL_PRICING from lib - src/input.rs: add missing extensions (zsh, fish, cs, php, phtml) - Formula/tersify.rb: migrate to precompiled binaries (darwin/linux arm/intel) - .github/workflows/release.yml: add homebrew job to patch tap formula on release Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f771e5b commit 6aa35fd

10 files changed

Lines changed: 198 additions & 110 deletions

File tree

.github/workflows/release.yml

Lines changed: 40 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -149,65 +149,49 @@ jobs:
149149
name: Update Homebrew tap
150150
needs: release
151151
runs-on: ubuntu-latest
152-
# Only run if the homebrew-tap repo exists (set secret HOMEBREW_TAP_TOKEN)
153-
if: ${{ vars.HOMEBREW_TAP_ENABLED == 'true' }}
152+
154153
steps:
155154
- uses: actions/checkout@v4
156155

157-
- name: Compute source tarball SHA256
156+
- name: Compute SHA256 for each binary
158157
id: sha
158+
shell: bash
159159
run: |
160-
TAG=${{ github.ref_name }}
161-
URL="https://github.com/rustkit-ai/tersify/archive/refs/tags/${TAG}.tar.gz"
162-
SHA=$(curl -fsSL "$URL" | sha256sum | cut -d' ' -f1)
163-
echo "sha256=$SHA" >> "$GITHUB_OUTPUT"
164-
echo "url=$URL" >> "$GITHUB_OUTPUT"
165-
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
166-
167-
- name: Update formula in homebrew-tap
168-
uses: actions/github-script@v7
160+
VERSION="${GITHUB_REF_NAME#v}"
161+
BASE="https://github.com/rustkit-ai/tersify/releases/download/${GITHUB_REF_NAME}"
162+
163+
fetch_sha() {
164+
curl -fsSL "$1.sha256" | awk '{print $1}'
165+
}
166+
167+
echo "version=$VERSION" >> $GITHUB_OUTPUT
168+
echo "aarch64_darwin=$(fetch_sha $BASE/tersify-aarch64-apple-darwin.tar.gz)" >> $GITHUB_OUTPUT
169+
echo "x86_64_darwin=$(fetch_sha $BASE/tersify-x86_64-apple-darwin.tar.gz)" >> $GITHUB_OUTPUT
170+
echo "aarch64_linux=$(fetch_sha $BASE/tersify-aarch64-unknown-linux-musl.tar.gz)" >> $GITHUB_OUTPUT
171+
echo "x86_64_linux=$(fetch_sha $BASE/tersify-x86_64-unknown-linux-musl.tar.gz)" >> $GITHUB_OUTPUT
172+
173+
- name: Checkout tap repo
174+
uses: actions/checkout@v4
169175
with:
170-
github-token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
171-
script: |
172-
const { sha256, url, version } = ${{ toJson(steps.sha.outputs) }};
173-
const formula = `class Tersify < Formula
174-
desc "Universal LLM context compressor — pipe anything, get token-optimized output"
175-
homepage "https://github.com/rustkit-ai/tersify"
176-
url "${url}"
177-
sha256 "${sha256}"
178-
license "MIT"
179-
180-
depends_on "rust" => :build
181-
182-
def install
183-
system "cargo", "install", *std_cargo_args
184-
end
185-
186-
def post_install
187-
system "\#{bin}/tersify", "install", "--all"
188-
rescue StandardError
189-
nil
190-
end
191-
192-
test do
193-
assert_match version.to_s, shell_output("\#{bin}/tersify --version")
194-
(testpath/"test.rs").write("// comment\\nfn main() {}\\n")
195-
output = shell_output("\#{bin}/tersify \#{testpath}/test.rs")
196-
refute_match "// comment", output
197-
end
198-
end
199-
`;
200-
const owner = 'rustkit-ai';
201-
const repo = 'homebrew-tap';
202-
const path = 'Formula/tersify.rb';
203-
let sha_file;
204-
try {
205-
const { data } = await github.rest.repos.getContent({ owner, repo, path });
206-
sha_file = data.sha;
207-
} catch {}
208-
await github.rest.repos.createOrUpdateFileContents({
209-
owner, repo, path,
210-
message: `tersify ${version}`,
211-
content: Buffer.from(formula).toString('base64'),
212-
sha: sha_file,
213-
});
176+
repository: rustkit-ai/homebrew-tap
177+
token: ${{ secrets.TAP_GITHUB_TOKEN }}
178+
path: tap
179+
180+
- name: Update formula
181+
shell: bash
182+
run: |
183+
F="tap/Formula/tersify.rb"
184+
sed -i "s/version \".*\"/version \"${{ steps.sha.outputs.version }}\"/" "$F"
185+
sed -i "s/PLACEHOLDER_AARCH64_DARWIN/${{ steps.sha.outputs.aarch64_darwin }}/" "$F"
186+
sed -i "s/PLACEHOLDER_X86_64_DARWIN/${{ steps.sha.outputs.x86_64_darwin }}/" "$F"
187+
sed -i "s/PLACEHOLDER_AARCH64_LINUX/${{ steps.sha.outputs.aarch64_linux }}/" "$F"
188+
sed -i "s/PLACEHOLDER_X86_64_LINUX/${{ steps.sha.outputs.x86_64_linux }}/" "$F"
189+
190+
- name: Commit + push to tap
191+
working-directory: tap
192+
run: |
193+
git config user.name "github-actions[bot]"
194+
git config user.email "github-actions[bot]@users.noreply.github.com"
195+
git add Formula/tersify.rb
196+
git commit -m "tersify ${{ steps.sha.outputs.version }}"
197+
git push

Formula/tersify.rb

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,45 @@
1+
# This file lives in the main repo for reference.
2+
# The canonical formula is maintained in rustkit-ai/homebrew-tap.
3+
#
4+
# To install:
5+
# brew tap rustkit-ai/tap
6+
# brew install tersify
7+
#
8+
# SHA256 values are updated automatically by .github/workflows/release.yml
9+
# on each release.
10+
111
class Tersify < Formula
212
desc "Universal LLM context compressor — pipe anything, get token-optimized output"
313
homepage "https://github.com/rustkit-ai/tersify"
4-
url "https://github.com/rustkit-ai/tersify/archive/refs/tags/v0.3.3.tar.gz"
5-
sha256 "0fc51141572dd7439284cd5a6089922b510eedb04596b7bc63ef7d4281a478f4"
14+
version "PLACEHOLDER_VERSION"
615
license "MIT"
7-
head "https://github.com/rustkit-ai/tersify.git", branch: "main"
816

9-
depends_on "rust" => :build
17+
on_macos do
18+
on_arm do
19+
url "https://github.com/rustkit-ai/tersify/releases/download/v#{version}/tersify-aarch64-apple-darwin.tar.gz"
20+
sha256 "PLACEHOLDER_AARCH64_DARWIN"
21+
end
22+
23+
on_intel do
24+
url "https://github.com/rustkit-ai/tersify/releases/download/v#{version}/tersify-x86_64-apple-darwin.tar.gz"
25+
sha256 "PLACEHOLDER_X86_64_DARWIN"
26+
end
27+
end
28+
29+
on_linux do
30+
on_arm do
31+
url "https://github.com/rustkit-ai/tersify/releases/download/v#{version}/tersify-aarch64-unknown-linux-musl.tar.gz"
32+
sha256 "PLACEHOLDER_AARCH64_LINUX"
33+
end
34+
35+
on_intel do
36+
url "https://github.com/rustkit-ai/tersify/releases/download/v#{version}/tersify-x86_64-unknown-linux-musl.tar.gz"
37+
sha256 "PLACEHOLDER_X86_64_LINUX"
38+
end
39+
end
1040

1141
def install
12-
system "cargo", "install", *std_cargo_args
42+
bin.install "tersify"
1343
end
1444

1545
def post_install
@@ -20,7 +50,6 @@ def post_install
2050

2151
test do
2252
assert_match version.to_s, shell_output("#{bin}/tersify --version")
23-
# Compression smoke test
2453
(testpath/"test.rs").write("// comment\nfn main() {\n println!(\"hello\");\n}\n")
2554
output = shell_output("#{bin}/tersify #{testpath}/test.rs")
2655
assert_match "fn main()", output

src/cache.rs

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@
99
//! is merely a wrong cached result — the next run on changed content will
1010
//! produce a fresh entry.
1111
12-
use std::collections::hash_map::DefaultHasher;
13-
use std::hash::{Hash, Hasher};
1412
use std::path::PathBuf;
1513

1614
fn cache_dir() -> PathBuf {
@@ -20,11 +18,21 @@ fn cache_dir() -> PathBuf {
2018
PathBuf::from(home).join(".tersify").join("cache")
2119
}
2220

21+
/// Stable FNV-1a 64-bit hash — deterministic across all Rust versions and platforms.
22+
/// Unlike `DefaultHasher`, this produces the same value for the same input always.
23+
fn fnv64(data: &[u8]) -> u64 {
24+
let mut h: u64 = 0xcbf29ce484222325;
25+
for &b in data {
26+
h ^= b as u64;
27+
h = h.wrapping_mul(0x100000001b3);
28+
}
29+
h
30+
}
31+
2332
fn cache_key(content: &str, opts: u8) -> String {
24-
let mut h = DefaultHasher::new();
25-
content.hash(&mut h);
26-
opts.hash(&mut h);
27-
format!("{:016x}", h.finish())
33+
let content_hash = fnv64(content.as_bytes());
34+
let opts_hash = fnv64(&[opts]);
35+
format!("{:016x}{:016x}", content_hash, opts_hash)
2836
}
2937

3038
/// Retrieve a previously cached compression result.
@@ -42,6 +50,39 @@ pub fn set(content: &str, opts: u8, compressed: &str) {
4250
let _ = std::fs::write(dir.join(cache_key(content, opts)), compressed);
4351
}
4452

53+
/// Return the total size in bytes of all cache entries.
54+
pub fn size_bytes() -> u64 {
55+
let dir = cache_dir();
56+
let Ok(entries) = std::fs::read_dir(&dir) else {
57+
return 0;
58+
};
59+
entries
60+
.flatten()
61+
.filter_map(|e| e.metadata().ok())
62+
.map(|m| m.len())
63+
.sum()
64+
}
65+
66+
/// Return the number of cached entries.
67+
pub fn entry_count() -> usize {
68+
let dir = cache_dir();
69+
let Ok(entries) = std::fs::read_dir(&dir) else {
70+
return 0;
71+
};
72+
entries.flatten().count()
73+
}
74+
75+
/// Delete all cache entries.
76+
pub fn clear() {
77+
let dir = cache_dir();
78+
let Ok(entries) = std::fs::read_dir(&dir) else {
79+
return;
80+
};
81+
for entry in entries.flatten() {
82+
let _ = std::fs::remove_file(entry.path());
83+
}
84+
}
85+
4586
/// Evict all cached entries older than `max_age_days` days.
4687
pub fn evict_old(max_age_days: u64) {
4788
let dir = cache_dir();

src/cli.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,18 @@ pub enum Command {
7878
all: bool,
7979
},
8080
/// Show token savings statistics
81-
Stats,
81+
Stats {
82+
/// Output as JSON instead of a formatted table
83+
#[arg(long)]
84+
json: bool,
85+
},
8286
/// Reset saved statistics
8387
StatsReset,
88+
/// Manage the compression cache (~/.tersify/cache/)
89+
Cache {
90+
#[command(subcommand)]
91+
command: CacheCommand,
92+
},
8493
/// Benchmark compression savings across all content types
8594
Bench,
8695
/// Estimate LLM API cost before and after compression
@@ -106,3 +115,11 @@ pub enum Command {
106115
#[command(hide = true, name = "hook")]
107116
HookRun,
108117
}
118+
119+
#[derive(Subcommand)]
120+
pub enum CacheCommand {
121+
/// Show cache size and entry count
122+
Stats,
123+
/// Delete all cache entries
124+
Clear,
125+
}

src/input.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,10 @@ const INCLUDE_EXT: &[&str] = &[
3737
"swift", // Kotlin
3838
"kt", "kts", // Web
3939
"html", "htm", "css", // SQL
40-
"sql", // Shell
41-
"sh", "bash", // Data / config
40+
"sql", // C#
41+
"cs", // PHP
42+
"php", "phtml", // Shell
43+
"sh", "bash", "zsh", "fish", // Data / config
4244
"json", "jsonc", "yaml", "yml", "toml", // Logs / diffs
4345
"log", "diff", "patch", // Docs
4446
"md", "txt",

src/lib.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,17 @@ pub mod detect;
4242
pub mod error;
4343
pub mod input;
4444
pub mod tokens;
45+
46+
/// LLM pricing table: (model name, provider, input $/M tokens) — early 2026.
47+
pub const MODEL_PRICING: &[(&str, &str, f64)] = &[
48+
("claude-opus-4.6", "Anthropic", 15.0),
49+
("claude-sonnet-4.6", "Anthropic", 3.0),
50+
("claude-haiku-4.5", "Anthropic", 0.80),
51+
("gpt-5.4", "OpenAI", 5.0),
52+
("gpt-4o-mini", "OpenAI", 0.15),
53+
("o1", "OpenAI", 15.0),
54+
("o3-mini", "OpenAI", 1.10),
55+
("gemini-2.5-pro", "Google", 1.25),
56+
("gemini-2.5-flash", "Google", 0.15),
57+
("deepseek-v3", "DeepSeek", 0.27),
58+
];

src/main.rs

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ mod token_cost;
1010

1111
use anyhow::Result;
1212
use clap::Parser;
13-
use cli::{Cli, Command};
13+
use cli::{CacheCommand, Cli, Command};
1414
use install::Target;
1515
use is_terminal::IsTerminal;
1616
use std::{
@@ -52,8 +52,29 @@ fn main() -> Result<()> {
5252
install::uninstall_with_opts(target)?;
5353
}
5454
}
55-
Some(Command::Stats) => stats::run()?,
55+
Some(Command::Stats { json }) => {
56+
if json {
57+
stats::run_json()?;
58+
} else {
59+
stats::run()?;
60+
}
61+
}
5662
Some(Command::StatsReset) => stats::reset()?,
63+
Some(Command::Cache { command }) => match command {
64+
CacheCommand::Stats => {
65+
let entries = tersify::cache::entry_count();
66+
let bytes = tersify::cache::size_bytes();
67+
println!(" Cache entries : {entries}");
68+
println!(
69+
" Cache size : {:.2} MB (~/.tersify/cache/)",
70+
bytes as f64 / 1_048_576.0
71+
);
72+
}
73+
CacheCommand::Clear => {
74+
tersify::cache::clear();
75+
println!("✓ Cache cleared.");
76+
}
77+
},
5778
Some(Command::Bench) => bench::run()?,
5879
Some(Command::TokenCost {
5980
inputs,
@@ -84,6 +105,9 @@ fn resolve_target(cursor: bool, windsurf: bool, copilot: bool) -> Target {
84105
}
85106

86107
fn run_compress(cli: Cli, cfg: &config::Config) -> Result<()> {
108+
// Evict cache entries not accessed in the last 30 days (best-effort, non-fatal).
109+
tersify::cache::evict_old(30);
110+
87111
if cli.inputs.is_empty() {
88112
if io::stdin().is_terminal() {
89113
eprintln!(

src/mcp.rs

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -304,20 +304,9 @@ fn call_estimate_cost(id: Option<Value>, args: &Value) -> Value {
304304
let after = tokens::count(&compressed);
305305
let saved_pct = tokens::savings_pct(before, after);
306306

307-
const MODELS: &[(&str, &str, f64)] = &[
308-
("claude-opus-4.6", "Anthropic", 15.0),
309-
("claude-sonnet-4.6", "Anthropic", 3.0),
310-
("claude-haiku-4.5", "Anthropic", 0.80),
311-
("gpt-4o", "OpenAI", 5.0),
312-
("gpt-4o-mini", "OpenAI", 0.15),
313-
("o1", "OpenAI", 15.0),
314-
("o3-mini", "OpenAI", 1.10),
315-
("gemini-2.5-pro", "Google", 1.25),
316-
("gemini-2.5-flash", "Google", 0.15),
317-
("deepseek-v3", "DeepSeek", 0.27),
318-
];
307+
let models_table = tersify::MODEL_PRICING;
319308

320-
let models: Vec<_> = MODELS
309+
let models: Vec<_> = models_table
321310
.iter()
322311
.filter(|(name, _, _)| {
323312
model_filter

0 commit comments

Comments
 (0)