@rizzmo/tokochi-cli 0.1.1
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 +17 -0
- package/dist/api.d.ts +15 -0
- package/dist/api.js +40 -0
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +16 -0
- package/dist/collectors.d.ts +6 -0
- package/dist/collectors.js +181 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.js +22 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +144 -0
- package/dist/types.d.ts +18 -0
- package/dist/types.js +1 -0
- package/package.json +30 -0
package/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# @tokochi/cli
|
|
2
|
+
|
|
3
|
+
Connect local Codex, Claude Code, and GitHub Copilot token usage to your Tokochi.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx @tokochi/cli connect --url https://your-tokochi.app
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The browser handles GitHub authentication and device approval. No token copying is required.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx @tokochi/cli sync
|
|
13
|
+
npx @tokochi/cli status
|
|
14
|
+
npx @tokochi/cli disconnect
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Only event IDs, timestamps, agent/model names, and token counts are uploaded. Prompts and responses remain on the computer.
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { UsageEvent } from "./types.js";
|
|
2
|
+
export type DeviceStart = {
|
|
3
|
+
device_code: string;
|
|
4
|
+
user_code: string;
|
|
5
|
+
verification_uri: string;
|
|
6
|
+
verification_uri_complete: string;
|
|
7
|
+
expires_in: number;
|
|
8
|
+
interval: number;
|
|
9
|
+
};
|
|
10
|
+
export declare function startDeviceFlow(baseUrl: string): Promise<DeviceStart>;
|
|
11
|
+
export declare function pollForToken(baseUrl: string, deviceCode: string): Promise<string | null>;
|
|
12
|
+
export declare function uploadEvents(baseUrl: string, token: string, events: UsageEvent[]): Promise<{
|
|
13
|
+
accepted: number;
|
|
14
|
+
inserted: number;
|
|
15
|
+
}>;
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export async function startDeviceFlow(baseUrl) {
|
|
2
|
+
return requestJson(`${baseUrl}/api/device/start`, { method: "POST" });
|
|
3
|
+
}
|
|
4
|
+
export async function pollForToken(baseUrl, deviceCode) {
|
|
5
|
+
const response = await fetch(`${baseUrl}/api/device/token`, {
|
|
6
|
+
method: "POST",
|
|
7
|
+
headers: { "content-type": "application/json" },
|
|
8
|
+
body: JSON.stringify({ device_code: deviceCode }),
|
|
9
|
+
});
|
|
10
|
+
const body = await response.json();
|
|
11
|
+
if (response.status === 202 && body.error === "authorization_pending")
|
|
12
|
+
return null;
|
|
13
|
+
if (!response.ok || !body.access_token)
|
|
14
|
+
throw new Error(body.error ?? `Connection failed (${response.status})`);
|
|
15
|
+
return body.access_token;
|
|
16
|
+
}
|
|
17
|
+
export async function uploadEvents(baseUrl, token, events) {
|
|
18
|
+
return requestJson(`${baseUrl}/api/usage/sync`, {
|
|
19
|
+
method: "POST",
|
|
20
|
+
headers: {
|
|
21
|
+
authorization: `Bearer ${token}`,
|
|
22
|
+
"content-type": "application/json",
|
|
23
|
+
"user-agent": "@tokochi/cli",
|
|
24
|
+
},
|
|
25
|
+
body: JSON.stringify({ events }),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
async function requestJson(url, init) {
|
|
29
|
+
let response;
|
|
30
|
+
try {
|
|
31
|
+
response = await fetch(url, init);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
throw new Error(`Could not reach ${new URL(url).origin}: ${error instanceof Error ? error.message : "network error"}`);
|
|
35
|
+
}
|
|
36
|
+
const body = await response.json().catch(() => ({}));
|
|
37
|
+
if (!response.ok)
|
|
38
|
+
throw new Error(body.error ?? `Request failed (${response.status})`);
|
|
39
|
+
return body;
|
|
40
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function openBrowser(url: string): boolean;
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
export function openBrowser(url) {
|
|
3
|
+
const command = process.platform === "darwin"
|
|
4
|
+
? { file: "open", args: [url] }
|
|
5
|
+
: process.platform === "win32"
|
|
6
|
+
? { file: "cmd", args: ["/c", "start", "", url] }
|
|
7
|
+
: { file: "xdg-open", args: [url] };
|
|
8
|
+
try {
|
|
9
|
+
const child = spawn(command.file, command.args, { detached: true, stdio: "ignore" });
|
|
10
|
+
child.unref();
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { UsageEvent } from "./types.js";
|
|
2
|
+
export declare function collectUsageEvents(): Promise<UsageEvent[]>;
|
|
3
|
+
export declare function readCodexEvents(path: string): Promise<UsageEvent[]>;
|
|
4
|
+
export declare function readClaudeEvents(path: string): Promise<UsageEvent[]>;
|
|
5
|
+
export declare function readCopilotEvents(path: string): Promise<UsageEvent[]>;
|
|
6
|
+
export declare function extractJsonAfterMarker(body: string, marker: string): Record<string, unknown> | null;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { readFile, readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
export async function collectUsageEvents() {
|
|
6
|
+
const [codex, claude, copilot] = await Promise.all([
|
|
7
|
+
readCodexEvents(process.env.TOKOCHI_CODEX_LOGS_PATH ?? join(homedir(), ".codex", "logs_2.sqlite")),
|
|
8
|
+
readClaudeEvents(process.env.TOKOCHI_CLAUDE_PROJECTS_PATH ?? join(homedir(), ".claude", "projects")),
|
|
9
|
+
readCopilotEvents(process.env.TOKOCHI_COPILOT_SESSION_PATH ?? join(homedir(), ".copilot", "session-state")),
|
|
10
|
+
]);
|
|
11
|
+
return [...codex, ...claude, ...copilot];
|
|
12
|
+
}
|
|
13
|
+
export async function readCodexEvents(path) {
|
|
14
|
+
if (!(await exists(path)))
|
|
15
|
+
return [];
|
|
16
|
+
const events = new Map();
|
|
17
|
+
let database;
|
|
18
|
+
try {
|
|
19
|
+
database = new DatabaseSync(path, { readOnly: true });
|
|
20
|
+
const rows = database.prepare(`
|
|
21
|
+
select ts, feedback_log_body
|
|
22
|
+
from logs
|
|
23
|
+
where feedback_log_body like '%response.completed%'
|
|
24
|
+
and feedback_log_body like '%"usage"%'
|
|
25
|
+
order by ts asc
|
|
26
|
+
`).all();
|
|
27
|
+
for (const [index, row] of rows.entries()) {
|
|
28
|
+
const payload = extractJsonAfterMarker(row.feedback_log_body, "websocket event: ") ??
|
|
29
|
+
extractJsonAfterMarker(row.feedback_log_body, "Received message ");
|
|
30
|
+
if (payload?.type !== "response.completed" || !isRecord(payload.response))
|
|
31
|
+
continue;
|
|
32
|
+
const response = payload.response;
|
|
33
|
+
if (!isRecord(response.usage))
|
|
34
|
+
continue;
|
|
35
|
+
const eventId = text(response.id) || `codex:${row.ts}:${index}`;
|
|
36
|
+
if (events.has(eventId))
|
|
37
|
+
continue;
|
|
38
|
+
events.set(eventId, eventFromUsage("codex", eventId, timestampFrom(response.completed_at ?? response.created_at ?? row.ts), text(response.model) || "unknown", response.usage));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
database?.close();
|
|
46
|
+
}
|
|
47
|
+
return [...events.values()];
|
|
48
|
+
}
|
|
49
|
+
export async function readClaudeEvents(path) {
|
|
50
|
+
const files = await jsonlFiles(path, (name) => name.endsWith(".jsonl"));
|
|
51
|
+
const events = new Map();
|
|
52
|
+
for (const file of files) {
|
|
53
|
+
for (const [index, record] of (await records(file)).entries()) {
|
|
54
|
+
if (!isRecord(record.message) || record.message.role !== "assistant" || !isRecord(record.message.usage))
|
|
55
|
+
continue;
|
|
56
|
+
const eventId = text(record.message.id) || text(record.uuid) || `${file}:${index}`;
|
|
57
|
+
if (events.has(eventId))
|
|
58
|
+
continue;
|
|
59
|
+
const timestamp = timestampFrom(record.timestamp);
|
|
60
|
+
if (!timestamp)
|
|
61
|
+
continue;
|
|
62
|
+
events.set(eventId, eventFromUsage("claude", eventId, timestamp, text(record.message.model) || text(record.model) || "unknown", record.message.usage));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return [...events.values()];
|
|
66
|
+
}
|
|
67
|
+
export async function readCopilotEvents(path) {
|
|
68
|
+
const files = await jsonlFiles(path, (name) => name === "events.jsonl");
|
|
69
|
+
const events = new Map();
|
|
70
|
+
for (const file of files) {
|
|
71
|
+
for (const [index, record] of (await records(file)).entries()) {
|
|
72
|
+
if (record.type !== "session.shutdown" || !isRecord(record.data) || !isRecord(record.data.modelMetrics))
|
|
73
|
+
continue;
|
|
74
|
+
const timestamp = timestampFrom(record.timestamp);
|
|
75
|
+
if (!timestamp)
|
|
76
|
+
continue;
|
|
77
|
+
const rootId = text(record.id) || `${file}:${index}`;
|
|
78
|
+
for (const [model, metrics] of Object.entries(record.data.modelMetrics)) {
|
|
79
|
+
if (!isRecord(metrics) || !isRecord(metrics.usage))
|
|
80
|
+
continue;
|
|
81
|
+
const eventId = `${rootId}:${model}`;
|
|
82
|
+
if (events.has(eventId))
|
|
83
|
+
continue;
|
|
84
|
+
events.set(eventId, {
|
|
85
|
+
source: "copilot",
|
|
86
|
+
event_id: eventId,
|
|
87
|
+
timestamp,
|
|
88
|
+
model,
|
|
89
|
+
input_tokens: token(metrics.usage.inputTokens),
|
|
90
|
+
output_tokens: token(metrics.usage.outputTokens),
|
|
91
|
+
cache_creation_input_tokens: token(metrics.usage.cacheWriteTokens),
|
|
92
|
+
cache_read_input_tokens: token(metrics.usage.cacheReadTokens),
|
|
93
|
+
reasoning_tokens: token(metrics.usage.reasoningTokens),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return [...events.values()];
|
|
99
|
+
}
|
|
100
|
+
export function extractJsonAfterMarker(body, marker) {
|
|
101
|
+
const index = body.indexOf(marker);
|
|
102
|
+
if (index < 0)
|
|
103
|
+
return null;
|
|
104
|
+
try {
|
|
105
|
+
const value = JSON.parse(body.slice(index + marker.length).trim());
|
|
106
|
+
return isRecord(value) ? value : null;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function eventFromUsage(source, eventId, timestamp, model, usage) {
|
|
113
|
+
return {
|
|
114
|
+
source,
|
|
115
|
+
event_id: eventId,
|
|
116
|
+
timestamp: timestamp ?? new Date(0).toISOString(),
|
|
117
|
+
model,
|
|
118
|
+
input_tokens: token(usage.input_tokens),
|
|
119
|
+
output_tokens: token(usage.output_tokens),
|
|
120
|
+
cache_creation_input_tokens: token(usage.cache_creation_input_tokens),
|
|
121
|
+
cache_read_input_tokens: token(usage.cache_read_input_tokens),
|
|
122
|
+
reasoning_tokens: token(usage.reasoning_tokens),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function token(value) {
|
|
126
|
+
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
|
|
127
|
+
}
|
|
128
|
+
function timestampFrom(value) {
|
|
129
|
+
const date = typeof value === "number" ? new Date(value * 1000) : typeof value === "string" ? new Date(value) : null;
|
|
130
|
+
return date && !Number.isNaN(date.valueOf()) ? date.toISOString() : null;
|
|
131
|
+
}
|
|
132
|
+
function text(value) {
|
|
133
|
+
return typeof value === "string" ? value.trim() : "";
|
|
134
|
+
}
|
|
135
|
+
function isRecord(value) {
|
|
136
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
137
|
+
}
|
|
138
|
+
async function records(path) {
|
|
139
|
+
try {
|
|
140
|
+
return (await readFile(path, "utf8"))
|
|
141
|
+
.split(/\r?\n/)
|
|
142
|
+
.filter(Boolean)
|
|
143
|
+
.flatMap((line) => {
|
|
144
|
+
try {
|
|
145
|
+
const value = JSON.parse(line);
|
|
146
|
+
return isRecord(value) ? [value] : [];
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async function jsonlFiles(path, include) {
|
|
158
|
+
if (!(await exists(path)))
|
|
159
|
+
return [];
|
|
160
|
+
if ((await stat(path)).isFile())
|
|
161
|
+
return [path];
|
|
162
|
+
const found = [];
|
|
163
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
164
|
+
for (const entry of entries) {
|
|
165
|
+
const child = join(path, entry.name);
|
|
166
|
+
if (entry.isDirectory())
|
|
167
|
+
found.push(...await jsonlFiles(child, include));
|
|
168
|
+
else if (entry.isFile() && include(entry.name))
|
|
169
|
+
found.push(child);
|
|
170
|
+
}
|
|
171
|
+
return found.sort();
|
|
172
|
+
}
|
|
173
|
+
async function exists(path) {
|
|
174
|
+
try {
|
|
175
|
+
await stat(path);
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { CliConfig } from "./types.js";
|
|
2
|
+
export declare function configPath(): string;
|
|
3
|
+
export declare function loadConfig(): Promise<CliConfig | null>;
|
|
4
|
+
export declare function saveConfig(config: CliConfig): Promise<void>;
|
|
5
|
+
export declare function deleteConfig(): Promise<void>;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
export function configPath() {
|
|
5
|
+
return process.env.TOKOCHI_CLI_CONFIG ?? join(homedir(), ".tokochi", "cli.json");
|
|
6
|
+
}
|
|
7
|
+
export async function loadConfig() {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(await readFile(configPath(), "utf8"));
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export async function saveConfig(config) {
|
|
16
|
+
const path = configPath();
|
|
17
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
18
|
+
await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
19
|
+
}
|
|
20
|
+
export async function deleteConfig() {
|
|
21
|
+
await rm(configPath(), { force: true });
|
|
22
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { startDeviceFlow, pollForToken, uploadEvents } from "./api.js";
|
|
3
|
+
import { openBrowser } from "./browser.js";
|
|
4
|
+
import { collectUsageEvents } from "./collectors.js";
|
|
5
|
+
import { deleteConfig, loadConfig, saveConfig } from "./config.js";
|
|
6
|
+
const DEFAULT_URL = "https://tokochi.app";
|
|
7
|
+
const command = process.argv[2] ?? "help";
|
|
8
|
+
try {
|
|
9
|
+
if (command === "connect")
|
|
10
|
+
await connect();
|
|
11
|
+
else if (command === "sync")
|
|
12
|
+
await sync();
|
|
13
|
+
else if (command === "status")
|
|
14
|
+
await status();
|
|
15
|
+
else if (command === "disconnect")
|
|
16
|
+
await disconnect();
|
|
17
|
+
else if (command === "help" || command === "--help" || command === "-h")
|
|
18
|
+
help();
|
|
19
|
+
else
|
|
20
|
+
throw new Error(`Unknown command: ${command}`);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
console.error(`\nTokochi: ${error instanceof Error ? error.message : String(error)}`);
|
|
24
|
+
process.exitCode = 1;
|
|
25
|
+
}
|
|
26
|
+
async function connect() {
|
|
27
|
+
const existing = await loadConfig();
|
|
28
|
+
const baseUrl = normalizeUrl(option("--url") ?? process.env.TOKOCHI_WEB_URL ?? existing?.baseUrl ?? DEFAULT_URL);
|
|
29
|
+
console.log("Requesting a feeding link…");
|
|
30
|
+
const device = await startDeviceFlow(baseUrl);
|
|
31
|
+
console.log(`\nYour device code is: ${device.user_code}`);
|
|
32
|
+
console.log(`Open: ${device.verification_uri_complete}\n`);
|
|
33
|
+
if (openBrowser(device.verification_uri_complete)) {
|
|
34
|
+
console.log("A browser window was opened. Approve this device to continue.");
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
console.log("Open the link above in your browser to continue.");
|
|
38
|
+
}
|
|
39
|
+
const deadline = Date.now() + device.expires_in * 1000;
|
|
40
|
+
let token = null;
|
|
41
|
+
while (Date.now() < deadline) {
|
|
42
|
+
await sleep(device.interval * 1000);
|
|
43
|
+
token = await pollForToken(baseUrl, device.device_code);
|
|
44
|
+
if (token)
|
|
45
|
+
break;
|
|
46
|
+
process.stdout.write(".");
|
|
47
|
+
}
|
|
48
|
+
if (!token)
|
|
49
|
+
throw new Error("The device code expired. Run `tokochi connect` again.");
|
|
50
|
+
const config = {
|
|
51
|
+
baseUrl,
|
|
52
|
+
token,
|
|
53
|
+
syncedEventKeys: existing?.baseUrl === baseUrl ? existing.syncedEventKeys : [],
|
|
54
|
+
connectedAt: new Date().toISOString(),
|
|
55
|
+
};
|
|
56
|
+
await saveConfig(config);
|
|
57
|
+
console.log("\n\nConnected. Feeding your Tokochi for the first time…");
|
|
58
|
+
await runSync(config);
|
|
59
|
+
}
|
|
60
|
+
async function sync() {
|
|
61
|
+
const config = await requireConfig();
|
|
62
|
+
await runSync(config);
|
|
63
|
+
}
|
|
64
|
+
async function runSync(config) {
|
|
65
|
+
const allEvents = await collectUsageEvents();
|
|
66
|
+
const synced = new Set(config.syncedEventKeys);
|
|
67
|
+
const pending = allEvents.filter((event) => !synced.has(eventKey(event)));
|
|
68
|
+
if (!pending.length) {
|
|
69
|
+
console.log(`Already fed. ${allEvents.length.toLocaleString()} local events are up to date.`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
let inserted = 0;
|
|
73
|
+
for (let start = 0; start < pending.length; start += 5000) {
|
|
74
|
+
const batch = pending.slice(start, start + 5000);
|
|
75
|
+
const result = await uploadEvents(config.baseUrl, config.token, batch);
|
|
76
|
+
inserted += result.inserted;
|
|
77
|
+
for (const event of batch)
|
|
78
|
+
synced.add(eventKey(event));
|
|
79
|
+
config.syncedEventKeys = [...synced];
|
|
80
|
+
config.lastSyncAt = new Date().toISOString();
|
|
81
|
+
await saveConfig(config);
|
|
82
|
+
}
|
|
83
|
+
console.log(`Fed ${pending.length.toLocaleString()} events to your Tokochi (${inserted.toLocaleString()} new).`);
|
|
84
|
+
}
|
|
85
|
+
async function status() {
|
|
86
|
+
const config = await loadConfig();
|
|
87
|
+
if (!config) {
|
|
88
|
+
console.log("Not connected. Run `npx @tokochi/cli connect --url https://your-tokochi.app`.");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const events = await collectUsageEvents();
|
|
92
|
+
const synced = new Set(config.syncedEventKeys);
|
|
93
|
+
const pending = events.filter((event) => !synced.has(eventKey(event))).length;
|
|
94
|
+
console.log(`Connected to: ${config.baseUrl}`);
|
|
95
|
+
console.log(`Local events: ${events.length.toLocaleString()}`);
|
|
96
|
+
console.log(`Waiting to feed: ${pending.toLocaleString()}`);
|
|
97
|
+
console.log(`Last feeding: ${config.lastSyncAt ? new Date(config.lastSyncAt).toLocaleString() : "never"}`);
|
|
98
|
+
}
|
|
99
|
+
async function disconnect() {
|
|
100
|
+
const config = await loadConfig();
|
|
101
|
+
if (!config) {
|
|
102
|
+
console.log("This computer is not connected.");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
await fetch(`${config.baseUrl}/api/device/revoke`, {
|
|
106
|
+
method: "DELETE",
|
|
107
|
+
headers: { authorization: `Bearer ${config.token}` },
|
|
108
|
+
}).catch(() => undefined);
|
|
109
|
+
await deleteConfig();
|
|
110
|
+
console.log("Disconnected. This computer will no longer feed your Tokochi.");
|
|
111
|
+
}
|
|
112
|
+
function help() {
|
|
113
|
+
console.log(`
|
|
114
|
+
Tokochi CLI
|
|
115
|
+
|
|
116
|
+
tokochi connect [--url URL] Connect this computer in the browser
|
|
117
|
+
tokochi sync Feed new local token events
|
|
118
|
+
tokochi status Show connection and feeding status
|
|
119
|
+
tokochi disconnect Remove this computer's connection
|
|
120
|
+
|
|
121
|
+
Run without installing:
|
|
122
|
+
npx @tokochi/cli connect --url http://localhost:3000
|
|
123
|
+
`.trim());
|
|
124
|
+
}
|
|
125
|
+
async function requireConfig() {
|
|
126
|
+
const config = await loadConfig();
|
|
127
|
+
if (!config)
|
|
128
|
+
throw new Error("This computer is not connected. Run `tokochi connect` first.");
|
|
129
|
+
return config;
|
|
130
|
+
}
|
|
131
|
+
function option(name) {
|
|
132
|
+
const index = process.argv.indexOf(name);
|
|
133
|
+
return index >= 0 ? process.argv[index + 1] : undefined;
|
|
134
|
+
}
|
|
135
|
+
function normalizeUrl(value) {
|
|
136
|
+
const url = new URL(value);
|
|
137
|
+
return url.toString().replace(/\/$/, "");
|
|
138
|
+
}
|
|
139
|
+
function eventKey(event) {
|
|
140
|
+
return `${event.source}:${event.event_id}`;
|
|
141
|
+
}
|
|
142
|
+
function sleep(milliseconds) {
|
|
143
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
144
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type UsageEvent = {
|
|
2
|
+
source: "codex" | "claude" | "copilot";
|
|
3
|
+
event_id: string;
|
|
4
|
+
timestamp: string;
|
|
5
|
+
model: string;
|
|
6
|
+
input_tokens: number;
|
|
7
|
+
output_tokens: number;
|
|
8
|
+
cache_creation_input_tokens: number;
|
|
9
|
+
cache_read_input_tokens: number;
|
|
10
|
+
reasoning_tokens: number;
|
|
11
|
+
};
|
|
12
|
+
export type CliConfig = {
|
|
13
|
+
baseUrl: string;
|
|
14
|
+
token: string;
|
|
15
|
+
syncedEventKeys: string[];
|
|
16
|
+
connectedAt: string;
|
|
17
|
+
lastSyncAt?: string;
|
|
18
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rizzmo/tokochi-cli",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Feed your Tokochi from local AI coding token usage.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tokochi": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc -p tsconfig.json",
|
|
15
|
+
"test": "vitest run",
|
|
16
|
+
"prepack": "npm run build"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=22.13"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "^24.0.0",
|
|
23
|
+
"typescript": "^5.9.0",
|
|
24
|
+
"vitest": "^3.2.0"
|
|
25
|
+
},
|
|
26
|
+
"overrides": {
|
|
27
|
+
"esbuild": "0.28.1"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT"
|
|
30
|
+
}
|