@rizzmo/tokochi-cli 0.1.2 → 0.1.4
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 +18 -5
- package/dist/auto-sync.d.ts +12 -0
- package/dist/auto-sync.js +217 -0
- package/dist/index.js +38 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,15 +3,28 @@
|
|
|
3
3
|
Connect local Codex, Claude Code, and GitHub Copilot token usage to your Tokochi.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
|
|
6
|
+
npm install -g @rizzmo/tokochi-cli
|
|
7
|
+
tokochi connect
|
|
7
8
|
```
|
|
8
9
|
|
|
9
|
-
The browser handles GitHub authentication and device approval
|
|
10
|
+
The CLI connects to `https://www.tokochi.me` by default. The browser handles GitHub authentication and device approval; no token copying is required.
|
|
10
11
|
|
|
11
12
|
```bash
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
tokochi auto-sync enable --interval 15m
|
|
14
|
+
tokochi auto-sync status
|
|
15
|
+
tokochi auto-sync disable
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Automatic syncing installs a user-level OS job: launchd on macOS, a systemd user timer on Linux, or Task Scheduler on Windows. Each run uploads only new events and exits; no terminal must remain open. The supported interval is 5 minutes to 24 hours.
|
|
19
|
+
|
|
20
|
+
Manual commands remain available:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
tokochi sync
|
|
24
|
+
tokochi status
|
|
25
|
+
tokochi disconnect
|
|
15
26
|
```
|
|
16
27
|
|
|
17
28
|
Only event IDs, timestamps, agent/model names, and token counts are uploaded. Prompts and responses remain on the computer.
|
|
29
|
+
|
|
30
|
+
Without a global installation, prefix commands with `npx @rizzmo/tokochi-cli`.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type AutoSyncStatus = {
|
|
2
|
+
enabled: boolean;
|
|
3
|
+
intervalMinutes?: number;
|
|
4
|
+
platform: NodeJS.Platform;
|
|
5
|
+
};
|
|
6
|
+
export declare function parseInterval(value?: string): number;
|
|
7
|
+
export declare function enableAutoSync(intervalMinutes?: number, cliPath?: string): Promise<AutoSyncStatus>;
|
|
8
|
+
export declare function disableAutoSync(): Promise<AutoSyncStatus>;
|
|
9
|
+
export declare function getAutoSyncStatus(): Promise<AutoSyncStatus>;
|
|
10
|
+
export declare function launchdPlist(nodePath: string, cliPath: string, intervalMinutes: number, logPath: string): string;
|
|
11
|
+
export declare function systemdService(nodePath: string, cliPath: string): string;
|
|
12
|
+
export declare function systemdTimer(intervalMinutes: number): string;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir, platform, userInfo } from "node:os";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
const LABEL = "app.tokochi.autosync";
|
|
6
|
+
const WINDOWS_TASK = "Tokochi Auto Sync";
|
|
7
|
+
const DEFAULT_INTERVAL_MINUTES = 15;
|
|
8
|
+
export function parseInterval(value = "15m") {
|
|
9
|
+
const match = /^(\d+)(m|h)$/.exec(value.trim().toLowerCase());
|
|
10
|
+
if (!match)
|
|
11
|
+
throw new Error("Interval must use minutes or hours, for example `15m` or `1h`.");
|
|
12
|
+
const amount = Number(match[1]);
|
|
13
|
+
const minutes = match[2] === "h" ? amount * 60 : amount;
|
|
14
|
+
if (!Number.isSafeInteger(minutes) || minutes < 5 || minutes > 1440) {
|
|
15
|
+
throw new Error("Auto-sync interval must be between 5 minutes and 24 hours.");
|
|
16
|
+
}
|
|
17
|
+
return minutes;
|
|
18
|
+
}
|
|
19
|
+
export async function enableAutoSync(intervalMinutes = DEFAULT_INTERVAL_MINUTES, cliPath = resolve(process.argv[1])) {
|
|
20
|
+
if (!Number.isSafeInteger(intervalMinutes) || intervalMinutes < 5 || intervalMinutes > 1440) {
|
|
21
|
+
throw new Error("Auto-sync interval must be between 5 minutes and 24 hours.");
|
|
22
|
+
}
|
|
23
|
+
const currentPlatform = platform();
|
|
24
|
+
if (currentPlatform === "darwin")
|
|
25
|
+
await enableLaunchd(intervalMinutes, cliPath);
|
|
26
|
+
else if (currentPlatform === "linux")
|
|
27
|
+
await enableSystemd(intervalMinutes, cliPath);
|
|
28
|
+
else if (currentPlatform === "win32")
|
|
29
|
+
await enableTaskScheduler(intervalMinutes, cliPath);
|
|
30
|
+
else
|
|
31
|
+
throw new Error(`Automatic syncing is not supported on ${currentPlatform}.`);
|
|
32
|
+
await saveState({ intervalMinutes, platform: currentPlatform });
|
|
33
|
+
return { enabled: true, intervalMinutes, platform: currentPlatform };
|
|
34
|
+
}
|
|
35
|
+
export async function disableAutoSync() {
|
|
36
|
+
const currentPlatform = platform();
|
|
37
|
+
if (currentPlatform === "darwin")
|
|
38
|
+
await disableLaunchd();
|
|
39
|
+
else if (currentPlatform === "linux")
|
|
40
|
+
await disableSystemd();
|
|
41
|
+
else if (currentPlatform === "win32")
|
|
42
|
+
await disableTaskScheduler();
|
|
43
|
+
else
|
|
44
|
+
throw new Error(`Automatic syncing is not supported on ${currentPlatform}.`);
|
|
45
|
+
await rm(statePath(), { force: true });
|
|
46
|
+
return { enabled: false, platform: currentPlatform };
|
|
47
|
+
}
|
|
48
|
+
export async function getAutoSyncStatus() {
|
|
49
|
+
const currentPlatform = platform();
|
|
50
|
+
const state = await loadState();
|
|
51
|
+
const enabled = await scheduleExists(currentPlatform);
|
|
52
|
+
return {
|
|
53
|
+
enabled,
|
|
54
|
+
intervalMinutes: enabled ? state?.intervalMinutes : undefined,
|
|
55
|
+
platform: currentPlatform,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export function launchdPlist(nodePath, cliPath, intervalMinutes, logPath) {
|
|
59
|
+
const args = [nodePath, cliPath, "sync"].map((value) => ` <string>${xml(value)}</string>`).join("\n");
|
|
60
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
61
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
62
|
+
<plist version="1.0">
|
|
63
|
+
<dict>
|
|
64
|
+
<key>Label</key>
|
|
65
|
+
<string>${LABEL}</string>
|
|
66
|
+
<key>ProgramArguments</key>
|
|
67
|
+
<array>
|
|
68
|
+
${args}
|
|
69
|
+
</array>
|
|
70
|
+
<key>StartInterval</key>
|
|
71
|
+
<integer>${intervalMinutes * 60}</integer>
|
|
72
|
+
<key>RunAtLoad</key>
|
|
73
|
+
<true/>
|
|
74
|
+
<key>StandardOutPath</key>
|
|
75
|
+
<string>${xml(logPath)}</string>
|
|
76
|
+
<key>StandardErrorPath</key>
|
|
77
|
+
<string>${xml(logPath)}</string>
|
|
78
|
+
</dict>
|
|
79
|
+
</plist>
|
|
80
|
+
`;
|
|
81
|
+
}
|
|
82
|
+
export function systemdService(nodePath, cliPath) {
|
|
83
|
+
return `[Unit]
|
|
84
|
+
Description=Sync local AI usage to Tokochi
|
|
85
|
+
|
|
86
|
+
[Service]
|
|
87
|
+
Type=oneshot
|
|
88
|
+
ExecStart=${systemdEscape(nodePath)} ${systemdEscape(cliPath)} sync
|
|
89
|
+
`;
|
|
90
|
+
}
|
|
91
|
+
export function systemdTimer(intervalMinutes) {
|
|
92
|
+
return `[Unit]
|
|
93
|
+
Description=Sync Tokochi every ${intervalMinutes} minutes
|
|
94
|
+
|
|
95
|
+
[Timer]
|
|
96
|
+
OnBootSec=2min
|
|
97
|
+
OnUnitActiveSec=${intervalMinutes}min
|
|
98
|
+
Persistent=true
|
|
99
|
+
|
|
100
|
+
[Install]
|
|
101
|
+
WantedBy=timers.target
|
|
102
|
+
`;
|
|
103
|
+
}
|
|
104
|
+
async function enableLaunchd(intervalMinutes, cliPath) {
|
|
105
|
+
const path = launchdPath();
|
|
106
|
+
await mkdir(dirname(path), { recursive: true });
|
|
107
|
+
await mkdir(dataDirectory(), { recursive: true, mode: 0o700 });
|
|
108
|
+
await run("launchctl", ["bootout", launchdDomain(), path], true);
|
|
109
|
+
await writeFile(path, launchdPlist(process.execPath, cliPath, intervalMinutes, logPath()), { mode: 0o600 });
|
|
110
|
+
await run("launchctl", ["bootstrap", launchdDomain(), path]);
|
|
111
|
+
}
|
|
112
|
+
async function disableLaunchd() {
|
|
113
|
+
const path = launchdPath();
|
|
114
|
+
await run("launchctl", ["bootout", launchdDomain(), path], true);
|
|
115
|
+
await rm(path, { force: true });
|
|
116
|
+
}
|
|
117
|
+
async function enableSystemd(intervalMinutes, cliPath) {
|
|
118
|
+
const directory = systemdDirectory();
|
|
119
|
+
await mkdir(directory, { recursive: true });
|
|
120
|
+
await writeFile(join(directory, "tokochi-sync.service"), systemdService(process.execPath, cliPath), { mode: 0o600 });
|
|
121
|
+
await writeFile(join(directory, "tokochi-sync.timer"), systemdTimer(intervalMinutes), { mode: 0o600 });
|
|
122
|
+
await run("systemctl", ["--user", "daemon-reload"]);
|
|
123
|
+
await run("systemctl", ["--user", "enable", "--now", "tokochi-sync.timer"]);
|
|
124
|
+
}
|
|
125
|
+
async function disableSystemd() {
|
|
126
|
+
await run("systemctl", ["--user", "disable", "--now", "tokochi-sync.timer"], true);
|
|
127
|
+
await rm(join(systemdDirectory(), "tokochi-sync.service"), { force: true });
|
|
128
|
+
await rm(join(systemdDirectory(), "tokochi-sync.timer"), { force: true });
|
|
129
|
+
await run("systemctl", ["--user", "daemon-reload"], true);
|
|
130
|
+
}
|
|
131
|
+
async function enableTaskScheduler(intervalMinutes, cliPath) {
|
|
132
|
+
const taskCommand = windowsQuote(process.execPath, cliPath, "sync");
|
|
133
|
+
await run("schtasks", [
|
|
134
|
+
"/Create", "/F", "/TN", WINDOWS_TASK, "/SC", "MINUTE", "/MO", String(intervalMinutes),
|
|
135
|
+
"/TR", taskCommand,
|
|
136
|
+
]);
|
|
137
|
+
}
|
|
138
|
+
async function disableTaskScheduler() {
|
|
139
|
+
await run("schtasks", ["/Delete", "/F", "/TN", WINDOWS_TASK], true);
|
|
140
|
+
}
|
|
141
|
+
async function scheduleExists(currentPlatform) {
|
|
142
|
+
if (currentPlatform === "darwin")
|
|
143
|
+
return exists(launchdPath());
|
|
144
|
+
if (currentPlatform === "linux")
|
|
145
|
+
return exists(join(systemdDirectory(), "tokochi-sync.timer"));
|
|
146
|
+
if (currentPlatform === "win32")
|
|
147
|
+
return (await run("schtasks", ["/Query", "/TN", WINDOWS_TASK], true)).ok;
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
async function run(command, args, ignoreFailure = false) {
|
|
151
|
+
return new Promise((resolvePromise, reject) => {
|
|
152
|
+
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
153
|
+
let output = "";
|
|
154
|
+
child.stdout.on("data", (chunk) => { output += String(chunk); });
|
|
155
|
+
child.stderr.on("data", (chunk) => { output += String(chunk); });
|
|
156
|
+
child.on("error", (error) => {
|
|
157
|
+
if (ignoreFailure)
|
|
158
|
+
resolvePromise({ ok: false, output: error.message });
|
|
159
|
+
else
|
|
160
|
+
reject(error);
|
|
161
|
+
});
|
|
162
|
+
child.on("close", (code) => {
|
|
163
|
+
if (code === 0 || ignoreFailure)
|
|
164
|
+
resolvePromise({ ok: code === 0, output: output.trim() });
|
|
165
|
+
else
|
|
166
|
+
reject(new Error(output.trim() || `${command} exited with code ${code}`));
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
function dataDirectory() {
|
|
171
|
+
return join(homedir(), ".tokochi");
|
|
172
|
+
}
|
|
173
|
+
function statePath() {
|
|
174
|
+
return join(dataDirectory(), "auto-sync.json");
|
|
175
|
+
}
|
|
176
|
+
function logPath() {
|
|
177
|
+
return join(dataDirectory(), "auto-sync.log");
|
|
178
|
+
}
|
|
179
|
+
function launchdPath() {
|
|
180
|
+
return join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
181
|
+
}
|
|
182
|
+
function launchdDomain() {
|
|
183
|
+
return `gui/${userInfo().uid}`;
|
|
184
|
+
}
|
|
185
|
+
function systemdDirectory() {
|
|
186
|
+
return join(homedir(), ".config", "systemd", "user");
|
|
187
|
+
}
|
|
188
|
+
async function saveState(state) {
|
|
189
|
+
await mkdir(dataDirectory(), { recursive: true, mode: 0o700 });
|
|
190
|
+
await writeFile(statePath(), `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
191
|
+
}
|
|
192
|
+
async function loadState() {
|
|
193
|
+
try {
|
|
194
|
+
return JSON.parse(await readFile(statePath(), "utf8"));
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async function exists(path) {
|
|
201
|
+
try {
|
|
202
|
+
await access(path);
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function xml(value) {
|
|
210
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
211
|
+
}
|
|
212
|
+
function systemdEscape(value) {
|
|
213
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"")}"`;
|
|
214
|
+
}
|
|
215
|
+
function windowsQuote(...parts) {
|
|
216
|
+
return parts.map((part) => `"${part.replaceAll("\"", "\\\"")}"`).join(" ");
|
|
217
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { startDeviceFlow, pollForToken, uploadEvents } from "./api.js";
|
|
3
|
+
import { disableAutoSync, enableAutoSync, getAutoSyncStatus, parseInterval } from "./auto-sync.js";
|
|
3
4
|
import { openBrowser } from "./browser.js";
|
|
4
5
|
import { collectUsageEvents } from "./collectors.js";
|
|
5
6
|
import { deleteConfig, loadConfig, saveConfig } from "./config.js";
|
|
6
|
-
const DEFAULT_URL = "https://tokochi.
|
|
7
|
+
const DEFAULT_URL = "https://www.tokochi.me";
|
|
7
8
|
const command = process.argv[2] ?? "help";
|
|
8
9
|
try {
|
|
9
10
|
if (command === "connect")
|
|
@@ -14,6 +15,8 @@ try {
|
|
|
14
15
|
await status();
|
|
15
16
|
else if (command === "disconnect")
|
|
16
17
|
await disconnect();
|
|
18
|
+
else if (command === "auto-sync")
|
|
19
|
+
await autoSync();
|
|
17
20
|
else if (command === "help" || command === "--help" || command === "-h")
|
|
18
21
|
help();
|
|
19
22
|
else
|
|
@@ -85,7 +88,7 @@ async function runSync(config) {
|
|
|
85
88
|
async function status() {
|
|
86
89
|
const config = await loadConfig();
|
|
87
90
|
if (!config) {
|
|
88
|
-
console.log("Not connected. Run `
|
|
91
|
+
console.log("Not connected. Run `tokochi connect`.");
|
|
89
92
|
return;
|
|
90
93
|
}
|
|
91
94
|
const events = await collectUsageEvents();
|
|
@@ -106,8 +109,32 @@ async function disconnect() {
|
|
|
106
109
|
method: "DELETE",
|
|
107
110
|
headers: { authorization: `Bearer ${config.token}` },
|
|
108
111
|
}).catch(() => undefined);
|
|
112
|
+
await disableAutoSync().catch(() => undefined);
|
|
109
113
|
await deleteConfig();
|
|
110
|
-
console.log("Disconnected. This computer will no longer feed your Tokochi.");
|
|
114
|
+
console.log("Disconnected. This computer will no longer feed your Tokochi, and automatic feeding was disabled.");
|
|
115
|
+
}
|
|
116
|
+
async function autoSync() {
|
|
117
|
+
const action = process.argv[3] ?? "status";
|
|
118
|
+
if (action === "enable") {
|
|
119
|
+
await requireConfig();
|
|
120
|
+
const intervalMinutes = parseInterval(option("--interval") ?? "15m");
|
|
121
|
+
const result = await enableAutoSync(intervalMinutes);
|
|
122
|
+
console.log(`Automatic feeding enabled every ${result.intervalMinutes} minutes.`);
|
|
123
|
+
console.log("It runs as a user-level OS job and survives terminal closure and restarts.");
|
|
124
|
+
}
|
|
125
|
+
else if (action === "disable") {
|
|
126
|
+
await disableAutoSync();
|
|
127
|
+
console.log("Automatic feeding disabled.");
|
|
128
|
+
}
|
|
129
|
+
else if (action === "status") {
|
|
130
|
+
const result = await getAutoSyncStatus();
|
|
131
|
+
console.log(result.enabled
|
|
132
|
+
? `Automatic feeding is enabled${result.intervalMinutes ? ` every ${result.intervalMinutes} minutes` : ""}.`
|
|
133
|
+
: "Automatic feeding is disabled.");
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
throw new Error("Use `tokochi auto-sync enable`, `status`, or `disable`.");
|
|
137
|
+
}
|
|
111
138
|
}
|
|
112
139
|
function help() {
|
|
113
140
|
console.log(`
|
|
@@ -117,9 +144,16 @@ Tokochi CLI
|
|
|
117
144
|
tokochi sync Feed new local token events
|
|
118
145
|
tokochi status Show connection and feeding status
|
|
119
146
|
tokochi disconnect Remove this computer's connection
|
|
147
|
+
tokochi auto-sync enable Schedule feeding every 15 minutes
|
|
148
|
+
tokochi auto-sync status Show automatic feeding status
|
|
149
|
+
tokochi auto-sync disable Remove the scheduled job
|
|
150
|
+
|
|
151
|
+
Install globally:
|
|
152
|
+
npm install -g @rizzmo/tokochi-cli
|
|
153
|
+
tokochi connect
|
|
120
154
|
|
|
121
155
|
Run without installing:
|
|
122
|
-
npx @rizzmo/tokochi-cli connect
|
|
156
|
+
npx @rizzmo/tokochi-cli connect
|
|
123
157
|
`.trim());
|
|
124
158
|
}
|
|
125
159
|
async function requireConfig() {
|