@blastin-dev/clocktopus-cli 0.1.3 → 0.2.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 +124 -6
- package/dist/src/commands/agent/disable.d.ts +14 -0
- package/dist/src/commands/agent/disable.d.ts.map +1 -0
- package/dist/src/commands/agent/disable.js +72 -0
- package/dist/src/commands/agent/doctor.d.ts +2 -0
- package/dist/src/commands/agent/doctor.d.ts.map +1 -0
- package/dist/src/commands/agent/doctor.js +235 -0
- package/dist/src/commands/agent/hook.d.ts +2 -0
- package/dist/src/commands/agent/hook.d.ts.map +1 -0
- package/dist/src/commands/agent/hook.js +231 -0
- package/dist/src/commands/agent/setup.d.ts +21 -0
- package/dist/src/commands/agent/setup.d.ts.map +1 -0
- package/dist/src/commands/agent/setup.js +194 -0
- package/dist/src/commands/agent/status.d.ts +2 -0
- package/dist/src/commands/agent/status.d.ts.map +1 -0
- package/dist/src/commands/agent/status.js +160 -0
- package/dist/src/commands/clock.d.ts +26 -4
- package/dist/src/commands/clock.d.ts.map +1 -1
- package/dist/src/commands/clock.js +99 -6
- package/dist/src/commands/login.d.ts.map +1 -1
- package/dist/src/commands/login.js +5 -5
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +45 -6
- package/dist/src/lib/agent-config.d.ts +41 -0
- package/dist/src/lib/agent-config.d.ts.map +1 -0
- package/dist/src/lib/agent-config.js +143 -0
- package/dist/src/lib/agent-hook-state.d.ts +36 -0
- package/dist/src/lib/agent-hook-state.d.ts.map +1 -0
- package/dist/src/lib/agent-hook-state.js +136 -0
- package/dist/src/lib/agent-receiver.d.ts +26 -0
- package/dist/src/lib/agent-receiver.d.ts.map +1 -0
- package/dist/src/lib/agent-receiver.js +44 -0
- package/dist/src/lib/api.d.ts.map +1 -1
- package/dist/src/lib/api.js +28 -1
- package/dist/src/lib/claude-settings.d.ts +82 -0
- package/dist/src/lib/claude-settings.d.ts.map +1 -0
- package/dist/src/lib/claude-settings.js +271 -0
- package/dist/src/lib/claude-settings.test.d.ts +2 -0
- package/dist/src/lib/claude-settings.test.d.ts.map +1 -0
- package/dist/src/lib/claude-settings.test.js +193 -0
- package/dist/src/lib/config.d.ts +23 -0
- package/dist/src/lib/config.d.ts.map +1 -1
- package/dist/src/lib/config.js +14 -0
- package/dist/src/lib/format.d.ts +6 -0
- package/dist/src/lib/format.d.ts.map +1 -0
- package/dist/src/lib/format.js +19 -0
- package/dist/src/lib/git.d.ts +3 -0
- package/dist/src/lib/git.d.ts.map +1 -0
- package/dist/src/lib/git.js +30 -0
- package/dist/src/lib/repo-guidance.d.ts +40 -0
- package/dist/src/lib/repo-guidance.d.ts.map +1 -0
- package/dist/src/lib/repo-guidance.js +123 -0
- package/dist/src/lib/validators.d.ts +69 -0
- package/dist/src/lib/validators.d.ts.map +1 -1
- package/dist/src/lib/validators.js +69 -0
- package/package.json +7 -5
|
@@ -16,8 +16,30 @@ const ClockSignalResponseSchema = z.object({
|
|
|
16
16
|
signalTimestamp: z.string(),
|
|
17
17
|
alreadyExists: z.boolean(),
|
|
18
18
|
blockedReason: z.enum(["already_clocked_in", "not_clocked_in"]).nullable(),
|
|
19
|
+
clamped: z.boolean().optional(),
|
|
20
|
+
requestedTime: z.string().nullable().optional(),
|
|
21
|
+
reopened: z.boolean().optional(),
|
|
19
22
|
}),
|
|
20
23
|
});
|
|
24
|
+
/**
|
|
25
|
+
* Parses "15m", "1h", "1h30m" → whole minutes. Mirrors
|
|
26
|
+
* `packages/core/src/utils/duration.ts` (the CLI is a standalone package
|
|
27
|
+
* and doesn't depend on @repo/core).
|
|
28
|
+
*/
|
|
29
|
+
function parseDurationToMinutes(input) {
|
|
30
|
+
const trimmed = input.trim().toLowerCase();
|
|
31
|
+
const match = /^(?:(\d+)h)?(?:(\d+)m)?$/.exec(trimmed);
|
|
32
|
+
if (!match || (!match[1] && !match[2])) {
|
|
33
|
+
throw new Error(`Invalid duration "${input}". Use formats like 15m, 1h, or 1h30m.`);
|
|
34
|
+
}
|
|
35
|
+
const hours = match[1] ? Number(match[1]) : 0;
|
|
36
|
+
const minutes = match[2] ? Number(match[2]) : 0;
|
|
37
|
+
const total = hours * 60 + minutes;
|
|
38
|
+
if (total <= 0) {
|
|
39
|
+
throw new Error(`Duration must be greater than zero: "${input}".`);
|
|
40
|
+
}
|
|
41
|
+
return total;
|
|
42
|
+
}
|
|
21
43
|
/**
|
|
22
44
|
* Schema for clock signals list response
|
|
23
45
|
*/
|
|
@@ -44,14 +66,43 @@ function formatSignalType(type) {
|
|
|
44
66
|
return type === "clock_in" ? "Clock In" : "Clock Out";
|
|
45
67
|
}
|
|
46
68
|
/**
|
|
47
|
-
* Record a clock-in signal for today
|
|
69
|
+
* Record a clock-in signal for today.
|
|
70
|
+
*
|
|
71
|
+
* Accepts the same backdate flags as `clock out`:
|
|
72
|
+
* --ago <duration> e.g. "45m", "1h", "1h30m"
|
|
73
|
+
* --at <HH:mm> absolute wall-clock time in the user's timezone
|
|
74
|
+
*
|
|
75
|
+
* The server silently clamps the requested time if it would fall outside
|
|
76
|
+
* the allowed window (before start-of-today, before the day's last
|
|
77
|
+
* clock-out, or after the first existing time entry on the day).
|
|
48
78
|
*/
|
|
49
|
-
export async function clockInCommand() {
|
|
79
|
+
export async function clockInCommand(options = {}) {
|
|
50
80
|
if (!checkAuth()) {
|
|
51
81
|
process.exit(1);
|
|
52
82
|
}
|
|
83
|
+
if (options.ago && options.at) {
|
|
84
|
+
console.error("Cannot use --ago and --at together. Pick one.");
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
const body = { signalType: "clock_in" };
|
|
88
|
+
if (options.ago) {
|
|
89
|
+
try {
|
|
90
|
+
body.minutesAgo = parseDurationToMinutes(options.ago);
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (options.at) {
|
|
98
|
+
if (!/^\d{2}:\d{2}(:\d{2})?$/.test(options.at)) {
|
|
99
|
+
console.error(`Invalid time "${options.at}". Use HH:mm or HH:mm:ss format.`);
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
body.effectiveTime = options.at;
|
|
103
|
+
}
|
|
53
104
|
try {
|
|
54
|
-
const data = await post("/api/clock-signal",
|
|
105
|
+
const data = await post("/api/clock-signal", body);
|
|
55
106
|
const response = ClockSignalResponseSchema.parse(data);
|
|
56
107
|
if (response.signal.alreadyExists) {
|
|
57
108
|
console.log(`\nAlready clocked in - must clock out first.`);
|
|
@@ -60,11 +111,21 @@ export async function clockInCommand() {
|
|
|
60
111
|
console.log(` Time: ${response.signal.effectiveTime}`);
|
|
61
112
|
console.log(` Timezone: ${response.signal.timezone}`);
|
|
62
113
|
}
|
|
114
|
+
else if (response.signal.reopened) {
|
|
115
|
+
console.log(`\nPrevious session reopened — clock-out removed.`);
|
|
116
|
+
console.log(` Clocked in since:`);
|
|
117
|
+
console.log(` Date: ${response.signal.workDate}`);
|
|
118
|
+
console.log(` Time: ${response.signal.effectiveTime}`);
|
|
119
|
+
console.log(` Timezone: ${response.signal.timezone}`);
|
|
120
|
+
}
|
|
63
121
|
else {
|
|
64
122
|
console.log(`\nClock In recorded!`);
|
|
65
123
|
console.log(` Date: ${response.signal.workDate}`);
|
|
66
124
|
console.log(` Time: ${response.signal.effectiveTime}`);
|
|
67
125
|
console.log(` Timezone: ${response.signal.timezone}`);
|
|
126
|
+
if (response.signal.clamped && response.signal.requestedTime) {
|
|
127
|
+
console.log(` Adjusted: you asked for ${response.signal.requestedTime}, but that's outside the allowed window for this day.`);
|
|
128
|
+
}
|
|
68
129
|
}
|
|
69
130
|
}
|
|
70
131
|
catch (error) {
|
|
@@ -83,14 +144,43 @@ export async function clockInCommand() {
|
|
|
83
144
|
}
|
|
84
145
|
}
|
|
85
146
|
/**
|
|
86
|
-
* Record a clock-out signal for today
|
|
147
|
+
* Record a clock-out signal for today.
|
|
148
|
+
*
|
|
149
|
+
* Accepts optional backdate flags:
|
|
150
|
+
* --ago <duration> e.g. "45m", "1h", "1h30m"
|
|
151
|
+
* --at <HH:mm> absolute wall-clock time in the user's timezone
|
|
152
|
+
*
|
|
153
|
+
* The two flags are mutually exclusive. The server silently clamps the
|
|
154
|
+
* requested time if it would predate the session's clock-in or the
|
|
155
|
+
* session's last time entry.
|
|
87
156
|
*/
|
|
88
|
-
export async function clockOutCommand() {
|
|
157
|
+
export async function clockOutCommand(options = {}) {
|
|
89
158
|
if (!checkAuth()) {
|
|
90
159
|
process.exit(1);
|
|
91
160
|
}
|
|
161
|
+
if (options.ago && options.at) {
|
|
162
|
+
console.error("Cannot use --ago and --at together. Pick one.");
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
const body = { signalType: "clock_out" };
|
|
166
|
+
if (options.ago) {
|
|
167
|
+
try {
|
|
168
|
+
body.minutesAgo = parseDurationToMinutes(options.ago);
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (options.at) {
|
|
176
|
+
if (!/^\d{2}:\d{2}(:\d{2})?$/.test(options.at)) {
|
|
177
|
+
console.error(`Invalid time "${options.at}". Use HH:mm or HH:mm:ss format.`);
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
body.effectiveTime = options.at;
|
|
181
|
+
}
|
|
92
182
|
try {
|
|
93
|
-
const data = await post("/api/clock-signal",
|
|
183
|
+
const data = await post("/api/clock-signal", body);
|
|
94
184
|
const response = ClockSignalResponseSchema.parse(data);
|
|
95
185
|
if (response.signal.alreadyExists) {
|
|
96
186
|
console.log(`\nNot clocked in - must clock in first.`);
|
|
@@ -106,6 +196,9 @@ export async function clockOutCommand() {
|
|
|
106
196
|
console.log(` Date: ${response.signal.workDate}`);
|
|
107
197
|
console.log(` Time: ${response.signal.effectiveTime}`);
|
|
108
198
|
console.log(` Timezone: ${response.signal.timezone}`);
|
|
199
|
+
if (response.signal.clamped && response.signal.requestedTime) {
|
|
200
|
+
console.log(` Adjusted: you asked for ${response.signal.requestedTime}, but that's before this session's earliest allowed time.`);
|
|
201
|
+
}
|
|
109
202
|
}
|
|
110
203
|
}
|
|
111
204
|
catch (error) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"login.d.ts","sourceRoot":"","sources":["../../../src/commands/login.ts"],"names":[],"mappings":"AAKA,wBAAsB,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC,
|
|
1
|
+
{"version":3,"file":"login.d.ts","sourceRoot":"","sources":["../../../src/commands/login.ts"],"names":[],"mappings":"AAKA,wBAAsB,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC,CAmFlD"}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { get } from "../lib/api.js";
|
|
2
2
|
import { pollForToken, requestDeviceCode, sleep } from "../lib/auth.js";
|
|
3
|
-
import {
|
|
3
|
+
import { setToken } from "../lib/config.js";
|
|
4
4
|
import { UserSchema } from "../lib/validators.js";
|
|
5
5
|
export async function loginCommand() {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
// If the user is already logged in we still start a fresh device flow
|
|
7
|
+
// — `setToken` on success overwrites the existing token. We don't
|
|
8
|
+
// clear the old token up-front, so a failed/cancelled/timed-out login
|
|
9
|
+
// leaves the previously-valid session intact.
|
|
10
10
|
console.log("Starting device authorization...\n");
|
|
11
11
|
try {
|
|
12
12
|
const deviceCode = await requestDeviceCode();
|
package/dist/src/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AA4IA,wBAAgB,GAAG,IAAI,IAAI,CAE1B"}
|
package/dist/src/index.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { disableCommand } from "./commands/agent/disable.js";
|
|
5
|
+
import { doctorCommand } from "./commands/agent/doctor.js";
|
|
6
|
+
import { hookCommand } from "./commands/agent/hook.js";
|
|
7
|
+
import { setupCommand } from "./commands/agent/setup.js";
|
|
8
|
+
import { statusCommand } from "./commands/agent/status.js";
|
|
4
9
|
import { clockInCommand, clockOutCommand, clockStatusCommand, } from "./commands/clock.js";
|
|
5
10
|
import { loginCommand } from "./commands/login.js";
|
|
6
11
|
import { logoutCommand } from "./commands/logout.js";
|
|
@@ -16,12 +21,12 @@ program
|
|
|
16
21
|
.name("clocktopus")
|
|
17
22
|
.description("CLI for Clocktopus time tracking")
|
|
18
23
|
.version(version)
|
|
19
|
-
.option("-e, --env <environment>", "Use environment (dev or prod)")
|
|
24
|
+
.option("-e, --env <environment>", "Use environment (dev, staging or prod)")
|
|
20
25
|
.hook("preAction", (thisCommand) => {
|
|
21
26
|
const opts = thisCommand.opts();
|
|
22
27
|
if (opts.env) {
|
|
23
28
|
if (!(opts.env in ENVIRONMENTS)) {
|
|
24
|
-
console.error(`Invalid environment: ${opts.env}. Use 'dev' or 'prod'.`);
|
|
29
|
+
console.error(`Invalid environment: ${opts.env}. Use 'dev', 'staging' or 'prod'.`);
|
|
25
30
|
process.exit(1);
|
|
26
31
|
}
|
|
27
32
|
setRuntimeEnvironment(opts.env);
|
|
@@ -45,17 +50,51 @@ const clock = program
|
|
|
45
50
|
.description("Record clock-in and clock-out signals");
|
|
46
51
|
clock
|
|
47
52
|
.command("in")
|
|
48
|
-
.description("Record clock-in for today")
|
|
49
|
-
.
|
|
53
|
+
.description("Record clock-in for today (optionally backdated)")
|
|
54
|
+
.option("--ago <duration>", "Backdate by a duration relative to now (e.g. 45m, 1h, 1h30m)")
|
|
55
|
+
.option("--at <time>", "Backdate to an absolute time in your timezone (HH:mm or HH:mm:ss)")
|
|
56
|
+
.action((options) => clockInCommand(options));
|
|
50
57
|
clock
|
|
51
58
|
.command("out")
|
|
52
|
-
.description("Record clock-out for today")
|
|
53
|
-
.
|
|
59
|
+
.description("Record clock-out for today (optionally backdated)")
|
|
60
|
+
.option("--ago <duration>", "Backdate by a duration relative to now (e.g. 45m, 1h, 1h30m)")
|
|
61
|
+
.option("--at <time>", "Backdate to an absolute time in your timezone (HH:mm or HH:mm:ss)")
|
|
62
|
+
.action((options) => clockOutCommand(options));
|
|
54
63
|
clock
|
|
55
64
|
.command("status")
|
|
56
65
|
.description("Show clock signals for a specific date")
|
|
57
66
|
.option("-d, --date <date>", "Date in YYYY-MM-DD format (default: today)")
|
|
58
67
|
.action(clockStatusCommand);
|
|
68
|
+
// Agent telemetry — tracks what AI agents cost, alongside human time.
|
|
69
|
+
const agent = program
|
|
70
|
+
.command("agent")
|
|
71
|
+
.description("Track AI agent spend from this machine");
|
|
72
|
+
agent
|
|
73
|
+
.command("setup")
|
|
74
|
+
.description("Point Claude Code at Clocktopus and install the session hooks")
|
|
75
|
+
.option("--name <name>", "Label for this machine's ingest token")
|
|
76
|
+
.option("--force", "Mint a replacement token instead of reusing the existing one")
|
|
77
|
+
.action((options) => setupCommand(options));
|
|
78
|
+
agent
|
|
79
|
+
.command("status")
|
|
80
|
+
.description("Show what the receiver has actually received")
|
|
81
|
+
.action(statusCommand);
|
|
82
|
+
agent
|
|
83
|
+
.command("doctor")
|
|
84
|
+
.description("Check every link in the telemetry chain and report failures")
|
|
85
|
+
.action(doctorCommand);
|
|
86
|
+
agent
|
|
87
|
+
.command("disable")
|
|
88
|
+
.description("Remove this machine's telemetry configuration")
|
|
89
|
+
.option("--revoke", "Also revoke the ingest token, everywhere")
|
|
90
|
+
.action((options) => disableCommand(options));
|
|
91
|
+
// Invoked by Claude Code, never by a person: it reads the hook payload from
|
|
92
|
+
// stdin and must not write to stdout. Hidden so it does not read as
|
|
93
|
+
// something to run by hand.
|
|
94
|
+
agent
|
|
95
|
+
.command("hook", { hidden: true })
|
|
96
|
+
.description("Internal: Claude Code SessionStart/SessionEnd hook")
|
|
97
|
+
.action(hookCommand);
|
|
59
98
|
export function run() {
|
|
60
99
|
program.parse();
|
|
61
100
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the ingest credentials come from, and what shadows what.
|
|
3
|
+
*
|
|
4
|
+
* The order matters and is not arbitrary. Inside a Claude Code session the
|
|
5
|
+
* process environment is what the exporter and the hook actually read, and
|
|
6
|
+
* `settings.json` `env` is applied *over* the inherited shell environment —
|
|
7
|
+
* so a value in settings.json wins over one exported from `.envrc` or a
|
|
8
|
+
* shell profile, silently. That is the failure this resolver is built to
|
|
9
|
+
* make visible rather than to paper over: it reports the source alongside
|
|
10
|
+
* the value so `doctor` can name the file that is winning.
|
|
11
|
+
*/
|
|
12
|
+
export type CredentialSource = "environment" | "settings" | "none";
|
|
13
|
+
export type ResolvedAgentCredentials = {
|
|
14
|
+
token: string | null;
|
|
15
|
+
endpoint: string | null;
|
|
16
|
+
tokenSource: CredentialSource;
|
|
17
|
+
endpointSource: CredentialSource;
|
|
18
|
+
};
|
|
19
|
+
export declare function resolveAgentCredentials(): ResolvedAgentCredentials;
|
|
20
|
+
/** Masks a token for display: never print more than the stored prefix. */
|
|
21
|
+
export declare function maskToken(token: string): string;
|
|
22
|
+
export declare function findShadowedExports(cwd?: string): Array<{
|
|
23
|
+
path: string;
|
|
24
|
+
keys: string[];
|
|
25
|
+
}>;
|
|
26
|
+
/**
|
|
27
|
+
* The command to write into settings.json for the hook.
|
|
28
|
+
*
|
|
29
|
+
* Prefers the bare name, but only after confirming that `clocktopus` on
|
|
30
|
+
* PATH resolves to *this* executable. Hooks run through a shell whose PATH
|
|
31
|
+
* may differ from the interactive one, and a bare name that does not
|
|
32
|
+
* resolve there fails silently — the session runs fine and every bit of
|
|
33
|
+
* repository context is lost. An absolute path is uglier and survives that;
|
|
34
|
+
* `doctor` checks it still exists.
|
|
35
|
+
*/
|
|
36
|
+
export declare function resolveHookCommand(): {
|
|
37
|
+
command: string;
|
|
38
|
+
usesAbsolutePath: boolean;
|
|
39
|
+
binaryPath: string | null;
|
|
40
|
+
};
|
|
41
|
+
//# sourceMappingURL=agent-config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent-config.d.ts","sourceRoot":"","sources":["../../../src/lib/agent-config.ts"],"names":[],"mappings":"AAYA;;;;;;;;;;GAUG;AAEH,MAAM,MAAM,gBAAgB,GAAG,aAAa,GAAG,UAAU,GAAG,MAAM,CAAC;AAEnE,MAAM,MAAM,wBAAwB,GAAG;IACrC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,WAAW,EAAE,gBAAgB,CAAC;IAC9B,cAAc,EAAE,gBAAgB,CAAC;CAClC,CAAC;AAEF,wBAAgB,uBAAuB,IAAI,wBAAwB,CA2BlE;AAED,0EAA0E;AAC1E,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAE/C;AAmBD,wBAAgB,mBAAmB,CAAC,GAAG,SAAgB,GAAG,KAAK,CAAC;IAC9D,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,EAAE,CAAC;CAChB,CAAC,CA+BD;AAED;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,IAAI;IACpC,OAAO,EAAE,MAAM,CAAC;IAChB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B,CA8BA"}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { accessSync, constants, existsSync, readFileSync, realpathSync, } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { delimiter, join, resolve } from "node:path";
|
|
4
|
+
import { readInstalledTelemetry } from "./claude-settings.js";
|
|
5
|
+
export function resolveAgentCredentials() {
|
|
6
|
+
const envToken = process.env.CLOCKTOPUS_INGEST_TOKEN?.trim();
|
|
7
|
+
const envEndpoint = process.env.CLOCKTOPUS_OTEL_ENDPOINT?.trim();
|
|
8
|
+
let settingsToken;
|
|
9
|
+
let settingsEndpoint;
|
|
10
|
+
try {
|
|
11
|
+
const installed = readInstalledTelemetry();
|
|
12
|
+
settingsToken = installed.env.CLOCKTOPUS_INGEST_TOKEN;
|
|
13
|
+
settingsEndpoint = installed.env.CLOCKTOPUS_OTEL_ENDPOINT;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// A malformed settings.json is reported properly by `setup` and
|
|
17
|
+
// `doctor`. Credential resolution must not throw — the hook calls it on
|
|
18
|
+
// every session start.
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
token: envToken || settingsToken || null,
|
|
22
|
+
endpoint: (envEndpoint || settingsEndpoint || null)?.replace(/\/$/, "") ?? null,
|
|
23
|
+
tokenSource: envToken ? "environment" : settingsToken ? "settings" : "none",
|
|
24
|
+
endpointSource: envEndpoint
|
|
25
|
+
? "environment"
|
|
26
|
+
: settingsEndpoint
|
|
27
|
+
? "settings"
|
|
28
|
+
: "none",
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** Masks a token for display: never print more than the stored prefix. */
|
|
32
|
+
export function maskToken(token) {
|
|
33
|
+
return token.length <= 15 ? "…" : `${token.slice(0, 15)}…`;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Files that commonly export the same variables, and would be shadowed.
|
|
37
|
+
*
|
|
38
|
+
* Scanned rather than guessed at, because "your `.envrc` is being ignored"
|
|
39
|
+
* is only useful advice when it names the file. The repo-local `.envrc` is
|
|
40
|
+
* included because that is exactly how this project was dogfooded before
|
|
41
|
+
* the CLI existed.
|
|
42
|
+
*/
|
|
43
|
+
const SHELL_FILES = [
|
|
44
|
+
".bashrc",
|
|
45
|
+
".bash_profile",
|
|
46
|
+
".zshrc",
|
|
47
|
+
".zshenv",
|
|
48
|
+
".profile",
|
|
49
|
+
".config/fish/config.fish",
|
|
50
|
+
];
|
|
51
|
+
export function findShadowedExports(cwd = process.cwd()) {
|
|
52
|
+
const candidates = [
|
|
53
|
+
...SHELL_FILES.map((file) => join(homedir(), file)),
|
|
54
|
+
join(cwd, ".envrc"),
|
|
55
|
+
];
|
|
56
|
+
const found = [];
|
|
57
|
+
for (const path of candidates) {
|
|
58
|
+
let contents;
|
|
59
|
+
try {
|
|
60
|
+
if (!existsSync(path))
|
|
61
|
+
continue;
|
|
62
|
+
contents = readFileSync(path, "utf8");
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const keys = [
|
|
68
|
+
"CLOCKTOPUS_INGEST_TOKEN",
|
|
69
|
+
"CLOCKTOPUS_OTEL_ENDPOINT",
|
|
70
|
+
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
|
71
|
+
"OTEL_EXPORTER_OTLP_HEADERS",
|
|
72
|
+
"CLAUDE_CODE_ENABLE_TELEMETRY",
|
|
73
|
+
].filter((key) => new RegExp(`^\\s*(export\\s+)?${key}=`, "m").test(contents));
|
|
74
|
+
if (keys.length > 0)
|
|
75
|
+
found.push({ path, keys });
|
|
76
|
+
}
|
|
77
|
+
return found;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The command to write into settings.json for the hook.
|
|
81
|
+
*
|
|
82
|
+
* Prefers the bare name, but only after confirming that `clocktopus` on
|
|
83
|
+
* PATH resolves to *this* executable. Hooks run through a shell whose PATH
|
|
84
|
+
* may differ from the interactive one, and a bare name that does not
|
|
85
|
+
* resolve there fails silently — the session runs fine and every bit of
|
|
86
|
+
* repository context is lost. An absolute path is uglier and survives that;
|
|
87
|
+
* `doctor` checks it still exists.
|
|
88
|
+
*/
|
|
89
|
+
export function resolveHookCommand() {
|
|
90
|
+
const script = process.argv[1] ? resolve(process.argv[1]) : null;
|
|
91
|
+
const quote = (value) => /[\s"']/.test(value) ? `"${value}"` : value;
|
|
92
|
+
if (script && resolvesOnPath("clocktopus", script)) {
|
|
93
|
+
return {
|
|
94
|
+
command: "clocktopus agent hook",
|
|
95
|
+
usesAbsolutePath: false,
|
|
96
|
+
binaryPath: script,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (script) {
|
|
100
|
+
// `node <script>` rather than executing the script directly: the file in
|
|
101
|
+
// a published package keeps its shebang, but a checkout may not have the
|
|
102
|
+
// executable bit, and the hook must not depend on that.
|
|
103
|
+
return {
|
|
104
|
+
command: `${quote(process.execPath)} ${quote(script)} agent hook`,
|
|
105
|
+
usesAbsolutePath: true,
|
|
106
|
+
binaryPath: script,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
command: "clocktopus agent hook",
|
|
111
|
+
usesAbsolutePath: false,
|
|
112
|
+
binaryPath: null,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function resolvesOnPath(name, expected) {
|
|
116
|
+
const searchPath = process.env.PATH;
|
|
117
|
+
if (!searchPath)
|
|
118
|
+
return false;
|
|
119
|
+
let expectedReal;
|
|
120
|
+
try {
|
|
121
|
+
expectedReal = realpathSync(expected);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
for (const dir of searchPath.split(delimiter)) {
|
|
127
|
+
if (!dir)
|
|
128
|
+
continue;
|
|
129
|
+
const candidate = join(dir, name);
|
|
130
|
+
try {
|
|
131
|
+
accessSync(candidate, constants.X_OK);
|
|
132
|
+
if (realpathSync(candidate) === expectedReal)
|
|
133
|
+
return true;
|
|
134
|
+
// A different `clocktopus` earlier on PATH would shadow this one, so
|
|
135
|
+
// stop rather than keep looking for a match further down.
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
declare const LastRunSchema: z.ZodObject<{
|
|
3
|
+
at: z.ZodString;
|
|
4
|
+
event: z.ZodOptional<z.ZodString>;
|
|
5
|
+
sessionId: z.ZodOptional<z.ZodString>;
|
|
6
|
+
endpoint: z.ZodOptional<z.ZodString>;
|
|
7
|
+
tokenPrefix: z.ZodOptional<z.ZodString>;
|
|
8
|
+
status: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
9
|
+
error: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
10
|
+
repository: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
11
|
+
}, z.core.$strip>;
|
|
12
|
+
export type HookLastRun = z.infer<typeof LastRunSchema>;
|
|
13
|
+
declare const StartStateSchema: z.ZodObject<{
|
|
14
|
+
sha: z.ZodString;
|
|
15
|
+
cwd: z.ZodOptional<z.ZodString>;
|
|
16
|
+
}, z.core.$strip>;
|
|
17
|
+
export type HookStartState = z.infer<typeof StartStateSchema>;
|
|
18
|
+
export declare function writeStartState(sessionId: string, sha: string, cwd: string): void;
|
|
19
|
+
export declare function readStartState(sessionId: string): HookStartState | undefined;
|
|
20
|
+
export declare function clearStartState(sessionId: string): void;
|
|
21
|
+
export declare function listStartStates(): Array<{
|
|
22
|
+
sessionId: string;
|
|
23
|
+
ageMs: number;
|
|
24
|
+
/**
|
|
25
|
+
* When the state file was last touched — the newest moment this machine
|
|
26
|
+
* has evidence the session existed. The sweep sends it as the abandoned
|
|
27
|
+
* session's `ended_at` so the receiver does not stamp its own clock,
|
|
28
|
+
* hours later, as the end of a window used to attribute commits.
|
|
29
|
+
*/
|
|
30
|
+
lastActivityAt: Date;
|
|
31
|
+
}>;
|
|
32
|
+
export declare function recordLastRun(run: HookLastRun): void;
|
|
33
|
+
export declare function readLastRun(): HookLastRun | null;
|
|
34
|
+
export declare function clearHookState(): void;
|
|
35
|
+
export {};
|
|
36
|
+
//# sourceMappingURL=agent-hook-state.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent-hook-state.d.ts","sourceRoot":"","sources":["../../../src/lib/agent-hook-state.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AA6BxB,QAAA,MAAM,aAAa;;;;;;;;;iBAUjB,CAAC;AAEH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAExD,QAAA,MAAM,gBAAgB;;;iBAGpB,CAAC;AAEH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAK9D,wBAAgB,eAAe,CAC7B,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,MAAM,GACV,IAAI,CAWN;AAED,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,cAAc,GAAG,SAAS,CAgB5E;AAED,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAMvD;AAED,wBAAgB,eAAe,IAAI,KAAK,CAAC;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd;;;;;OAKG;IACH,cAAc,EAAE,IAAI,CAAC;CACtB,CAAC,CAsBD;AAED,wBAAgB,aAAa,CAAC,GAAG,EAAE,WAAW,GAAG,IAAI,CAOpD;AAED,wBAAgB,WAAW,IAAI,WAAW,GAAG,IAAI,CAShD;AAED,wBAAgB,cAAc,IAAI,IAAI,CAOrC"}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
/**
|
|
6
|
+
* On-disk state the SessionStart/SessionEnd hook keeps between its two
|
|
7
|
+
* invocations, plus the record of what the last invocation achieved.
|
|
8
|
+
*
|
|
9
|
+
* Under the cache directory rather than the repository so it never shows up
|
|
10
|
+
* in `git status`. The path is unchanged from the standalone script this
|
|
11
|
+
* command replaced (`scripts/agent-telemetry/claude-hook.mjs`, since
|
|
12
|
+
* deleted) on purpose: a session that started under the old script must
|
|
13
|
+
* still be able to close under the new one, and a sweep must still find
|
|
14
|
+
* what the old one abandoned.
|
|
15
|
+
*/
|
|
16
|
+
const CACHE_DIR = join(homedir(), ".cache", "clocktopus");
|
|
17
|
+
const STATE_DIR = join(CACHE_DIR, "agent-sessions");
|
|
18
|
+
/**
|
|
19
|
+
* The hook's last outcome, written every run.
|
|
20
|
+
*
|
|
21
|
+
* This is the only place the pipeline reports on itself from the machine's
|
|
22
|
+
* own side. The hook must stay silent — it cannot warn, prompt or print —
|
|
23
|
+
* so instead it leaves a receipt, and `agent status` / `agent doctor` read
|
|
24
|
+
* it. Without this, a token that started returning 401 mid-week is
|
|
25
|
+
* invisible locally: the session runs normally and nothing is written
|
|
26
|
+
* anywhere the user looks.
|
|
27
|
+
*/
|
|
28
|
+
const LAST_RUN_FILE = join(CACHE_DIR, "agent-hook-last.json");
|
|
29
|
+
const LastRunSchema = z.object({
|
|
30
|
+
at: z.string(),
|
|
31
|
+
event: z.string().optional(),
|
|
32
|
+
sessionId: z.string().optional(),
|
|
33
|
+
endpoint: z.string().optional(),
|
|
34
|
+
tokenPrefix: z.string().optional(),
|
|
35
|
+
/** HTTP status from the receiver, or null when the request never landed. */
|
|
36
|
+
status: z.number().nullable().optional(),
|
|
37
|
+
error: z.string().nullable().optional(),
|
|
38
|
+
repository: z.string().nullable().optional(),
|
|
39
|
+
});
|
|
40
|
+
const StartStateSchema = z.object({
|
|
41
|
+
sha: z.string(),
|
|
42
|
+
cwd: z.string().optional(),
|
|
43
|
+
});
|
|
44
|
+
const stateFile = (sessionId) => join(STATE_DIR, `${sessionId.replace(/[^\w-]/g, "")}.sha`);
|
|
45
|
+
export function writeStartState(sessionId, sha, cwd) {
|
|
46
|
+
try {
|
|
47
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
48
|
+
// `cwd` is stored alongside the SHA because the sweep runs from
|
|
49
|
+
// whatever repository the *next* session happens to start in.
|
|
50
|
+
// Resolving an abandoned session's SHA against the wrong checkout would
|
|
51
|
+
// either fail or, worse, succeed against an unrelated history.
|
|
52
|
+
writeFileSync(stateFile(sessionId), JSON.stringify({ sha, cwd }), "utf8");
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Losing the start SHA costs the exact commit list, not the session.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function readStartState(sessionId) {
|
|
59
|
+
try {
|
|
60
|
+
const raw = readFileSync(stateFile(sessionId), "utf8").trim();
|
|
61
|
+
if (!raw)
|
|
62
|
+
return undefined;
|
|
63
|
+
try {
|
|
64
|
+
const parsed = StartStateSchema.safeParse(JSON.parse(raw));
|
|
65
|
+
return parsed.success ? parsed.data : undefined;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Files written by earlier versions hold a bare SHA. A session that
|
|
69
|
+
// started under the old format must still close correctly.
|
|
70
|
+
return { sha: raw };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function clearStartState(sessionId) {
|
|
78
|
+
try {
|
|
79
|
+
rmSync(stateFile(sessionId), { force: true });
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Best effort; a stale file is overwritten by the next session anyway.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export function listStartStates() {
|
|
86
|
+
try {
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
return readdirSync(STATE_DIR)
|
|
89
|
+
.filter((name) => name.endsWith(".sha"))
|
|
90
|
+
.flatMap((name) => {
|
|
91
|
+
try {
|
|
92
|
+
const { mtimeMs } = statSync(join(STATE_DIR, name));
|
|
93
|
+
return [
|
|
94
|
+
{
|
|
95
|
+
sessionId: name.replace(/\.sha$/, ""),
|
|
96
|
+
ageMs: now - mtimeMs,
|
|
97
|
+
lastActivityAt: new Date(mtimeMs),
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
export function recordLastRun(run) {
|
|
111
|
+
try {
|
|
112
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
113
|
+
writeFileSync(LAST_RUN_FILE, JSON.stringify(run, null, 2), "utf8");
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// A missing receipt degrades diagnostics; it must never fail a session.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export function readLastRun() {
|
|
120
|
+
try {
|
|
121
|
+
const parsed = LastRunSchema.safeParse(JSON.parse(readFileSync(LAST_RUN_FILE, "utf8")));
|
|
122
|
+
return parsed.success ? parsed.data : null;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
export function clearHookState() {
|
|
129
|
+
try {
|
|
130
|
+
rmSync(STATE_DIR, { recursive: true, force: true });
|
|
131
|
+
rmSync(LAST_RUN_FILE, { force: true });
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// Nothing to clean up, or not ours to clean up.
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Talks to the OTLP receiver directly, which is the whole point.
|
|
3
|
+
*
|
|
4
|
+
* The receiver is a different host from the web app — its own Router, its
|
|
5
|
+
* own Lambda, its own domain — so a token that works against the dashboard
|
|
6
|
+
* proves nothing about whether telemetry can reach ingest. This check is
|
|
7
|
+
* the only local way to tell "nothing has happened yet" apart from "nothing
|
|
8
|
+
* can happen": a wrong endpoint, a revoked token and an idle afternoon all
|
|
9
|
+
* look identical otherwise.
|
|
10
|
+
*/
|
|
11
|
+
export type ReceiverCheck = {
|
|
12
|
+
ok: true;
|
|
13
|
+
} | {
|
|
14
|
+
ok: false;
|
|
15
|
+
reason: "invalid_token";
|
|
16
|
+
} | {
|
|
17
|
+
ok: false;
|
|
18
|
+
reason: "unexpected_status";
|
|
19
|
+
status: number;
|
|
20
|
+
} | {
|
|
21
|
+
ok: false;
|
|
22
|
+
reason: "unreachable";
|
|
23
|
+
message: string;
|
|
24
|
+
};
|
|
25
|
+
export declare function verifyReceiver(endpoint: string, token: string): Promise<ReceiverCheck>;
|
|
26
|
+
//# sourceMappingURL=agent-receiver.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent-receiver.d.ts","sourceRoot":"","sources":["../../../src/lib/agent-receiver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,MAAM,MAAM,aAAa,GACrB;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GACZ;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,eAAe,CAAA;CAAE,GACtC;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,mBAAmB,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAC1D;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,aAAa,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAE1D,wBAAsB,cAAc,CAClC,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,aAAa,CAAC,CA+BxB"}
|