valistry 0.0.0-stage → 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 +57 -2
- package/bin/valistry.mjs +839 -0
- package/package.json +14 -4
package/README.md
CHANGED
|
@@ -1,3 +1,58 @@
|
|
|
1
|
-
#
|
|
1
|
+
# valistry
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Sets up the AI tools on your computer to send their use to [Valistry](https://valistry.ai). Counts only.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx valistry setup --key <your key> --email you@company.com --desktop
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Your key is on Connect → OpenTelemetry in Valistry. It needs Node 18 or later.
|
|
10
|
+
|
|
11
|
+
If Claude Code already sends to Valistry, you can leave out `--key`. Setup then uses the key and address already in `~/.claude/settings.json`, or else in Codex's `[otel]` table, and says which, with the key's last four characters only. A regional address, such as `/in/api/otlp`, is kept. If neither file has a key, it says so and changes nothing.
|
|
12
|
+
|
|
13
|
+
**It shows every change first and makes none.** Run the same command with `--apply` to make them.
|
|
14
|
+
|
|
15
|
+
## What it changes
|
|
16
|
+
|
|
17
|
+
| Tool | File | Change |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| Claude Code | `~/.claude/settings.json` | Adds the telemetry settings to `"env"` and two hooks (a limit stopped a turn, a session ended). Keeps every other setting. A copy is saved first. |
|
|
20
|
+
| Codex | `~/.codex/config.toml` | Adds an `[otel]` table at the end, with prompts off. Keeps every other line. A copy is saved first. |
|
|
21
|
+
| Desktop apps (`--desktop`) | macOS: `~/Library/LaunchAgents/ai.valistry.desktop.plist`. Windows: a Scheduled Task, "Valistry desktop" | Runs a small observer once a minute. Its files are in `~/.valistry`. |
|
|
22
|
+
|
|
23
|
+
A tool that is not on the computer is skipped. Telemetry that already goes to another address is not replaced unless you add `--force`.
|
|
24
|
+
|
|
25
|
+
Options: `--team <name>` names your team. `--endpoint <url>` sends to another Valistry address (the default is the address found with the key, else `https://app.valistry.ai/api/otlp`). `--no-claude` and `--no-codex` leave a tool alone.
|
|
26
|
+
|
|
27
|
+
## The desktop observer
|
|
28
|
+
|
|
29
|
+
Each minute it asks the computer which app is in front. If it is the Claude desktop app, the ChatGPT desktop app or the Microsoft Copilot app, and the keyboard or mouse was used in the last five minutes, that app gets one minute.
|
|
30
|
+
|
|
31
|
+
It reads:
|
|
32
|
+
|
|
33
|
+
- the front app's bundle id and name (macOS `lsappinfo`), or its process name (Windows);
|
|
34
|
+
- the seconds since the last key or mouse input, as one number (macOS `ioreg`, Windows `GetLastInputInfo`).
|
|
35
|
+
|
|
36
|
+
It sends, every 15 minutes, to `<endpoint>/v1/metrics` with your key:
|
|
37
|
+
|
|
38
|
+
- per day and app, the active minutes (`valistry.desktop.active_minutes`);
|
|
39
|
+
- you, as a code: a hash of your email, made on your computer;
|
|
40
|
+
- your team, if you named one.
|
|
41
|
+
|
|
42
|
+
**Never read or sent:** window titles, what is on the screen, which keys you type, prompts, replies, files, and your email itself.
|
|
43
|
+
|
|
44
|
+
Windows support is written but not yet run on Windows.
|
|
45
|
+
|
|
46
|
+
## Status and undo
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
npx valistry status # what is set up, and the last send
|
|
50
|
+
npx valistry remove # shows what it would undo
|
|
51
|
+
npx valistry remove --apply # stops the observer, deletes its files, puts your settings files back
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
A settings file you have not changed since setup gets its copy back as it was. A file you changed since loses only what setup added.
|
|
55
|
+
|
|
56
|
+
## Read it before you run it
|
|
57
|
+
|
|
58
|
+
`bin/valistry.mjs` is one file, not minified, with no dependencies. It is built from `packages/cli/src` in the Valistry repository.
|
package/bin/valistry.mjs
ADDED
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// valistry 0.1.0: sets up this computer's AI tools to send their use to Valistry. Built from packages/cli/src in the
|
|
3
|
+
// Valistry repository by packages/cli/build.mjs: do not edit. Counts only: never prompts, replies, code, window titles,
|
|
4
|
+
// screen contents or keys typed.
|
|
5
|
+
|
|
6
|
+
// packages/cli/src/main.ts
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
10
|
+
import { existsSync as existsSync2, rmSync as rmSync2 } from "node:fs";
|
|
11
|
+
|
|
12
|
+
// packages/cli/src/setup.ts
|
|
13
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, rmSync, copyFileSync, chmodSync } from "node:fs";
|
|
15
|
+
import { basename, dirname, join as join2 } from "node:path";
|
|
16
|
+
|
|
17
|
+
// packages/connectors/src/adapters/telemetry-env.ts
|
|
18
|
+
var CLAUDE_CODE_ENV = (endpoint, key) => [
|
|
19
|
+
["CLAUDE_CODE_ENABLE_TELEMETRY", "1"],
|
|
20
|
+
["OTEL_METRICS_EXPORTER", "otlp"],
|
|
21
|
+
["OTEL_LOGS_EXPORTER", "otlp"],
|
|
22
|
+
["OTEL_EXPORTER_OTLP_PROTOCOL", "http/json"],
|
|
23
|
+
["OTEL_EXPORTER_OTLP_ENDPOINT", endpoint],
|
|
24
|
+
["OTEL_EXPORTER_OTLP_HEADERS", `Authorization=Bearer ${key}`],
|
|
25
|
+
["OTEL_METRICS_INCLUDE_SESSION_ID", "true"]
|
|
26
|
+
];
|
|
27
|
+
var teamAttribute = (team) => `team.id=${team.trim().replace(/[,=\s]+/g, "-").slice(0, 64)}`;
|
|
28
|
+
var CLAUDE_CODE_HOOKS = (endpoint, key) => ({
|
|
29
|
+
StopFailure: [{ hooks: [{ type: "http", url: `${endpoint}/hooks/claude-code`, headers: { Authorization: `Bearer ${key}` } }] }],
|
|
30
|
+
SessionEnd: [{ hooks: [{ type: "http", url: `${endpoint}/hooks/claude-code`, headers: { Authorization: `Bearer ${key}` }, timeout: 10 }] }]
|
|
31
|
+
});
|
|
32
|
+
var CODEX_OTEL = (endpoint, key, person) => [
|
|
33
|
+
"[otel]",
|
|
34
|
+
"log_user_prompt = false",
|
|
35
|
+
`exporter = { otlp-http = { endpoint = "${endpoint}/v1/logs", protocol = "json", headers = { "Authorization" = "Bearer ${key}", "x-valistry-person" = "${person}" } } }`,
|
|
36
|
+
`metrics_exporter = { otlp-http = { endpoint = "${endpoint}/v1/metrics", protocol = "json", headers = { "Authorization" = "Bearer ${key}", "x-valistry-person" = "${person}" } } }`
|
|
37
|
+
].join("\n");
|
|
38
|
+
|
|
39
|
+
// packages/cli/src/claude.ts
|
|
40
|
+
var ENDPOINT = "OTEL_EXPORTER_OTLP_ENDPOINT";
|
|
41
|
+
var ATTRS = "OTEL_RESOURCE_ATTRIBUTES";
|
|
42
|
+
var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
43
|
+
var OUR_ENV = [...CLAUDE_CODE_ENV("", "").map(([k]) => k), ATTRS];
|
|
44
|
+
function parseSettings(text) {
|
|
45
|
+
if (text === null || !text.trim()) return {};
|
|
46
|
+
let v;
|
|
47
|
+
try {
|
|
48
|
+
v = JSON.parse(text);
|
|
49
|
+
} catch {
|
|
50
|
+
throw new Error("~/.claude/settings.json is not valid JSON. Fix it, then run this again. Nothing was changed.");
|
|
51
|
+
}
|
|
52
|
+
if (!isObj(v)) throw new Error("~/.claude/settings.json is not a JSON object. Fix it, then run this again. Nothing was changed.");
|
|
53
|
+
return v;
|
|
54
|
+
}
|
|
55
|
+
var printSettings = (o) => `${JSON.stringify(o, null, 2)}
|
|
56
|
+
`;
|
|
57
|
+
var ours = (h) => isObj(h) && h.type === "http" && typeof h.url === "string" && /\/hooks\/claude-code$/.test(h.url) && isObj(h.headers) && typeof h.headers.Authorization === "string" && /^Bearer vly_ot_/.test(h.headers.Authorization);
|
|
58
|
+
function withoutOurHooks(hooks) {
|
|
59
|
+
if (!isObj(hooks)) return hooks === void 0 ? void 0 : hooks;
|
|
60
|
+
const out = {};
|
|
61
|
+
for (const [event, groups] of Object.entries(hooks)) {
|
|
62
|
+
if (!Array.isArray(groups)) {
|
|
63
|
+
out[event] = groups;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const kept = groups.map((g) => isObj(g) && Array.isArray(g.hooks) ? { ...g, hooks: g.hooks.filter((h) => !ours(h)) } : g).filter((g) => !(isObj(g) && Array.isArray(g.hooks) && g.hooks.length === 0));
|
|
67
|
+
if (kept.length) out[event] = kept;
|
|
68
|
+
}
|
|
69
|
+
return Object.keys(out).length ? out : void 0;
|
|
70
|
+
}
|
|
71
|
+
function withTeam(current, team) {
|
|
72
|
+
const rest = typeof current === "string" ? current.split(",").map((s) => s.trim()).filter((s) => s && !/^team\.id=/.test(s)) : [];
|
|
73
|
+
return [...rest, teamAttribute(team)].join(",");
|
|
74
|
+
}
|
|
75
|
+
function mergeClaude(text, o) {
|
|
76
|
+
const s = parseSettings(text);
|
|
77
|
+
const env = isObj(s.env) ? { ...s.env } : {};
|
|
78
|
+
const was = env[ENDPOINT];
|
|
79
|
+
if (typeof was === "string" && was && was !== o.endpoint && !o.force)
|
|
80
|
+
return { after: text ?? "", added: [], changed: [], hooks: [], conflict: `Claude Code already sends telemetry to ${was}. Add --force to send it to Valistry instead.` };
|
|
81
|
+
const added = [], changed = [];
|
|
82
|
+
const want = [...CLAUDE_CODE_ENV(o.endpoint, o.key).map(([k, v]) => [k, v]), ...o.team ? [[ATTRS, withTeam(env[ATTRS], o.team)]] : []];
|
|
83
|
+
for (const [k, v] of want) {
|
|
84
|
+
if (!(k in env)) added.push(k);
|
|
85
|
+
else if (env[k] !== v) changed.push(k);
|
|
86
|
+
env[k] = v;
|
|
87
|
+
}
|
|
88
|
+
const hooks = withoutOurHooks(s.hooks) ?? {};
|
|
89
|
+
const mine = CLAUDE_CODE_HOOKS(o.endpoint, o.key);
|
|
90
|
+
for (const [event, groups] of Object.entries(mine)) hooks[event] = [...Array.isArray(hooks[event]) ? hooks[event] : [], ...groups];
|
|
91
|
+
const after = printSettings({ ...s, env, hooks });
|
|
92
|
+
return { after, added, changed, hooks: Object.keys(mine) };
|
|
93
|
+
}
|
|
94
|
+
function unmergeClaude(text, backup) {
|
|
95
|
+
const s = parseSettings(text);
|
|
96
|
+
const b = (() => {
|
|
97
|
+
try {
|
|
98
|
+
return parseSettings(backup);
|
|
99
|
+
} catch {
|
|
100
|
+
return {};
|
|
101
|
+
}
|
|
102
|
+
})();
|
|
103
|
+
const before = isObj(b.env) ? b.env : {};
|
|
104
|
+
const env = isObj(s.env) ? { ...s.env } : {};
|
|
105
|
+
for (const k of OUR_ENV) {
|
|
106
|
+
if (k in before) env[k] = before[k];
|
|
107
|
+
else delete env[k];
|
|
108
|
+
}
|
|
109
|
+
const out = { ...s };
|
|
110
|
+
if (Object.keys(env).length) out.env = env;
|
|
111
|
+
else delete out.env;
|
|
112
|
+
const hadOurs = JSON.stringify(withoutOurHooks(b.hooks) ?? null) !== JSON.stringify(isObj(b.hooks) ? b.hooks : null);
|
|
113
|
+
const hooks = hadOurs ? s.hooks : withoutOurHooks(s.hooks);
|
|
114
|
+
if (hooks) out.hooks = hooks;
|
|
115
|
+
else delete out.hooks;
|
|
116
|
+
return printSettings(out);
|
|
117
|
+
}
|
|
118
|
+
function claudeConfigured(text, endpoint) {
|
|
119
|
+
try {
|
|
120
|
+
const env = parseSettings(text).env;
|
|
121
|
+
return isObj(env) && env.CLAUDE_CODE_ENABLE_TELEMETRY === "1" && typeof env[ENDPOINT] === "string" && (!endpoint || env[ENDPOINT] === endpoint);
|
|
122
|
+
} catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// packages/cli/src/codex.ts
|
|
128
|
+
var BEGIN = "# Valistry telemetry, added by npx valistry. Remove it with: npx valistry remove --apply";
|
|
129
|
+
var END = "# End of Valistry telemetry.";
|
|
130
|
+
var lines = (t) => (t ?? "").split(/\r?\n/);
|
|
131
|
+
function withoutOurs(text) {
|
|
132
|
+
const out = [];
|
|
133
|
+
let inside = false;
|
|
134
|
+
for (const l of lines(text)) {
|
|
135
|
+
if (l === BEGIN) {
|
|
136
|
+
inside = true;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (inside) {
|
|
140
|
+
if (l === END) inside = false;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
out.push(l);
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
var header = (l) => /^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(#.*)?$/.exec(l)?.[1];
|
|
148
|
+
var isOtel = (name) => name !== void 0 && (name === "otel" || name.startsWith("otel."));
|
|
149
|
+
var otelLines = (ls) => ls.reduce((a, l) => {
|
|
150
|
+
const h = header(l);
|
|
151
|
+
if (h !== void 0) a.on = isOtel(h);
|
|
152
|
+
if (a.on) a.out.push(l);
|
|
153
|
+
return a;
|
|
154
|
+
}, { on: false, out: [] }).out;
|
|
155
|
+
var isValistrys = (ls) => ls.some((l) => /"Bearer vly_ot_/.test(l));
|
|
156
|
+
var hasOtherOtel = (text) => {
|
|
157
|
+
const t = otelLines(withoutOurs(text));
|
|
158
|
+
return t.length > 0 && !isValistrys(t);
|
|
159
|
+
};
|
|
160
|
+
var hasOtel = (text) => otelLines(withoutOurs(text)).length > 0;
|
|
161
|
+
function withoutOtel(ls) {
|
|
162
|
+
const out = [];
|
|
163
|
+
let skipping = false;
|
|
164
|
+
for (const l of ls) {
|
|
165
|
+
const h = header(l);
|
|
166
|
+
if (h !== void 0) skipping = isOtel(h);
|
|
167
|
+
if (!skipping) out.push(l);
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
var tidy = (ls) => {
|
|
172
|
+
const t = ls.join("\n").replace(/\n{3,}/g, "\n\n").replace(/\s+$/, "");
|
|
173
|
+
return t ? `${t}
|
|
174
|
+
` : "";
|
|
175
|
+
};
|
|
176
|
+
function addCodex(text, o) {
|
|
177
|
+
if (hasOtherOtel(text) && !o.force) return { after: text ?? "", replaced: false, conflict: "Codex already has an [otel] table in ~/.codex/config.toml. Add --force to replace it with Valistry's." };
|
|
178
|
+
const other = hasOtel(text);
|
|
179
|
+
const base = other ? withoutOtel(withoutOurs(text)) : withoutOurs(text);
|
|
180
|
+
const head = tidy(base);
|
|
181
|
+
return { after: `${head}${head ? "\n" : ""}${BEGIN}
|
|
182
|
+
${CODEX_OTEL(o.endpoint, o.key, o.person)}
|
|
183
|
+
${END}
|
|
184
|
+
`, replaced: other };
|
|
185
|
+
}
|
|
186
|
+
function removeCodex(text, backup) {
|
|
187
|
+
const kept = tidy(withoutOurs(text));
|
|
188
|
+
const theirs = otelLines(lines(backup));
|
|
189
|
+
if (!theirs.length || hasOtel(kept)) return kept;
|
|
190
|
+
return `${kept}${kept ? "\n" : ""}${tidy(theirs)}`;
|
|
191
|
+
}
|
|
192
|
+
var codexConfigured = (text) => lines(text).includes(BEGIN);
|
|
193
|
+
|
|
194
|
+
// packages/cli/src/desktop.ts
|
|
195
|
+
import { createHash } from "node:crypto";
|
|
196
|
+
|
|
197
|
+
// packages/core/src/desktopUse.ts
|
|
198
|
+
var DESKTOP_APPS = ["claude", "chatgpt", "copilot"];
|
|
199
|
+
var DESKTOP_METRIC = "valistry.desktop.active_minutes";
|
|
200
|
+
|
|
201
|
+
// packages/cli/src/desktop.ts
|
|
202
|
+
var IDLE_SECONDS = 300;
|
|
203
|
+
var SEND_EVERY_MS = 15 * 6e4;
|
|
204
|
+
var KEEP_DAYS = 10;
|
|
205
|
+
function appOfMac(bundle = "", name = "") {
|
|
206
|
+
const b = bundle.toLowerCase(), n = name.trim().toLowerCase();
|
|
207
|
+
if (b === "com.anthropic.claudefordesktop" || b.startsWith("com.anthropic.") && n === "claude") return "claude";
|
|
208
|
+
if (b === "com.openai.chat" || b.startsWith("com.openai.") && n === "chatgpt") return "chatgpt";
|
|
209
|
+
if (b.startsWith("com.microsoft.") && /copilot/.test(`${b} ${n}`)) return "copilot";
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
function appOfWindows(process2 = "") {
|
|
213
|
+
const p = process2.trim().toLowerCase().replace(/\.exe$/, "");
|
|
214
|
+
if (p === "claude") return "claude";
|
|
215
|
+
if (p === "chatgpt") return "chatgpt";
|
|
216
|
+
if (/^(microsoft\.)?(m365)?copilot(\.app)?$/.test(p)) return "copilot";
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
function activeApp(s, platform) {
|
|
220
|
+
const app = platform === "win32" ? appOfWindows(s.process) : appOfMac(s.bundle, s.name);
|
|
221
|
+
if (!app) return null;
|
|
222
|
+
return s.idleSeconds !== null && s.idleSeconds >= IDLE_SECONDS ? null : app;
|
|
223
|
+
}
|
|
224
|
+
var personCode = (email) => createHash("sha256").update(email.trim().toLowerCase()).digest("hex").slice(0, 16);
|
|
225
|
+
var localDay = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
226
|
+
var emptyState = () => ({ days: {}, sent: {} });
|
|
227
|
+
function record(state, app, now) {
|
|
228
|
+
if (state.lastSample && now.getTime() - Date.parse(state.lastSample) < 45e3) return state;
|
|
229
|
+
const day = localDay(now);
|
|
230
|
+
const d = { ...state.days[day] ?? { watched: 0 } };
|
|
231
|
+
d.watched += 1;
|
|
232
|
+
if (app) d[app] = (d[app] ?? 0) + 1;
|
|
233
|
+
const keepFrom = localDay(new Date(now.getTime() - KEEP_DAYS * 864e5));
|
|
234
|
+
const days = Object.fromEntries(Object.entries({ ...state.days, [day]: d }).filter(([k]) => k >= keepFrom));
|
|
235
|
+
const sent = Object.fromEntries(Object.entries(state.sent).filter(([k]) => k >= keepFrom));
|
|
236
|
+
return { ...state, days, sent, lastSample: now.toISOString() };
|
|
237
|
+
}
|
|
238
|
+
function unsent(state) {
|
|
239
|
+
const out = [];
|
|
240
|
+
for (const [day, d] of Object.entries(state.days).sort(([a], [b]) => a.localeCompare(b))) {
|
|
241
|
+
if (!(d.watched > 0)) continue;
|
|
242
|
+
for (const app of DESKTOP_APPS) {
|
|
243
|
+
const minutes = d[app] ?? 0;
|
|
244
|
+
if (state.sent[day]?.[app] !== minutes) out.push({ day, app, minutes });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
function sendDue(state, now) {
|
|
250
|
+
if (!state.lastTry) return true;
|
|
251
|
+
return localDay(new Date(state.lastTry)) !== localDay(now) || now.getTime() - Date.parse(state.lastTry) >= SEND_EVERY_MS;
|
|
252
|
+
}
|
|
253
|
+
function markSent(state, points) {
|
|
254
|
+
const sent = { ...state.sent };
|
|
255
|
+
for (const p of points) sent[p.day] = { ...sent[p.day] ?? {}, [p.app]: p.minutes };
|
|
256
|
+
return { ...state, sent };
|
|
257
|
+
}
|
|
258
|
+
var attr = (key, v) => ({ key, value: { stringValue: v } });
|
|
259
|
+
var nano = (ms) => `${BigInt(Math.round(ms)) * 1000000n}`;
|
|
260
|
+
var dayStart = (day) => {
|
|
261
|
+
const [y, m, d] = day.split("-").map(Number);
|
|
262
|
+
return new Date(y, m - 1, d).getTime();
|
|
263
|
+
};
|
|
264
|
+
function payload(points, who, now, version) {
|
|
265
|
+
return {
|
|
266
|
+
resourceMetrics: [{
|
|
267
|
+
resource: { attributes: [attr("service.name", "valistry-desktop"), attr("service.version", version)] },
|
|
268
|
+
scopeMetrics: [{ scope: { name: "valistry-desktop", version }, metrics: [{
|
|
269
|
+
name: DESKTOP_METRIC,
|
|
270
|
+
unit: "min",
|
|
271
|
+
sum: { aggregationTemporality: 2, isMonotonic: true, dataPoints: points.map((p) => ({
|
|
272
|
+
attributes: [attr("app", p.app), attr("day", p.day), attr("person", who.person), ...who.team ? [attr("team", who.team)] : []],
|
|
273
|
+
startTimeUnixNano: nano(dayStart(p.day)),
|
|
274
|
+
timeUnixNano: nano(Math.min(now.getTime(), dayStart(p.day) + 864e5 - 1)),
|
|
275
|
+
asInt: String(p.minutes)
|
|
276
|
+
})) }
|
|
277
|
+
}] }]
|
|
278
|
+
}]
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// packages/cli/src/system.ts
|
|
283
|
+
import { execFileSync } from "node:child_process";
|
|
284
|
+
import { join } from "node:path";
|
|
285
|
+
var run = (cmd2, args) => execFileSync(cmd2, args, { encoding: "utf8", timeout: 1e4, stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
|
|
286
|
+
function sampleMac(r = run) {
|
|
287
|
+
let bundle, name, idleSeconds = null;
|
|
288
|
+
try {
|
|
289
|
+
const asn = r("lsappinfo", ["front"]).trim();
|
|
290
|
+
if (/^ASN:/.test(asn)) {
|
|
291
|
+
const info = r("lsappinfo", ["info", "-only", "bundleid", "-only", "name", asn]);
|
|
292
|
+
bundle = /"CFBundleIdentifier"="([^"]*)"/.exec(info)?.[1];
|
|
293
|
+
name = /"LSDisplayName"="([^"]*)"/.exec(info)?.[1];
|
|
294
|
+
}
|
|
295
|
+
} catch {
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
const ns = /"HIDIdleTime" = (\d+)/.exec(r("ioreg", ["-c", "IOHIDSystem", "-d", "4"]))?.[1];
|
|
299
|
+
if (ns) idleSeconds = Math.floor(Number(ns) / 1e9);
|
|
300
|
+
} catch {
|
|
301
|
+
}
|
|
302
|
+
return { bundle, name, idleSeconds };
|
|
303
|
+
}
|
|
304
|
+
var WINDOWS_PROBE = [
|
|
305
|
+
"Add-Type -TypeDefinition @'",
|
|
306
|
+
"using System; using System.Runtime.InteropServices;",
|
|
307
|
+
"public static class ValistryProbe {",
|
|
308
|
+
' [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();',
|
|
309
|
+
' [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint p);',
|
|
310
|
+
" [StructLayout(LayoutKind.Sequential)] public struct LastInput { public uint cbSize; public uint dwTime; }",
|
|
311
|
+
' [DllImport("user32.dll")] public static extern bool GetLastInputInfo(ref LastInput l);',
|
|
312
|
+
"}",
|
|
313
|
+
"'@",
|
|
314
|
+
"$p = 0; [void][ValistryProbe]::GetWindowThreadProcessId([ValistryProbe]::GetForegroundWindow(), [ref]$p)",
|
|
315
|
+
"$l = New-Object ValistryProbe+LastInput; $l.cbSize = 8; [void][ValistryProbe]::GetLastInputInfo([ref]$l)",
|
|
316
|
+
"$idle = [math]::Floor(([uint32][Environment]::TickCount - $l.dwTime) / 1000)",
|
|
317
|
+
"$n = (Get-Process -Id $p -ErrorAction SilentlyContinue).ProcessName",
|
|
318
|
+
'Write-Output "$n|$idle"'
|
|
319
|
+
].join("\n");
|
|
320
|
+
function sampleWindows(r = run) {
|
|
321
|
+
try {
|
|
322
|
+
const [process2, idle] = r("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", WINDOWS_PROBE]).trim().split("|");
|
|
323
|
+
const n = Number(idle);
|
|
324
|
+
return { process: process2, idleSeconds: Number.isFinite(n) && idle !== "" ? n : null };
|
|
325
|
+
} catch {
|
|
326
|
+
return { idleSeconds: null };
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
var LABEL = "ai.valistry.desktop";
|
|
330
|
+
var TASK = "Valistry desktop";
|
|
331
|
+
var xml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
332
|
+
function launchAgent(node, script, log) {
|
|
333
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
334
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
335
|
+
<plist version="1.0">
|
|
336
|
+
<dict>
|
|
337
|
+
<key>Label</key><string>${LABEL}</string>
|
|
338
|
+
<key>ProgramArguments</key>
|
|
339
|
+
<array><string>${xml(node)}</string><string>${xml(script)}</string><string>observe</string></array>
|
|
340
|
+
<key>StartInterval</key><integer>60</integer>
|
|
341
|
+
<key>RunAtLoad</key><true/>
|
|
342
|
+
<key>ProcessType</key><string>Background</string>
|
|
343
|
+
<key>LowPriorityIO</key><true/>
|
|
344
|
+
<key>StandardOutPath</key><string>${xml(log)}</string>
|
|
345
|
+
<key>StandardErrorPath</key><string>${xml(log)}</string>
|
|
346
|
+
</dict>
|
|
347
|
+
</plist>
|
|
348
|
+
`;
|
|
349
|
+
}
|
|
350
|
+
var launchAgentPath = (home) => join(home, "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
351
|
+
var macStart = (uid, plist) => [["launchctl", ["bootstrap", `gui/${uid}`, plist]]];
|
|
352
|
+
var macStop = (uid) => [["launchctl", ["bootout", `gui/${uid}/${LABEL}`]]];
|
|
353
|
+
var windowsLauncher = (node, script) => `' Starts the Valistry desktop observer with no window. Written by npx valistry.\r
|
|
354
|
+
CreateObject("WScript.Shell").Run """${node}"" ""${script}"" observe", 0, False\r
|
|
355
|
+
`;
|
|
356
|
+
var windowsStart = (vbs) => [["schtasks", ["/Create", "/F", "/SC", "MINUTE", "/MO", "1", "/TN", TASK, "/TR", `wscript.exe "${vbs}"`]]];
|
|
357
|
+
var windowsStop = () => [["schtasks", ["/Delete", "/F", "/TN", TASK]]];
|
|
358
|
+
|
|
359
|
+
// packages/cli/src/setup.ts
|
|
360
|
+
var DEFAULT_ENDPOINT = "https://app.valistry.ai/api/otlp";
|
|
361
|
+
var pathsOf = (home) => {
|
|
362
|
+
const dir = join2(home, ".valistry");
|
|
363
|
+
return {
|
|
364
|
+
home,
|
|
365
|
+
dir,
|
|
366
|
+
backups: join2(dir, "backups"),
|
|
367
|
+
manifest: join2(dir, "setup.json"),
|
|
368
|
+
config: join2(dir, "desktop.json"),
|
|
369
|
+
state: join2(dir, "desktop-state.json"),
|
|
370
|
+
script: join2(dir, "valistry.mjs"),
|
|
371
|
+
log: join2(dir, "desktop.log"),
|
|
372
|
+
vbs: join2(dir, "observer.vbs"),
|
|
373
|
+
plist: launchAgentPath(home),
|
|
374
|
+
claudeDir: join2(home, ".claude"),
|
|
375
|
+
claude: join2(home, ".claude", "settings.json"),
|
|
376
|
+
codexDir: join2(home, ".codex"),
|
|
377
|
+
codex: join2(home, ".codex", "config.toml")
|
|
378
|
+
};
|
|
379
|
+
};
|
|
380
|
+
var tilde = (p, home) => p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
|
381
|
+
var sha = (t) => createHash2("sha256").update(t).digest("hex");
|
|
382
|
+
var read = (p) => {
|
|
383
|
+
try {
|
|
384
|
+
return readFileSync(p, "utf8");
|
|
385
|
+
} catch {
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
var readJson = (p) => {
|
|
390
|
+
const t = read(p);
|
|
391
|
+
if (t === null) return null;
|
|
392
|
+
try {
|
|
393
|
+
return JSON.parse(t);
|
|
394
|
+
} catch {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
function writeAtomic(p, text, mode) {
|
|
399
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
400
|
+
const tmp = `${p}.valistry-${process.pid}`;
|
|
401
|
+
writeFileSync(tmp, text, mode ? { mode } : void 0);
|
|
402
|
+
renameSync(tmp, p);
|
|
403
|
+
if (mode) chmodSync(p, mode);
|
|
404
|
+
}
|
|
405
|
+
var KEY = /^vly_ot_.+_[0-9a-f]{32,}$/;
|
|
406
|
+
var lastFour = (key) => `\u2022\u2022${key.slice(-4)}`;
|
|
407
|
+
function foundKey(home) {
|
|
408
|
+
const p = pathsOf(home);
|
|
409
|
+
try {
|
|
410
|
+
const env = JSON.parse(read(p.claude) ?? "{}").env ?? {};
|
|
411
|
+
const k2 = /Bearer\s+(vly_ot_\S+)/.exec(String(env.OTEL_EXPORTER_OTLP_HEADERS ?? ""))?.[1];
|
|
412
|
+
const e = typeof env.OTEL_EXPORTER_OTLP_ENDPOINT === "string" ? env.OTEL_EXPORTER_OTLP_ENDPOINT.trim() : "";
|
|
413
|
+
if (k2 && KEY.test(k2)) return { key: k2, ...e ? { endpoint: e } : {}, from: "~/.claude/settings.json" };
|
|
414
|
+
} catch {
|
|
415
|
+
}
|
|
416
|
+
const t = read(p.codex) ?? "";
|
|
417
|
+
const k = /"Authorization"\s*=\s*"Bearer\s+(vly_ot_[^"\s]+)"/.exec(t)?.[1];
|
|
418
|
+
if (k && KEY.test(k)) {
|
|
419
|
+
const e = /endpoint\s*=\s*"([^"]+?)\/v1\/(?:logs|metrics|traces)"/.exec(t)?.[1];
|
|
420
|
+
return { key: k, ...e ? { endpoint: e } : {}, from: "~/.codex/config.toml" };
|
|
421
|
+
}
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
var cmd = ([c, args]) => ({ cmd: c, args });
|
|
425
|
+
function setupPlan(o) {
|
|
426
|
+
const p = pathsOf(o.home);
|
|
427
|
+
const changes = [];
|
|
428
|
+
const notes = [];
|
|
429
|
+
const errors = [];
|
|
430
|
+
const found = o.key?.trim() ? null : foundKey(o.home);
|
|
431
|
+
const key = (o.key ?? found?.key ?? "").trim();
|
|
432
|
+
if (!key) errors.push("No key found. Make one in Valistry \u2192 Connect \u2192 Claude Code, then pass --key.");
|
|
433
|
+
else if (!KEY.test(key)) errors.push("That is not a Valistry ingest key. It starts with vly_ot_.");
|
|
434
|
+
const endpoint = (o.endpoint ?? found?.endpoint ?? DEFAULT_ENDPOINT).trim().replace(/\/+$/, "");
|
|
435
|
+
if (!/^https:\/\//.test(endpoint) && !/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?(\/|$)/.test(endpoint)) errors.push("The endpoint must start with https://.");
|
|
436
|
+
const using = found && !errors.length ? `Using the key already in ${found.from} (${lastFour(key)}), sending to ${endpoint}.` : void 0;
|
|
437
|
+
const email = (o.email ?? "").trim();
|
|
438
|
+
const team = o.team?.trim() || void 0;
|
|
439
|
+
if (errors.length) return { changes, notes, errors };
|
|
440
|
+
const prior = readJson(p.manifest);
|
|
441
|
+
if (o.claude !== false) {
|
|
442
|
+
if (!existsSync(p.claudeDir)) notes.push("Claude Code: not found on this computer (no ~/.claude). Skipped.");
|
|
443
|
+
else {
|
|
444
|
+
const before = read(p.claude);
|
|
445
|
+
try {
|
|
446
|
+
const m = mergeClaude(before, { endpoint, key, team, force: o.force });
|
|
447
|
+
if (m.conflict) errors.push(m.conflict);
|
|
448
|
+
else if (m.after === before) notes.push("Claude Code: already set up. No change.");
|
|
449
|
+
else changes.push({ kind: "write", path: p.claude, before, after: m.after, theirs: true, title: "Claude Code settings", lines: [
|
|
450
|
+
...m.added.length ? [`Adds to "env": ${m.added.join(", ")}.`] : [],
|
|
451
|
+
...m.changed.length ? [`Changes in "env": ${m.changed.join(", ")}.`] : [],
|
|
452
|
+
`Adds the ${m.hooks.join(" and ")} hooks. They send when a limit stops a turn and when a session ends.`,
|
|
453
|
+
"Keeps every other setting."
|
|
454
|
+
] });
|
|
455
|
+
} catch (e) {
|
|
456
|
+
errors.push(e.message);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (o.codex !== false) {
|
|
461
|
+
if (!existsSync(p.codexDir)) notes.push("Codex: not found on this computer (no ~/.codex). Skipped.");
|
|
462
|
+
else if (!email) notes.push("Codex: skipped. It needs your work email: add --email you@company.com.");
|
|
463
|
+
else {
|
|
464
|
+
const before = read(p.codex);
|
|
465
|
+
const a = addCodex(before, { endpoint, key, person: email, force: o.force });
|
|
466
|
+
if (a.conflict) errors.push(a.conflict);
|
|
467
|
+
else if (a.after === before) notes.push("Codex: already set up. No change.");
|
|
468
|
+
else changes.push({ kind: "write", path: p.codex, before, after: a.after, theirs: true, title: "Codex settings", lines: [
|
|
469
|
+
`${a.replaced ? "Replaces your [otel] table with" : "Adds"} an [otel] table at the end. Prompts stay off (log_user_prompt = false).`,
|
|
470
|
+
"Keeps every other line."
|
|
471
|
+
] });
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (o.desktop) {
|
|
475
|
+
if (o.platform !== "darwin" && o.platform !== "win32") errors.push("The desktop observer runs on macOS and Windows only.");
|
|
476
|
+
else if (!email) errors.push("The desktop observer needs your work email, to count you as one person: add --email you@company.com.");
|
|
477
|
+
else {
|
|
478
|
+
const config = { endpoint, key, person: personCode(email), ...team ? { team } : {}, version: o.version };
|
|
479
|
+
const shown = `Each minute it checks which app is in front. It counts the Claude, ChatGPT and Microsoft Copilot apps.`;
|
|
480
|
+
changes.push({ kind: "write", path: p.script, before: read(p.script), after: read(o.self) ?? "", title: "The observer", lines: ["A copy of this command, so it runs after npx is gone."] });
|
|
481
|
+
changes.push({ kind: "write", path: p.config, before: read(p.config), after: `${JSON.stringify(config, null, 2)}
|
|
482
|
+
`, secret: true, title: "Its settings", lines: [
|
|
483
|
+
`The address, your key, and your email as a code (${config.person}). Only you can read the file.`
|
|
484
|
+
] });
|
|
485
|
+
if (o.platform === "darwin") {
|
|
486
|
+
const plist = launchAgent(o.node, p.script, p.log);
|
|
487
|
+
if (existsSync(p.plist)) changes.push({ kind: "run", ...cmd(macStop(o.uid)[0]), title: "Stops the observer that is running", mayFail: true });
|
|
488
|
+
changes.push({ kind: "write", path: p.plist, before: read(p.plist), after: plist, title: "A LaunchAgent", lines: [shown, "It starts again when you log in."] });
|
|
489
|
+
changes.push({ kind: "run", ...cmd(macStart(o.uid, p.plist)[0]), title: "Starts it now" });
|
|
490
|
+
} else {
|
|
491
|
+
changes.push({ kind: "write", path: p.vbs, before: read(p.vbs), after: windowsLauncher(o.node, p.script), title: "A launcher with no window", lines: [shown] });
|
|
492
|
+
changes.push({ kind: "run", ...cmd(windowsStart(p.vbs)[0]), title: "A Scheduled Task, each minute, as you" });
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
if (!changes.length && !errors.length && !notes.length) notes.push("Nothing to set up.");
|
|
497
|
+
return { changes, notes, errors, manifest: prior ?? void 0, key, endpoint, using };
|
|
498
|
+
}
|
|
499
|
+
var stamp = (d) => d.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
|
|
500
|
+
function applyPlan(plan, o) {
|
|
501
|
+
const p = pathsOf(o.home);
|
|
502
|
+
const done = [];
|
|
503
|
+
const prior = readJson(p.manifest);
|
|
504
|
+
const files = new Map((prior?.files ?? []).map((f) => [f.path, f]));
|
|
505
|
+
for (const c of plan.changes) {
|
|
506
|
+
if (c.kind === "write") {
|
|
507
|
+
const known = files.get(c.path);
|
|
508
|
+
let backup = known?.backup ?? null;
|
|
509
|
+
if (!known && c.theirs && c.before !== null) {
|
|
510
|
+
mkdirSync(p.backups, { recursive: true });
|
|
511
|
+
backup = join2(p.backups, `${basename(dirname(c.path)).replace(/^\./, "")}-${basename(c.path)}.${stamp(o.now)}`);
|
|
512
|
+
copyFileSync(c.path, backup);
|
|
513
|
+
done.push(`Saved a copy of ${tilde(c.path, o.home)} to ${tilde(backup, o.home)}.`);
|
|
514
|
+
}
|
|
515
|
+
writeAtomic(c.path, c.after, c.secret ? 384 : void 0);
|
|
516
|
+
files.set(c.path, { path: c.path, backup: known ? known.backup : backup, sha: sha(c.after), theirs: Boolean(c.theirs) });
|
|
517
|
+
done.push(`Wrote ${tilde(c.path, o.home)}.`);
|
|
518
|
+
} else if (c.kind === "delete") {
|
|
519
|
+
rmSync(c.path, { force: true });
|
|
520
|
+
done.push(`Removed ${tilde(c.path, o.home)}.`);
|
|
521
|
+
} else {
|
|
522
|
+
try {
|
|
523
|
+
o.run(c.cmd, c.args);
|
|
524
|
+
done.push(`Ran ${c.cmd} ${c.args.join(" ")}.`);
|
|
525
|
+
} catch (e) {
|
|
526
|
+
if (!c.mayFail) throw new Error(`${c.cmd} ${c.args.join(" ")} failed: ${e.message.split("\n")[0]}`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
const stop = o.stop ?? prior?.stop ?? [];
|
|
531
|
+
const manifest = { version: o.version, at: o.now.toISOString(), endpoint: o.endpoint, platform: o.platform, files: [...files.values()], stop };
|
|
532
|
+
writeAtomic(p.manifest, `${JSON.stringify(manifest, null, 2)}
|
|
533
|
+
`);
|
|
534
|
+
return done;
|
|
535
|
+
}
|
|
536
|
+
var stopsFor = (plan, o) => plan.changes.some((c) => c.kind === "run" && !c.mayFail) ? o.platform === "darwin" ? macStop(o.uid) : windowsStop() : void 0;
|
|
537
|
+
function removePlan(home) {
|
|
538
|
+
const p = pathsOf(home);
|
|
539
|
+
const m = readJson(p.manifest);
|
|
540
|
+
if (!m) return { changes: [], notes: ["Nothing to remove: this computer has no Valistry setup."], errors: [] };
|
|
541
|
+
const changes = [];
|
|
542
|
+
for (const [c, args] of m.stop) changes.push({ kind: "run", cmd: c, args, title: "Stops the observer", mayFail: true });
|
|
543
|
+
for (const f of m.files) {
|
|
544
|
+
const now = read(f.path);
|
|
545
|
+
if (!f.theirs) {
|
|
546
|
+
if (now !== null) changes.push({ kind: "delete", path: f.path, title: `Deletes ${basename(f.path)}`, lines: [] });
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
const backup = f.backup ? read(f.backup) : null;
|
|
550
|
+
const label = f.path.endsWith("config.toml") ? "Codex settings" : "Claude Code settings";
|
|
551
|
+
if (now === null) continue;
|
|
552
|
+
if (sha(now) === f.sha) {
|
|
553
|
+
if (f.backup === null) changes.push({ kind: "delete", path: f.path, title: label, lines: ["Deletes it: there was no file before setup."] });
|
|
554
|
+
else changes.push({ kind: "write", path: f.path, before: now, after: backup ?? now, title: label, lines: [`Puts back the copy from ${tilde(f.backup, home)}.`] });
|
|
555
|
+
} else {
|
|
556
|
+
const after = f.path.endsWith("config.toml") ? removeCodex(now, backup) : unmergeClaude(now, backup);
|
|
557
|
+
changes.push({ kind: "write", path: f.path, before: now, after, title: label, lines: ["It changed after setup, so only what setup added is taken out. Every other change is kept."] });
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
for (const extra of [p.state, p.log]) if (existsSync(extra)) changes.push({ kind: "delete", path: extra, title: `Deletes ${basename(extra)}`, lines: [] });
|
|
561
|
+
changes.push({ kind: "delete", path: p.manifest, title: "Deletes setup.json", lines: [] });
|
|
562
|
+
return { changes, notes: m.files.some((f) => f.backup) ? [`The copies stay in ${tilde(p.backups, home)}.`] : [], errors: [] };
|
|
563
|
+
}
|
|
564
|
+
var ago = (iso, now) => {
|
|
565
|
+
const m = Math.max(0, Math.round((now.getTime() - Date.parse(iso)) / 6e4));
|
|
566
|
+
return m < 1 ? "just now" : m < 120 ? `${m} min ago` : `${Math.round(m / 60)} hours ago`;
|
|
567
|
+
};
|
|
568
|
+
function status(home, now, running) {
|
|
569
|
+
const p = pathsOf(home);
|
|
570
|
+
const m = readJson(p.manifest);
|
|
571
|
+
const lines2 = [];
|
|
572
|
+
if (!m) return { lines: ["Not set up on this computer. Run: npx valistry setup --key <your key>"] };
|
|
573
|
+
lines2.push(`Set up ${m.at.slice(0, 16).replace("T", " ")} UTC, sending to ${m.endpoint}.`);
|
|
574
|
+
lines2.push(`Claude Code: ${claudeConfigured(read(p.claude), m.endpoint) ? "on" : m.files.some((f) => f.path === p.claude) ? "changed since setup: not sending here" : "not set up"}.`);
|
|
575
|
+
lines2.push(`Codex: ${codexConfigured(read(p.codex)) ? "on" : "not set up"}.`);
|
|
576
|
+
const config = readJson(p.config);
|
|
577
|
+
if (!config) lines2.push("Desktop apps: not set up. Add --desktop to count them.");
|
|
578
|
+
else {
|
|
579
|
+
const s = readJson(p.state) ?? emptyState();
|
|
580
|
+
lines2.push(`Desktop apps: ${running === false ? "set up, but the observer is not running" : "on"}. Your code: ${config.person}${config.team ? `, team ${config.team}` : ""}.`);
|
|
581
|
+
const today = Object.entries(s.days).sort(([a], [b]) => b.localeCompare(a))[0];
|
|
582
|
+
if (today) lines2.push(`${today[0]}: Claude ${today[1].claude ?? 0} min, ChatGPT ${today[1].chatgpt ?? 0} min, Copilot ${today[1].copilot ?? 0} min, of ${today[1].watched} min watched.`);
|
|
583
|
+
const l = s.lastSend;
|
|
584
|
+
lines2.push(!l ? "Last send: none yet. It sends within 15 minutes." : l.ok ? `Last send: ${ago(l.at, now)}, received.` : `Last send: ${ago(l.at, now)}, failed${l.status ? ` (${l.status})` : ""}${l.message ? `: ${l.message}` : ""}.`);
|
|
585
|
+
}
|
|
586
|
+
return { lines: lines2 };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// packages/cli/src/observe.ts
|
|
590
|
+
async function observeOnce(o) {
|
|
591
|
+
const p = pathsOf(o.home);
|
|
592
|
+
const config = readJson(p.config);
|
|
593
|
+
if (!config) return null;
|
|
594
|
+
const s0 = readJson(p.state) ?? emptyState();
|
|
595
|
+
const sample = o.sample ?? (o.platform === "win32" ? () => sampleWindows() : () => sampleMac());
|
|
596
|
+
let s = record(s0, activeApp(sample(), o.platform), o.now);
|
|
597
|
+
if (o.force || sendDue(s, o.now)) s = await sendNow(s, config, o.now, o.send ?? fetch);
|
|
598
|
+
writeAtomic(p.state, `${JSON.stringify(s)}
|
|
599
|
+
`);
|
|
600
|
+
return s;
|
|
601
|
+
}
|
|
602
|
+
async function sendNow(s, config, now, send) {
|
|
603
|
+
const points = unsent(s);
|
|
604
|
+
if (!points.length) return { ...s, lastTry: now.toISOString() };
|
|
605
|
+
const body = JSON.stringify(payload(points, { person: config.person, team: config.team }, now, config.version));
|
|
606
|
+
try {
|
|
607
|
+
const r = await send(`${config.endpoint}/v1/metrics`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${config.key}` }, body });
|
|
608
|
+
const text = await r.text().catch(() => "");
|
|
609
|
+
const message = (() => {
|
|
610
|
+
try {
|
|
611
|
+
return String(JSON.parse(text)?.partialSuccess?.errorMessage ?? "").slice(0, 200);
|
|
612
|
+
} catch {
|
|
613
|
+
return "";
|
|
614
|
+
}
|
|
615
|
+
})();
|
|
616
|
+
const ok = r.status >= 200 && r.status < 300;
|
|
617
|
+
const next = ok ? markSent(s, points) : s;
|
|
618
|
+
return { ...next, lastTry: now.toISOString(), lastSend: { at: now.toISOString(), ok, status: r.status, ...message && !ok ? { message } : {} } };
|
|
619
|
+
} catch (e) {
|
|
620
|
+
return { ...s, lastTry: now.toISOString(), lastSend: { at: now.toISOString(), ok: false, message: `Could not reach ${config.endpoint}: ${e.message}`.slice(0, 200) } };
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// packages/cli/src/main.ts
|
|
625
|
+
var VERSION = true ? "0.1.0" : "0.0.0";
|
|
626
|
+
var HELP = `valistry ${VERSION}: sets up this computer's AI tools to send their use to Valistry. Counts only.
|
|
627
|
+
|
|
628
|
+
npx valistry setup [--key <key>] [--email you@company.com] [--team <name>] [--desktop] [--apply]
|
|
629
|
+
Claude Code: adds the telemetry settings to ~/.claude/settings.json (a copy is saved first).
|
|
630
|
+
Codex: adds an [otel] table to ~/.codex/config.toml (a copy is saved first).
|
|
631
|
+
--desktop: counts the minutes the Claude, ChatGPT and Microsoft Copilot apps are in front (macOS, Windows).
|
|
632
|
+
Shows every change first. Add --apply to make them.
|
|
633
|
+
If Claude Code already sends to Valistry, you can leave out --key: its key and address are used.
|
|
634
|
+
Other options: --endpoint <url> (default ${DEFAULT_ENDPOINT}), --no-claude, --no-codex, --force.
|
|
635
|
+
|
|
636
|
+
npx valistry status What is set up, and the last send.
|
|
637
|
+
npx valistry remove [--apply] Undoes everything setup did, and puts your files back.
|
|
638
|
+
|
|
639
|
+
Never read or sent: prompts, replies, code, window titles, screen contents, keys you type.`;
|
|
640
|
+
function parseArgs(argv) {
|
|
641
|
+
const [cmd2 = "help", ...rest] = argv;
|
|
642
|
+
const flags = {};
|
|
643
|
+
for (let i = 0; i < rest.length; i++) {
|
|
644
|
+
const a = rest[i];
|
|
645
|
+
if (!a.startsWith("--")) continue;
|
|
646
|
+
const [name, inline] = a.slice(2).split(/=(.*)/s, 2);
|
|
647
|
+
if (inline !== void 0) flags[name] = inline;
|
|
648
|
+
else if (rest[i + 1] !== void 0 && !rest[i + 1].startsWith("--")) flags[name] = rest[++i];
|
|
649
|
+
else flags[name] = true;
|
|
650
|
+
}
|
|
651
|
+
return { cmd: cmd2, flags };
|
|
652
|
+
}
|
|
653
|
+
var str = (v) => typeof v === "string" ? v : void 0;
|
|
654
|
+
var gitEmail = () => {
|
|
655
|
+
try {
|
|
656
|
+
return execFileSync2("git", ["config", "--global", "user.email"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5e3 }).trim() || void 0;
|
|
657
|
+
} catch {
|
|
658
|
+
return void 0;
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
var masked = (text, key) => key && key.length > 12 ? text.split(key).join(lastFour(key)) : text;
|
|
662
|
+
function describe(plan, home, key) {
|
|
663
|
+
const out = [];
|
|
664
|
+
plan.changes.forEach((c, i) => {
|
|
665
|
+
const where = c.kind === "run" ? `runs: ${c.cmd} ${c.args.map((a) => tilde(a, home)).map((a) => /\s/.test(a) ? JSON.stringify(a) : a).join(" ")}` : tilde(c.path, home);
|
|
666
|
+
out.push(`${i + 1}. ${c.title} ${where}`);
|
|
667
|
+
if (c.kind === "write" && c.theirs && c.before !== null) out.push(" A copy is saved first, in ~/.valistry/backups.");
|
|
668
|
+
for (const l of c.kind === "run" ? [] : c.lines) out.push(` ${l}`);
|
|
669
|
+
});
|
|
670
|
+
for (const n of plan.notes) out.push(n);
|
|
671
|
+
return out.map((l) => masked(l, key));
|
|
672
|
+
}
|
|
673
|
+
var SENDS = [
|
|
674
|
+
"What is sent: from Claude Code and Codex, counts (tokens, sessions, lines, commits, errors); from the desktop apps, minutes per app per day.",
|
|
675
|
+
"Never read or sent: prompts, replies, code, window titles, screen contents, keys you type. Your email is sent only as a code."
|
|
676
|
+
];
|
|
677
|
+
async function main(argv, io = { log: console.log, err: console.error }) {
|
|
678
|
+
const { cmd: cmd2, flags } = parseArgs(argv);
|
|
679
|
+
const home = str(flags.home) ?? process.env.VALISTRY_HOME ?? homedir();
|
|
680
|
+
const platform = process.platform;
|
|
681
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
|
|
682
|
+
const now = /* @__PURE__ */ new Date();
|
|
683
|
+
const apply = flags.apply === true;
|
|
684
|
+
if (cmd2 === "setup") {
|
|
685
|
+
const key = str(flags.key) ?? process.env.VALISTRY_INGEST_KEY;
|
|
686
|
+
const endpoint = str(flags.endpoint)?.replace(/\/+$/, "");
|
|
687
|
+
const plan = setupPlan({
|
|
688
|
+
key,
|
|
689
|
+
endpoint,
|
|
690
|
+
team: str(flags.team),
|
|
691
|
+
email: str(flags.email) ?? gitEmail(),
|
|
692
|
+
desktop: flags.desktop === true,
|
|
693
|
+
claude: flags["no-claude"] !== true,
|
|
694
|
+
codex: flags["no-codex"] !== true,
|
|
695
|
+
force: flags.force === true,
|
|
696
|
+
home,
|
|
697
|
+
platform,
|
|
698
|
+
node: process.execPath,
|
|
699
|
+
uid,
|
|
700
|
+
version: VERSION,
|
|
701
|
+
self: fileURLToPath(import.meta.url)
|
|
702
|
+
});
|
|
703
|
+
if (plan.errors.length) {
|
|
704
|
+
for (const e of plan.errors) io.err(masked(e, key));
|
|
705
|
+
io.err("Nothing was changed.");
|
|
706
|
+
return 1;
|
|
707
|
+
}
|
|
708
|
+
const say = () => {
|
|
709
|
+
if (plan.using) io.log(masked(plan.using, plan.key));
|
|
710
|
+
};
|
|
711
|
+
if (!plan.changes.length) {
|
|
712
|
+
say();
|
|
713
|
+
for (const l of describe(plan, home, plan.key)) io.log(l);
|
|
714
|
+
return 0;
|
|
715
|
+
}
|
|
716
|
+
if (!apply) {
|
|
717
|
+
io.log("Valistry setup, a dry run. Nothing has changed yet.\n");
|
|
718
|
+
if (plan.using) {
|
|
719
|
+
say();
|
|
720
|
+
io.log("");
|
|
721
|
+
}
|
|
722
|
+
for (const l of describe(plan, home, plan.key)) io.log(l);
|
|
723
|
+
io.log("");
|
|
724
|
+
for (const l of SENDS) io.log(l);
|
|
725
|
+
io.log("\nTo make these changes, run the same command with --apply.\nTo undo them later: npx valistry remove --apply");
|
|
726
|
+
return 0;
|
|
727
|
+
}
|
|
728
|
+
say();
|
|
729
|
+
try {
|
|
730
|
+
for (const l of applyPlan(plan, { home, run, now, version: VERSION, endpoint: plan.endpoint ?? DEFAULT_ENDPOINT, platform, stop: stopsFor(plan, { platform, uid }) })) io.log(masked(l, plan.key));
|
|
731
|
+
} catch (e) {
|
|
732
|
+
io.err(masked(e.message, plan.key));
|
|
733
|
+
io.err("Run npx valistry status to see what is set up.");
|
|
734
|
+
return 1;
|
|
735
|
+
}
|
|
736
|
+
io.log("\nDone. Start a new Claude Code or Codex session to begin sending.");
|
|
737
|
+
if (flags.desktop === true && platform === "darwin") io.log("The observer runs each minute. Check it with: npx valistry status");
|
|
738
|
+
return 0;
|
|
739
|
+
}
|
|
740
|
+
if (cmd2 === "remove") {
|
|
741
|
+
const plan = removePlan(home);
|
|
742
|
+
if (!plan.changes.length) {
|
|
743
|
+
for (const n of plan.notes) io.log(n);
|
|
744
|
+
return 0;
|
|
745
|
+
}
|
|
746
|
+
if (!apply) {
|
|
747
|
+
io.log("Valistry remove, a dry run. Nothing has changed yet.\n");
|
|
748
|
+
for (const l of describe(plan, home)) io.log(l);
|
|
749
|
+
io.log("\nTo undo the setup, run: npx valistry remove --apply");
|
|
750
|
+
return 0;
|
|
751
|
+
}
|
|
752
|
+
try {
|
|
753
|
+
for (const l of applyRemove(plan, home)) io.log(l);
|
|
754
|
+
} catch (e) {
|
|
755
|
+
io.err(e.message);
|
|
756
|
+
return 1;
|
|
757
|
+
}
|
|
758
|
+
for (const n of plan.notes) io.log(n);
|
|
759
|
+
io.log("Done. Start new Claude Code and Codex sessions to stop sending.");
|
|
760
|
+
return 0;
|
|
761
|
+
}
|
|
762
|
+
if (cmd2 === "status") {
|
|
763
|
+
for (const l of status(home, now, observerRunning(home, platform, uid)).lines) io.log(l);
|
|
764
|
+
return 0;
|
|
765
|
+
}
|
|
766
|
+
if (cmd2 === "observe") {
|
|
767
|
+
if (flags.print === true) {
|
|
768
|
+
const s = platform === "win32" ? sampleWindows() : sampleMac();
|
|
769
|
+
io.log(`In front: ${activeApp(s, platform) ?? "none of the three apps"}. Seconds since last input: ${s.idleSeconds ?? "unknown"}.`);
|
|
770
|
+
return 0;
|
|
771
|
+
}
|
|
772
|
+
try {
|
|
773
|
+
await observeOnce({ home, platform, now });
|
|
774
|
+
} catch (e) {
|
|
775
|
+
io.err(`valistry observe: ${e.message}`);
|
|
776
|
+
}
|
|
777
|
+
return 0;
|
|
778
|
+
}
|
|
779
|
+
if (cmd2 === "send") {
|
|
780
|
+
const p = pathsOf(home);
|
|
781
|
+
const config = readJson(p.config);
|
|
782
|
+
if (!config) {
|
|
783
|
+
io.err("The desktop observer is not set up.");
|
|
784
|
+
return 1;
|
|
785
|
+
}
|
|
786
|
+
const s = await sendNow(readJson(p.state) ?? emptyState(), config, now, fetch);
|
|
787
|
+
writeAtomic(p.state, `${JSON.stringify(s)}
|
|
788
|
+
`);
|
|
789
|
+
io.log(s.lastSend ? s.lastSend.ok ? "Sent." : `Not sent${s.lastSend.status ? ` (${s.lastSend.status})` : ""}: ${s.lastSend.message ?? ""}` : "Nothing new to send.");
|
|
790
|
+
return s.lastSend && !s.lastSend.ok ? 1 : 0;
|
|
791
|
+
}
|
|
792
|
+
if (cmd2 === "version" || flags.version === true) {
|
|
793
|
+
io.log(VERSION);
|
|
794
|
+
return 0;
|
|
795
|
+
}
|
|
796
|
+
io.log(HELP);
|
|
797
|
+
return cmd2 === "help" || flags.help === true ? 0 : 1;
|
|
798
|
+
}
|
|
799
|
+
function applyRemove(plan, home, r = run) {
|
|
800
|
+
const done = [];
|
|
801
|
+
for (const c of plan.changes) {
|
|
802
|
+
if (c.kind === "run") {
|
|
803
|
+
try {
|
|
804
|
+
r(c.cmd, c.args);
|
|
805
|
+
done.push(`Ran ${c.cmd} ${c.args.join(" ")}.`);
|
|
806
|
+
} catch {
|
|
807
|
+
}
|
|
808
|
+
} else if (c.kind === "delete") {
|
|
809
|
+
rmSync2(c.path, { force: true });
|
|
810
|
+
done.push(`Removed ${tilde(c.path, home)}.`);
|
|
811
|
+
} else {
|
|
812
|
+
writeAtomic(c.path, c.after);
|
|
813
|
+
done.push(`Put back ${tilde(c.path, home)}.`);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
return done;
|
|
817
|
+
}
|
|
818
|
+
function observerRunning(home, platform, uid) {
|
|
819
|
+
if (!existsSync2(pathsOf(home).config)) return void 0;
|
|
820
|
+
try {
|
|
821
|
+
if (platform === "darwin") {
|
|
822
|
+
run("launchctl", ["print", `gui/${uid}/${LABEL}`]);
|
|
823
|
+
return true;
|
|
824
|
+
}
|
|
825
|
+
if (platform === "win32") {
|
|
826
|
+
run("schtasks", ["/Query", "/TN", TASK]);
|
|
827
|
+
return true;
|
|
828
|
+
}
|
|
829
|
+
} catch {
|
|
830
|
+
return false;
|
|
831
|
+
}
|
|
832
|
+
return void 0;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// packages/cli/src/cli.ts
|
|
836
|
+
main(process.argv.slice(2)).then((code) => process.exit(code), (e) => {
|
|
837
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
838
|
+
process.exit(1);
|
|
839
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "valistry",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
}
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Sets up Claude Code, Codex and the Claude, ChatGPT and Copilot desktop apps to send their use to Valistry. Counts only: never prompts, replies, window titles, screen contents or keystrokes.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "valistry": "bin/valistry.mjs" },
|
|
7
|
+
"files": ["bin", "README.md"],
|
|
8
|
+
"engines": { "node": ">=18" },
|
|
9
|
+
"license": "UNLICENSED",
|
|
10
|
+
"homepage": "https://valistry.ai",
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "node build.mjs",
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"prepack": "node build.mjs --check"
|
|
15
|
+
}
|
|
16
|
+
}
|