@obedience-corp/tcount 0.3.0
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.
- package/README.md +34 -0
- package/bin/tcount +8 -0
- package/install.js +257 -0
- package/lib/run.js +27 -0
- package/package.json +48 -0
package/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# @obedience-corp/tcount
|
|
2
|
+
|
|
3
|
+
npm distribution wrapper for the `tcount` CLI — a fast, zero-network token counter for LLM workflows.
|
|
4
|
+
|
|
5
|
+
This package provides the `tcount` command via npm / pnpm / bun.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g @obedience-corp/tcount
|
|
11
|
+
# or
|
|
12
|
+
pnpm add -g @obedience-corp/tcount
|
|
13
|
+
# or
|
|
14
|
+
bun add -g @obedience-corp/tcount
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
After installation, the `tcount` command will be available in your PATH.
|
|
18
|
+
|
|
19
|
+
## How it works
|
|
20
|
+
|
|
21
|
+
The package is a lightweight wrapper. On first `postinstall` (or when the binary is missing), it downloads the matching platform-specific release archive from the [tcount GitHub releases](https://github.com/lancekrogers/tcount/releases) and verifies the checksum.
|
|
22
|
+
|
|
23
|
+
This keeps the npm package small while ensuring you always get the official, signed-off binaries produced by the Go release process.
|
|
24
|
+
|
|
25
|
+
## Supported Platforms
|
|
26
|
+
|
|
27
|
+
- macOS (Intel + Apple Silicon)
|
|
28
|
+
- Linux (x64 + arm64)
|
|
29
|
+
|
|
30
|
+
Windows users: use `go install github.com/lancekrogers/tcount/cmd/tcount@latest` or download the `.zip` from the releases page.
|
|
31
|
+
|
|
32
|
+
## License
|
|
33
|
+
|
|
34
|
+
MIT
|
package/bin/tcount
ADDED
package/install.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const https = require("https");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
const { spawnSync } = require("child_process");
|
|
8
|
+
|
|
9
|
+
const REPO = "lancekrogers/tcount";
|
|
10
|
+
const BINARY = "tcount";
|
|
11
|
+
const DOWNLOAD_ATTEMPTS = 3;
|
|
12
|
+
const DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
13
|
+
const RETRY_BASE_DELAY_MS = 750;
|
|
14
|
+
|
|
15
|
+
const PLATFORM_MAP = {
|
|
16
|
+
darwin: "darwin",
|
|
17
|
+
linux: "linux",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const ARCH_MAP = {
|
|
21
|
+
x64: "amd64",
|
|
22
|
+
arm64: "arm64",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function packageVersion() {
|
|
26
|
+
const packageJSON = require("./package.json");
|
|
27
|
+
return packageJSON.version;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function releaseTag(version = packageVersion()) {
|
|
31
|
+
return `v${version}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function targetForCurrentPlatform() {
|
|
35
|
+
const platform = PLATFORM_MAP[process.platform];
|
|
36
|
+
const arch = ARCH_MAP[process.arch];
|
|
37
|
+
|
|
38
|
+
if (!platform || !arch) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`Unsupported platform: ${process.platform}/${process.arch}. @obedience-corp/tcount currently supports macOS and Linux on x64/arm64. Use 'go install' or download binaries from https://github.com/lancekrogers/tcount/releases for other platforms.`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return `${platform}_${arch}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function archiveName(version = packageVersion()) {
|
|
48
|
+
return `tcount_${version}_${targetForCurrentPlatform()}.tar.gz`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function releaseURL(asset, version = packageVersion()) {
|
|
52
|
+
return `https://github.com/${REPO}/releases/download/${releaseTag(version)}/${asset}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function binaryPath() {
|
|
56
|
+
// We store the real binary as tcount-bin (unix) so the small launcher in bin/tcount can find it.
|
|
57
|
+
return path.join(__dirname, "bin", "tcount-bin");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function haveBinary() {
|
|
61
|
+
return fs.existsSync(binaryPath());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sleep(ms) {
|
|
65
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function downloadOnce(url, dest) {
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
let settled = false;
|
|
71
|
+
|
|
72
|
+
const fail = (err) => {
|
|
73
|
+
if (settled) return;
|
|
74
|
+
settled = true;
|
|
75
|
+
fs.rmSync(dest, { force: true });
|
|
76
|
+
reject(err);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const done = () => {
|
|
80
|
+
if (settled) return;
|
|
81
|
+
settled = true;
|
|
82
|
+
resolve();
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const follow = (nextURL, redirects = 0) => {
|
|
86
|
+
if (redirects > 10) {
|
|
87
|
+
fail(new Error("too many redirects"));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const request = https
|
|
92
|
+
.get(nextURL, (response) => {
|
|
93
|
+
if (
|
|
94
|
+
response.statusCode >= 300 &&
|
|
95
|
+
response.statusCode < 400 &&
|
|
96
|
+
response.headers.location
|
|
97
|
+
) {
|
|
98
|
+
response.resume();
|
|
99
|
+
follow(response.headers.location, redirects + 1);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (response.statusCode !== 200) {
|
|
104
|
+
response.resume();
|
|
105
|
+
fail(new Error(`failed to download ${url}: HTTP ${response.statusCode}`));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const file = fs.createWriteStream(dest);
|
|
110
|
+
response.pipe(file);
|
|
111
|
+
file.on("finish", () => {
|
|
112
|
+
file.close(done);
|
|
113
|
+
});
|
|
114
|
+
file.on("error", fail);
|
|
115
|
+
})
|
|
116
|
+
.on("error", fail);
|
|
117
|
+
|
|
118
|
+
request.setTimeout(DOWNLOAD_TIMEOUT_MS, () => {
|
|
119
|
+
request.destroy(new Error(`download timeout after ${DOWNLOAD_TIMEOUT_MS / 1000}s`));
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
follow(url);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function download(url, dest, attempts = DOWNLOAD_ATTEMPTS) {
|
|
128
|
+
let lastErr;
|
|
129
|
+
|
|
130
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
131
|
+
try {
|
|
132
|
+
await downloadOnce(url, dest);
|
|
133
|
+
return;
|
|
134
|
+
} catch (err) {
|
|
135
|
+
lastErr = err;
|
|
136
|
+
|
|
137
|
+
if (attempt === attempts) {
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const delay = RETRY_BASE_DELAY_MS * 2 ** (attempt - 1);
|
|
142
|
+
console.warn(
|
|
143
|
+
`Download failed (${attempt}/${attempts}) for ${path.basename(dest)}: ${err.message}. Retrying in ${delay}ms...`,
|
|
144
|
+
);
|
|
145
|
+
await sleep(delay);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
throw lastErr;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function sha256(filePath) {
|
|
153
|
+
const hash = crypto.createHash("sha256");
|
|
154
|
+
hash.update(fs.readFileSync(filePath));
|
|
155
|
+
return hash.digest("hex");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function expectedChecksum(checksumsPath, filename) {
|
|
159
|
+
const checksums = fs.readFileSync(checksumsPath, "utf8").split(/\r?\n/);
|
|
160
|
+
|
|
161
|
+
for (const line of checksums) {
|
|
162
|
+
const parts = line.trim().split(/\s+/);
|
|
163
|
+
if (parts.length >= 2 && parts[1] === filename) {
|
|
164
|
+
return parts[0];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
throw new Error(`checksum for ${filename} not found in checksums.txt`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function verifyChecksum(archivePath, checksumsPath, filename) {
|
|
172
|
+
const expected = expectedChecksum(checksumsPath, filename);
|
|
173
|
+
const actual = sha256(archivePath);
|
|
174
|
+
|
|
175
|
+
if (actual !== expected) {
|
|
176
|
+
throw new Error(`checksum mismatch for ${filename}: expected ${expected}, got ${actual}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function extractArchive(archivePath, destDir) {
|
|
181
|
+
const result = spawnSync("tar", ["-xzf", archivePath, "-C", destDir], {
|
|
182
|
+
stdio: "inherit",
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
if (result.error) {
|
|
186
|
+
throw result.error;
|
|
187
|
+
}
|
|
188
|
+
if (result.status !== 0) {
|
|
189
|
+
throw new Error(`tar exited with status ${result.status}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function install(options = {}) {
|
|
194
|
+
const force = options.force === true;
|
|
195
|
+
|
|
196
|
+
if (!force && haveBinary()) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const version = packageVersion();
|
|
201
|
+
const filename = archiveName(version);
|
|
202
|
+
const binDir = path.join(__dirname, "bin");
|
|
203
|
+
const tempDir = path.join(__dirname, ".tmp-extract");
|
|
204
|
+
const archivePath = path.join(__dirname, filename);
|
|
205
|
+
const checksumsPath = path.join(__dirname, "checksums.txt");
|
|
206
|
+
|
|
207
|
+
console.log(`Installing tcount ${version} (${targetForCurrentPlatform()})...`);
|
|
208
|
+
|
|
209
|
+
fs.mkdirSync(binDir, { recursive: true });
|
|
210
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
211
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
await download(releaseURL(filename, version), archivePath);
|
|
215
|
+
await download(releaseURL("checksums.txt", version), checksumsPath);
|
|
216
|
+
verifyChecksum(archivePath, checksumsPath, filename);
|
|
217
|
+
extractArchive(archivePath, tempDir);
|
|
218
|
+
|
|
219
|
+
const extractedPath = path.join(tempDir, BINARY);
|
|
220
|
+
const targetPath = binaryPath();
|
|
221
|
+
|
|
222
|
+
if (!fs.existsSync(extractedPath)) {
|
|
223
|
+
throw new Error(`${BINARY} not found in ${filename}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
fs.renameSync(extractedPath, targetPath);
|
|
227
|
+
if (process.platform !== "win32") {
|
|
228
|
+
fs.chmodSync(targetPath, 0o755);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
console.log("Installed tcount successfully");
|
|
232
|
+
} finally {
|
|
233
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
234
|
+
fs.rmSync(archivePath, { force: true });
|
|
235
|
+
fs.rmSync(checksumsPath, { force: true });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function main() {
|
|
240
|
+
try {
|
|
241
|
+
await install({ force: false });
|
|
242
|
+
} catch (err) {
|
|
243
|
+
console.error(`Failed to install tcount: ${err.message}`);
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (require.main === module) {
|
|
249
|
+
main();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
module.exports = {
|
|
253
|
+
archiveName,
|
|
254
|
+
binaryPath,
|
|
255
|
+
install,
|
|
256
|
+
targetForCurrentPlatform,
|
|
257
|
+
};
|
package/lib/run.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const { spawnSync } = require("child_process");
|
|
3
|
+
|
|
4
|
+
const { binaryPath, install } = require("../install");
|
|
5
|
+
|
|
6
|
+
async function runBinary(name) {
|
|
7
|
+
const target = binaryPath(name);
|
|
8
|
+
|
|
9
|
+
if (!fs.existsSync(target)) {
|
|
10
|
+
await install();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const result = spawnSync(target, process.argv.slice(2), {
|
|
14
|
+
stdio: "inherit",
|
|
15
|
+
shell: false,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
if (result.error) {
|
|
19
|
+
throw result.error;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
process.exit(result.status ?? 1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = {
|
|
26
|
+
runBinary,
|
|
27
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@obedience-corp/tcount",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Fast, zero-network token counter for LLM workflows (tcount CLI)",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/lancekrogers/tcount.git"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/lancekrogers/tcount",
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/lancekrogers/tcount/issues"
|
|
12
|
+
},
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"bin": {
|
|
15
|
+
"tcount": "bin/tcount"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"postinstall": "node install.js"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"README.md",
|
|
22
|
+
"bin",
|
|
23
|
+
"install.js",
|
|
24
|
+
"lib"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"os": [
|
|
30
|
+
"darwin",
|
|
31
|
+
"linux"
|
|
32
|
+
],
|
|
33
|
+
"cpu": [
|
|
34
|
+
"x64",
|
|
35
|
+
"arm64"
|
|
36
|
+
],
|
|
37
|
+
"keywords": [
|
|
38
|
+
"cli",
|
|
39
|
+
"llm",
|
|
40
|
+
"token",
|
|
41
|
+
"tokenizer",
|
|
42
|
+
"tiktoken",
|
|
43
|
+
"claude",
|
|
44
|
+
"llama",
|
|
45
|
+
"count",
|
|
46
|
+
"cost-estimate"
|
|
47
|
+
]
|
|
48
|
+
}
|