@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 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
- npx @rizzmo/tokochi-cli connect --url https://your-tokochi.app
6
+ npm install -g @rizzmo/tokochi-cli
7
+ tokochi connect
7
8
  ```
8
9
 
9
- The browser handles GitHub authentication and device approval. No token copying is required.
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
- npx @rizzmo/tokochi-cli sync
13
- npx @rizzmo/tokochi-cli status
14
- npx @rizzmo/tokochi-cli disconnect
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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
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.app";
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 `npx @rizzmo/tokochi-cli connect --url https://your-tokochi.app`.");
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 --url http://localhost:3000
156
+ npx @rizzmo/tokochi-cli connect
123
157
  `.trim());
124
158
  }
125
159
  async function requireConfig() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rizzmo/tokochi-cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Feed your Tokochi from local AI coding token usage.",
5
5
  "type": "module",
6
6
  "bin": {