satoai 0.1.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 ADDED
@@ -0,0 +1,94 @@
1
+ # Sato
2
+
3
+ AI Coding Telemetry Dashboard. One command to capture and visualize OpenTelemetry data from Claude Code.
4
+
5
+ Built with Next.js, Tailwind CSS v4, and Recharts.
6
+
7
+ ## Quick Start
8
+
9
+ ```bash
10
+ npx sato
11
+ ```
12
+
13
+ That's it. On first run, Sato automatically configures Claude Code to export telemetry, starts an embedded OTLP receiver on `:4318`, and opens the dashboard on `:3000`. No external collector needed.
14
+
15
+ Open a new terminal and use Claude Code as usual — telemetry flows automatically.
16
+
17
+ ## Commands
18
+
19
+ ### `sato` / `sato start`
20
+
21
+ Start the OTLP receiver and dashboard, then open the browser.
22
+
23
+ ```bash
24
+ sato # defaults: dashboard :3000, OTLP :4318
25
+ sato start --port 8080 # custom dashboard port
26
+ sato start --otlp-port 4319 # custom OTLP port
27
+ sato start --no-browser # don't open browser
28
+ ```
29
+
30
+ ### `sato stop`
31
+
32
+ Stop the running Sato instance.
33
+
34
+ ```bash
35
+ sato stop
36
+ ```
37
+
38
+ ### `sato status`
39
+
40
+ Show running state and data stats.
41
+
42
+ ```bash
43
+ sato status
44
+ ```
45
+
46
+ ### `sato setup claude`
47
+
48
+ Configure Claude Code to export telemetry. Appends env vars to your shell profile (`~/.zshrc` or `~/.bashrc`). Idempotent — safe to run multiple times.
49
+
50
+ ```bash
51
+ sato setup claude
52
+ ```
53
+
54
+ ## How It Works
55
+
56
+ 1. `sato start` launches an embedded OTLP HTTP/JSON receiver on `:4318` and the Next.js dashboard on `:3000`
57
+ 2. Claude Code (with telemetry enabled) sends OpenTelemetry logs and metrics to the receiver
58
+ 3. The receiver appends data to `~/.sato/data/logs.json` and `~/.sato/data/metrics.json`
59
+ 4. The dashboard reads those files plus Claude Code session transcripts from `~/.claude/projects/`
60
+
61
+ ## Data
62
+
63
+ All telemetry is stored locally in `~/.sato/data/`. Nothing is sent to external services.
64
+
65
+ - `~/.sato/data/logs.json` — OTLP log records
66
+ - `~/.sato/data/metrics.json` — OTLP metric records
67
+ - `~/.sato/classification-cache.json` — AI classification cache
68
+ - `~/.sato/sato.pid` — PID file for the running instance
69
+
70
+ ## Features
71
+
72
+ - **Summary cards** — Total spend, sessions, tokens, prompts, avg latency, cache hit rate
73
+ - **Cost chart** — Cumulative spend over time
74
+ - **Token chart** — Input/output/cache breakdown per request
75
+ - **Model distribution** — Usage across Claude models
76
+ - **Session explorer** — Expandable session timeline with per-prompt detail, tool calls, and assistant messages
77
+ - **AI classification** — Automatic categorization of prompts (requires `ANTHROPIC_API_KEY` env var)
78
+ - **Request table** — Sortable, paginated table of all API calls
79
+
80
+ ## Development
81
+
82
+ ```bash
83
+ git clone https://github.com/SatoAI-co/Sato.git
84
+ cd Sato
85
+ npm install
86
+ npm run dev # Next.js dev server on :3000
87
+ npm run build:cli # compile CLI to dist/cli/
88
+ ```
89
+
90
+ ## Prerequisites
91
+
92
+ - **Node.js** 18+
93
+ - **Claude Code** CLI (`npm install -g @anthropic-ai/claude-code`)
94
+ - **Anthropic API key** (optional, for AI session classification)
@@ -0,0 +1,35 @@
1
+ import { createOtlpReceiver } from "../lib/otlp-receiver.js";
2
+ import { writePid, readPid, isRunning, removePid } from "../lib/process-manager.js";
3
+ export async function daemonCommand(options) {
4
+ const otlpPort = parseInt(options.otlpPort, 10);
5
+ // Check if already running
6
+ const existing = readPid();
7
+ if (existing && isRunning(existing.pid)) {
8
+ // Another instance is already running — exit silently
9
+ process.exit(0);
10
+ }
11
+ // Clean stale PID
12
+ if (existing)
13
+ removePid();
14
+ // Start OTLP receiver only
15
+ const otlpServer = createOtlpReceiver(otlpPort);
16
+ await new Promise((resolve, reject) => {
17
+ otlpServer.on("error", reject);
18
+ otlpServer.listen(otlpPort, () => resolve());
19
+ });
20
+ // Write PID file in daemon mode
21
+ writePid({
22
+ pid: process.pid,
23
+ otlpPort,
24
+ startedAt: new Date().toISOString(),
25
+ mode: "daemon",
26
+ });
27
+ // Handle shutdown
28
+ const cleanup = () => {
29
+ otlpServer.close();
30
+ removePid();
31
+ process.exit(0);
32
+ };
33
+ process.on("SIGINT", cleanup);
34
+ process.on("SIGTERM", cleanup);
35
+ }
@@ -0,0 +1,40 @@
1
+ import chalk from "chalk";
2
+ import { detectShellProfile, hasSatoConfig, injectEnvVars } from "../../lib/shell-config.js";
3
+ export function setupClaudeCommand() {
4
+ const profilePath = detectShellProfile();
5
+ if (!profilePath) {
6
+ console.error(chalk.red("Could not detect shell profile."), "Manually add the Sato env vars to your shell config.");
7
+ printManualInstructions();
8
+ process.exit(1);
9
+ }
10
+ if (hasSatoConfig(profilePath)) {
11
+ console.log(chalk.green("✓"), `Sato env vars already present in ${chalk.dim(profilePath)}`);
12
+ console.log(chalk.dim(" To update, remove the existing block and re-run this command."));
13
+ return;
14
+ }
15
+ injectEnvVars(profilePath);
16
+ console.log(chalk.green("✓"), `Added Sato env vars to ${chalk.bold(profilePath)}`);
17
+ console.log();
18
+ console.log(` Reload your shell to apply:`);
19
+ console.log(chalk.cyan(` source ${profilePath}`));
20
+ console.log();
21
+ console.log(` Then start Sato and use Claude Code as usual:`);
22
+ console.log(chalk.cyan(` sato start`));
23
+ console.log(chalk.cyan(` claude`));
24
+ console.log();
25
+ }
26
+ function printManualInstructions() {
27
+ console.log();
28
+ console.log(" Add these to your shell profile:");
29
+ console.log();
30
+ console.log(chalk.dim(" # Sato - AI Coding Telemetry"));
31
+ console.log(chalk.dim(" export CLAUDE_CODE_ENABLE_TELEMETRY=1"));
32
+ console.log(chalk.dim(" export OTEL_LOG_USER_PROMPTS=1"));
33
+ console.log(chalk.dim(" export OTEL_LOGS_EXPORTER=otlp"));
34
+ console.log(chalk.dim(" export OTEL_METRICS_EXPORTER=otlp"));
35
+ console.log(chalk.dim(' export OTEL_EXPORTER_OTLP_PROTOCOL=http/json'));
36
+ console.log(chalk.dim(" export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318"));
37
+ console.log(chalk.dim(" export OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta"));
38
+ console.log(chalk.dim(" # End Sato"));
39
+ console.log();
40
+ }
@@ -0,0 +1,10 @@
1
+ import { setupClaudeCommand } from "./claude.js";
2
+ export function registerSetupCommand(program) {
3
+ const setup = program
4
+ .command("setup")
5
+ .description("Configure AI coding tools to send telemetry to Sato");
6
+ setup
7
+ .command("claude")
8
+ .description("Configure Claude Code to export telemetry via OTLP")
9
+ .action(setupClaudeCommand);
10
+ }
@@ -0,0 +1,63 @@
1
+ import { resolve, dirname } from "path";
2
+ import { fileURLToPath } from "url";
3
+ import chalk from "chalk";
4
+ import { createOtlpReceiver } from "../lib/otlp-receiver.js";
5
+ import { writePid, readPid, isRunning, removePid } from "../lib/process-manager.js";
6
+ import { printBanner } from "../lib/banner.js";
7
+ import { detectShellProfile, hasSatoConfig, injectEnvVars } from "../lib/shell-config.js";
8
+ import { installLaunchAgent, isLaunchAgentInstalled } from "../lib/launchd.js";
9
+ export async function startCommand(options) {
10
+ const otlpPort = parseInt(options.otlpPort, 10);
11
+ // Auto-setup: configure Claude Code env vars if not already done
12
+ const profilePath = detectShellProfile();
13
+ if (profilePath && !hasSatoConfig(profilePath)) {
14
+ injectEnvVars(profilePath);
15
+ console.log(chalk.green("✓"), `Configured Claude Code telemetry in ${chalk.dim(profilePath)}`);
16
+ console.log();
17
+ console.log(chalk.yellow("⚠"), chalk.bold("Open a new terminal window before using Claude Code."));
18
+ console.log(chalk.dim(" The telemetry env vars won't take effect in this terminal session."));
19
+ console.log(chalk.dim(` Alternatively, run: source ${profilePath}`));
20
+ console.log();
21
+ }
22
+ // Auto-install LaunchAgent on macOS (so daemon starts on login)
23
+ if (process.platform === "darwin" && !isLaunchAgentInstalled()) {
24
+ const __dirname = dirname(fileURLToPath(import.meta.url));
25
+ const cliPath = resolve(__dirname, "..", "index.js");
26
+ installLaunchAgent(otlpPort, cliPath);
27
+ console.log(chalk.green("✓"), "Installed LaunchAgent — OTLP receiver will auto-start on login");
28
+ }
29
+ // Check if already running
30
+ const existing = readPid();
31
+ if (existing && isRunning(existing.pid)) {
32
+ console.log(chalk.yellow(`Sato is already running (PID ${existing.pid}).`));
33
+ return;
34
+ }
35
+ // Clean stale PID if process is dead
36
+ if (existing && !isRunning(existing.pid)) {
37
+ removePid();
38
+ }
39
+ // Start OTLP receiver
40
+ const otlpServer = createOtlpReceiver(otlpPort);
41
+ await new Promise((resolve, reject) => {
42
+ otlpServer.on("error", reject);
43
+ otlpServer.listen(otlpPort, () => resolve());
44
+ });
45
+ console.log(chalk.green("✓"), `OTLP receiver listening on :${otlpPort}`);
46
+ // Write PID file
47
+ writePid({
48
+ pid: process.pid,
49
+ otlpPort,
50
+ startedAt: new Date().toISOString(),
51
+ mode: "receiver",
52
+ });
53
+ printBanner(otlpPort);
54
+ // Handle shutdown
55
+ const cleanup = () => {
56
+ console.log(chalk.dim("\nShutting down..."));
57
+ otlpServer.close();
58
+ removePid();
59
+ process.exit(0);
60
+ };
61
+ process.on("SIGINT", cleanup);
62
+ process.on("SIGTERM", cleanup);
63
+ }
@@ -0,0 +1,32 @@
1
+ import chalk from "chalk";
2
+ import { readPid, isRunning } from "../lib/process-manager.js";
3
+ import { DATA_DIR, SATOAI_INGEST_URL } from "../lib/paths.js";
4
+ import { isLaunchAgentInstalled } from "../lib/launchd.js";
5
+ export function statusCommand() {
6
+ const info = readPid();
7
+ console.log(chalk.bold("\n Sato Status\n"));
8
+ if (info && isRunning(info.pid)) {
9
+ console.log(` ${chalk.green("●")} Running (PID ${info.pid})`);
10
+ console.log(` OTLP: http://localhost:${info.otlpPort}`);
11
+ console.log(` Forwarding: ${SATOAI_INGEST_URL}/api/ingest`);
12
+ console.log(` Started: ${info.startedAt}`);
13
+ }
14
+ else {
15
+ console.log(` ${chalk.red("●")} Not running`);
16
+ if (info) {
17
+ console.log(chalk.dim(" (Stale PID file found — run 'sato start' to restart)"));
18
+ }
19
+ }
20
+ console.log(chalk.bold("\n LaunchAgent\n"));
21
+ if (isLaunchAgentInstalled()) {
22
+ console.log(` ${chalk.green("●")} Installed — daemon auto-starts on login`);
23
+ }
24
+ else {
25
+ console.log(` ${chalk.dim("○")} Not installed — run 'sato start' to install`);
26
+ }
27
+ console.log(chalk.bold("\n Dashboard\n"));
28
+ console.log(` ${chalk.underline(SATOAI_INGEST_URL)}`);
29
+ console.log(chalk.bold("\n Data\n"));
30
+ console.log(` Directory: ${DATA_DIR}`);
31
+ console.log();
32
+ }
@@ -0,0 +1,32 @@
1
+ import chalk from "chalk";
2
+ import { readPid, isRunning, removePid } from "../lib/process-manager.js";
3
+ import { uninstallLaunchAgent, isLaunchAgentInstalled } from "../lib/launchd.js";
4
+ export function stopCommand(options) {
5
+ const info = readPid();
6
+ if (!info) {
7
+ console.log(chalk.yellow("Sato is not running (no PID file found)."));
8
+ }
9
+ else if (!isRunning(info.pid)) {
10
+ console.log(chalk.yellow(`Sato process (PID ${info.pid}) is no longer running. Cleaning up.`));
11
+ removePid();
12
+ }
13
+ else {
14
+ try {
15
+ process.kill(info.pid, "SIGTERM");
16
+ console.log(chalk.green("✓"), `Stopped Sato (PID ${info.pid})`);
17
+ removePid();
18
+ }
19
+ catch (err) {
20
+ console.error(chalk.red(`Failed to stop process ${info.pid}:`), err);
21
+ }
22
+ }
23
+ if (options.uninstall) {
24
+ if (isLaunchAgentInstalled()) {
25
+ uninstallLaunchAgent();
26
+ console.log(chalk.green("✓"), "Removed LaunchAgent — daemon will no longer auto-start on login");
27
+ }
28
+ else {
29
+ console.log(chalk.dim(" LaunchAgent was not installed, nothing to remove."));
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { startCommand } from "./commands/start.js";
4
+ import { stopCommand } from "./commands/stop.js";
5
+ import { statusCommand } from "./commands/status.js";
6
+ import { daemonCommand } from "./commands/daemon.js";
7
+ import { registerSetupCommand } from "./commands/setup/index.js";
8
+ const program = new Command();
9
+ program
10
+ .name("sato")
11
+ .description("AI Coding Telemetry Collector")
12
+ .version("0.1.0");
13
+ program
14
+ .command("start", { isDefault: true })
15
+ .description("Start the OTLP receiver and forward data to satoai.co")
16
+ .option("--otlp-port <port>", "OTLP receiver port", "4318")
17
+ .action(startCommand);
18
+ program
19
+ .command("stop")
20
+ .description("Stop the running Sato instance")
21
+ .option("--uninstall", "Also remove the LaunchAgent (disables auto-start on login)")
22
+ .action(stopCommand);
23
+ program
24
+ .command("status")
25
+ .description("Show Sato running state")
26
+ .action(statusCommand);
27
+ program
28
+ .command("daemon")
29
+ .description("Start OTLP receiver only (used by LaunchAgent)")
30
+ .option("--otlp-port <port>", "OTLP receiver port", "4318")
31
+ .action(daemonCommand);
32
+ registerSetupCommand(program);
33
+ program.parse();
@@ -0,0 +1,21 @@
1
+ import chalk from "chalk";
2
+ import { SATOAI_INGEST_URL } from "./paths.js";
3
+ export function printBanner(otlpPort) {
4
+ const banner = `
5
+ ${chalk.bold.cyan(" ███████╗ █████╗ ████████╗ ██████╗ ")}
6
+ ${chalk.bold.cyan(" ██╔════╝██╔══██╗╚══██╔══╝██╔═══██╗")}
7
+ ${chalk.bold.cyan(" ███████╗███████║ ██║ ██║ ██║")}
8
+ ${chalk.bold.cyan(" ╚════██║██╔══██║ ██║ ██║ ██║")}
9
+ ${chalk.bold.cyan(" ███████║██║ ██║ ██║ ╚██████╔╝")}
10
+ ${chalk.bold.cyan(" ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ")}
11
+
12
+ ${chalk.dim("AI Coding Telemetry Collector")}
13
+
14
+ ${chalk.green("▸")} Dashboard ${chalk.underline(`${SATOAI_INGEST_URL}`)}
15
+ ${chalk.green("▸")} OTLP receiver ${chalk.dim(`http://localhost:${otlpPort}`)}
16
+ ${chalk.green("▸")} Data dir ${chalk.dim("~/.sato/data/")}
17
+
18
+ ${chalk.dim("Press Ctrl+C to stop")}
19
+ `;
20
+ console.log(banner);
21
+ }
@@ -0,0 +1,59 @@
1
+ import { existsSync, writeFileSync, unlinkSync, mkdirSync } from "fs";
2
+ import { dirname } from "path";
3
+ import { execSync } from "child_process";
4
+ import { LAUNCHD_PLIST, LOG_DIR } from "./paths.js";
5
+ function buildPlist(cliPath, otlpPort) {
6
+ return `<?xml version="1.0" encoding="UTF-8"?>
7
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
8
+ <plist version="1.0">
9
+ <dict>
10
+ <key>Label</key>
11
+ <string>co.sato.receiver</string>
12
+ <key>ProgramArguments</key>
13
+ <array>
14
+ <string>${process.execPath}</string>
15
+ <string>${cliPath}</string>
16
+ <string>daemon</string>
17
+ <string>--otlp-port</string>
18
+ <string>${otlpPort}</string>
19
+ </array>
20
+ <key>RunAtLoad</key>
21
+ <true/>
22
+ <key>KeepAlive</key>
23
+ <true/>
24
+ <key>StandardOutPath</key>
25
+ <string>${LOG_DIR}/daemon.stdout.log</string>
26
+ <key>StandardErrorPath</key>
27
+ <string>${LOG_DIR}/daemon.stderr.log</string>
28
+ </dict>
29
+ </plist>`;
30
+ }
31
+ export function installLaunchAgent(otlpPort, cliPath) {
32
+ mkdirSync(dirname(LAUNCHD_PLIST), { recursive: true });
33
+ mkdirSync(LOG_DIR, { recursive: true });
34
+ const plist = buildPlist(cliPath, otlpPort);
35
+ writeFileSync(LAUNCHD_PLIST, plist);
36
+ try {
37
+ execSync(`launchctl load -w "${LAUNCHD_PLIST}"`, { stdio: "pipe" });
38
+ }
39
+ catch {
40
+ // May already be loaded
41
+ }
42
+ }
43
+ export function uninstallLaunchAgent() {
44
+ try {
45
+ execSync(`launchctl unload "${LAUNCHD_PLIST}"`, { stdio: "pipe" });
46
+ }
47
+ catch {
48
+ // May not be loaded
49
+ }
50
+ try {
51
+ unlinkSync(LAUNCHD_PLIST);
52
+ }
53
+ catch {
54
+ // Already gone
55
+ }
56
+ }
57
+ export function isLaunchAgentInstalled() {
58
+ return existsSync(LAUNCHD_PLIST);
59
+ }
@@ -0,0 +1,71 @@
1
+ import http from "http";
2
+ import { mkdirSync, appendFileSync } from "fs";
3
+ import { dirname } from "path";
4
+ import { LOGS_FILE, METRICS_FILE, SATOAI_INGEST_URL } from "./paths.js";
5
+ const isRemote = !SATOAI_INGEST_URL.includes("localhost");
6
+ function forwardToSatoai(endpoint, body) {
7
+ if (!isRemote)
8
+ return;
9
+ const url = `${SATOAI_INGEST_URL}/api/ingest${endpoint}`;
10
+ fetch(url, {
11
+ method: "POST",
12
+ headers: { "Content-Type": "application/json" },
13
+ body,
14
+ }).catch((err) => {
15
+ console.error(`[forward] Failed to forward to ${url}:`, err.message);
16
+ });
17
+ }
18
+ export function createOtlpReceiver(port) {
19
+ // Ensure data directory exists
20
+ mkdirSync(dirname(LOGS_FILE), { recursive: true });
21
+ const server = http.createServer((req, res) => {
22
+ // CORS headers for browser-based exporters
23
+ res.setHeader("Access-Control-Allow-Origin", "*");
24
+ res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
25
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
26
+ if (req.method === "OPTIONS") {
27
+ res.writeHead(200);
28
+ res.end();
29
+ return;
30
+ }
31
+ if (req.method !== "POST") {
32
+ res.writeHead(405);
33
+ res.end(JSON.stringify({ error: "Method not allowed" }));
34
+ return;
35
+ }
36
+ const chunks = [];
37
+ req.on("data", (chunk) => chunks.push(chunk));
38
+ req.on("end", () => {
39
+ const body = Buffer.concat(chunks).toString("utf-8");
40
+ let targetFile = null;
41
+ let forwardEndpoint = null;
42
+ if (req.url === "/v1/logs") {
43
+ targetFile = LOGS_FILE;
44
+ forwardEndpoint = "/logs";
45
+ }
46
+ else if (req.url === "/v1/metrics") {
47
+ targetFile = METRICS_FILE;
48
+ forwardEndpoint = "/metrics";
49
+ }
50
+ if (!targetFile) {
51
+ // Accept any OTLP endpoint gracefully (traces, etc.)
52
+ res.writeHead(200, { "Content-Type": "application/json" });
53
+ res.end("{}");
54
+ return;
55
+ }
56
+ try {
57
+ appendFileSync(targetFile, body + "\n");
58
+ res.writeHead(200, { "Content-Type": "application/json" });
59
+ res.end("{}");
60
+ // Fire-and-forget forward (only when pointing at a remote server)
61
+ forwardToSatoai(forwardEndpoint, body);
62
+ }
63
+ catch (err) {
64
+ console.error(`Failed to write to ${targetFile}:`, err);
65
+ res.writeHead(500, { "Content-Type": "application/json" });
66
+ res.end(JSON.stringify({ error: "Failed to persist data" }));
67
+ }
68
+ });
69
+ });
70
+ return server;
71
+ }
@@ -0,0 +1,10 @@
1
+ import { homedir } from "os";
2
+ import { join } from "path";
3
+ export const SATO_HOME = join(homedir(), ".sato");
4
+ export const DATA_DIR = join(SATO_HOME, "data");
5
+ export const LOG_DIR = join(SATO_HOME, "logs");
6
+ export const PID_FILE = join(SATO_HOME, "sato.pid");
7
+ export const LOGS_FILE = join(DATA_DIR, "logs.json");
8
+ export const METRICS_FILE = join(DATA_DIR, "metrics.json");
9
+ export const LAUNCHD_PLIST = join(homedir(), "Library", "LaunchAgents", "co.sato.receiver.plist");
10
+ export const SATOAI_INGEST_URL = process.env.SATO_INGEST_URL || "http://localhost:3000";
@@ -0,0 +1,34 @@
1
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "fs";
2
+ import { dirname } from "path";
3
+ import { PID_FILE } from "./paths.js";
4
+ export function writePid(info) {
5
+ mkdirSync(dirname(PID_FILE), { recursive: true });
6
+ writeFileSync(PID_FILE, JSON.stringify(info, null, 2));
7
+ }
8
+ export function readPid() {
9
+ try {
10
+ if (!existsSync(PID_FILE))
11
+ return null;
12
+ return JSON.parse(readFileSync(PID_FILE, "utf-8"));
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
18
+ export function removePid() {
19
+ try {
20
+ unlinkSync(PID_FILE);
21
+ }
22
+ catch {
23
+ // Already gone
24
+ }
25
+ }
26
+ export function isRunning(pid) {
27
+ try {
28
+ process.kill(pid, 0);
29
+ return true;
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
@@ -0,0 +1,55 @@
1
+ import { existsSync, readFileSync, appendFileSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+ const SENTINEL_START = "# Sato - AI Coding Telemetry";
5
+ const SENTINEL_END = "# End Sato";
6
+ const ENV_BLOCK = `
7
+ ${SENTINEL_START}
8
+ export CLAUDE_CODE_ENABLE_TELEMETRY=1
9
+ export OTEL_LOG_USER_PROMPTS=1
10
+ export OTEL_LOGS_EXPORTER=otlp
11
+ export OTEL_METRICS_EXPORTER=otlp
12
+ export OTEL_EXPORTER_OTLP_PROTOCOL=http/json
13
+ export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
14
+ export OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta
15
+ ${SENTINEL_END}
16
+ `;
17
+ export function detectShellProfile() {
18
+ const home = homedir();
19
+ const shell = process.env.SHELL ?? "";
20
+ if (shell.includes("zsh")) {
21
+ const zshrc = join(home, ".zshrc");
22
+ if (existsSync(zshrc))
23
+ return zshrc;
24
+ return zshrc; // Create it if needed
25
+ }
26
+ if (shell.includes("bash")) {
27
+ // Prefer .bashrc, fallback to .bash_profile
28
+ const bashrc = join(home, ".bashrc");
29
+ if (existsSync(bashrc))
30
+ return bashrc;
31
+ const profile = join(home, ".bash_profile");
32
+ if (existsSync(profile))
33
+ return profile;
34
+ return bashrc;
35
+ }
36
+ // Fallback: try common profile files
37
+ for (const name of [".zshrc", ".bashrc", ".bash_profile", ".profile"]) {
38
+ const p = join(home, name);
39
+ if (existsSync(p))
40
+ return p;
41
+ }
42
+ return null;
43
+ }
44
+ export function hasSatoConfig(profilePath) {
45
+ try {
46
+ const content = readFileSync(profilePath, "utf-8");
47
+ return content.includes(SENTINEL_START);
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
53
+ export function injectEnvVars(profilePath) {
54
+ appendFileSync(profilePath, ENV_BLOCK);
55
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "satoai",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "AI Coding Telemetry Collector — captures OpenTelemetry data from Claude Code and forwards to satoai.co",
6
+ "bin": {
7
+ "sato": "./dist/cli/index.js"
8
+ },
9
+ "files": [
10
+ "dist/"
11
+ ],
12
+ "scripts": {
13
+ "dev": "next dev",
14
+ "build": "next build",
15
+ "build:cli": "tsc -p tsconfig.cli.json",
16
+ "prepack": "npm run build:cli",
17
+ "start": "next start",
18
+ "lint": "eslint"
19
+ },
20
+ "dependencies": {
21
+ "chalk": "^5.4.1",
22
+ "commander": "^13.1.0",
23
+ "next": "16.1.6",
24
+ "react": "19.2.3",
25
+ "react-dom": "19.2.3",
26
+ "recharts": "^3.7.0"
27
+ },
28
+ "devDependencies": {
29
+ "@tailwindcss/postcss": "^4",
30
+ "@types/node": "^20",
31
+ "@types/react": "^19",
32
+ "@types/react-dom": "^19",
33
+ "eslint": "^9",
34
+ "eslint-config-next": "16.1.6",
35
+ "tailwindcss": "^4",
36
+ "typescript": "^5"
37
+ }
38
+ }