@harnessforce/claude-code-plugin 0.1.0 → 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/.claude-plugin/plugin.json +1 -1
- package/README.md +34 -13
- package/commands/help.md +31 -0
- package/commands/import.md +18 -0
- package/commands/run.md +25 -0
- package/commands/setup.md +29 -47
- package/commands/status.md +26 -0
- package/commands/tune.md +18 -18
- package/package.json +1 -1
- package/scripts/harnessforce-hook-main.cjs +539 -154
- package/scripts/harnessforce-hook.cjs +2 -1
- package/skills/propose-improvements/SKILL.md +69 -69
- package/skills/propose-improvements/scripts/summarize.mjs +59 -57
- package/skills/record-run/SKILL.md +14 -51
- package/skills/using-harnessforce/SKILL.md +32 -32
|
@@ -1,16 +1,71 @@
|
|
|
1
1
|
let node_os = require("node:os");
|
|
2
|
-
let node_perf_hooks = require("node:perf_hooks");
|
|
3
2
|
let node_path = require("node:path");
|
|
3
|
+
let node_perf_hooks = require("node:perf_hooks");
|
|
4
4
|
let node_crypto = require("node:crypto");
|
|
5
5
|
let node_fs_promises = require("node:fs/promises");
|
|
6
6
|
let node_child_process = require("node:child_process");
|
|
7
7
|
let node_fs = require("node:fs");
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
8
|
+
const NO_WHITE_SPACE = new RegExp(`^[^\\t-\\r \\u0085\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000]+$`);
|
|
9
|
+
const isToken = (value, maxLength = 256) => {
|
|
10
|
+
const length = [...value].length;
|
|
11
|
+
return length >= 1 && length <= maxLength && NO_WHITE_SPACE.test(value);
|
|
12
|
+
};
|
|
13
|
+
const INSTANT_PATTERN = `^(?:1(?:6(?:7[7-9]|[89]\\d)|[7-9]\\d\\d)|2(?:[01]\\d\\d|2(?:[0-5]\\d|6[0-2])))-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$`;
|
|
14
|
+
const INSTANT_PARTS = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(?:Z|([+-])(\d{2}):(\d{2}))$/;
|
|
15
|
+
const INSTANT = new RegExp(INSTANT_PATTERN);
|
|
16
|
+
const MIN_INSTANT_MS = Date.UTC(1677, 8, 21, 0, 12, 44);
|
|
17
|
+
const MAX_INSTANT_MS = Date.UTC(2262, 3, 11, 23, 47, 16);
|
|
18
|
+
function isInstant(value) {
|
|
19
|
+
const parts = INSTANT.test(value) ? INSTANT_PARTS.exec(value) : null;
|
|
20
|
+
if (!parts) return false;
|
|
21
|
+
const [, ...fields] = parts;
|
|
22
|
+
const [year, month, day, hour, minute, second] = fields.slice(0, 6).map(Number);
|
|
23
|
+
const [fraction = "", sign, offsetHours = "0", offsetMinutes = "0"] = fields.slice(6);
|
|
24
|
+
const localMs = Date.UTC(year, month - 1, day, hour, minute, second);
|
|
25
|
+
const local = new Date(localMs);
|
|
26
|
+
if (!(local.getUTCFullYear() === year && local.getUTCMonth() === month - 1 && local.getUTCDate() === day && local.getUTCHours() === hour && local.getUTCMinutes() === minute && local.getUTCSeconds() === second)) return false;
|
|
27
|
+
const secondMs = localMs - (sign === "-" ? -1 : 1) * (Number(offsetHours) * 60 + Number(offsetMinutes)) * 6e4;
|
|
28
|
+
const nanos = Number(fraction.slice(0, 9) || "0");
|
|
29
|
+
return secondMs >= MIN_INSTANT_MS && !(secondMs > MAX_INSTANT_MS || secondMs === MAX_INSTANT_MS && nanos > 0);
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region ../../packages/semconv/dist/repository.js
|
|
33
|
+
const SCHEME_URL = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
34
|
+
const SCP_LIKE = /^(?:[^@/\s]+@)?([^:/\s]+):(?!\/)(.+)$/;
|
|
35
|
+
const HOST = /^[a-z0-9.-]+$/;
|
|
36
|
+
const SEGMENT = /^[A-Za-z0-9_.-]+$/;
|
|
37
|
+
function splitRemote(remote) {
|
|
38
|
+
if (SCHEME_URL.test(remote)) {
|
|
39
|
+
let url;
|
|
40
|
+
try {
|
|
41
|
+
url = new URL(remote);
|
|
42
|
+
} catch {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
return url.protocol === "file:" ? void 0 : {
|
|
46
|
+
host: url.hostname,
|
|
47
|
+
path: url.pathname
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const scp = SCP_LIKE.exec(remote);
|
|
51
|
+
return scp?.[1] && scp[2] ? {
|
|
52
|
+
host: scp[1],
|
|
53
|
+
path: scp[2]
|
|
54
|
+
} : void 0;
|
|
55
|
+
}
|
|
56
|
+
function normalizeRepository(remoteUrl) {
|
|
57
|
+
const location = splitRemote(remoteUrl.trim());
|
|
58
|
+
if (!location) return void 0;
|
|
59
|
+
const lowerHost = location.host.toLowerCase();
|
|
60
|
+
const host = lowerHost === "ssh.github.com" ? "github.com" : lowerHost;
|
|
61
|
+
const segments = location.path.replace(/\/+$/, "").replace(/\.git$/, "").replace(/^\/+/, "").split("/");
|
|
62
|
+
if (segments.length !== 2 || !HOST.test(host) || !segments.every((segment) => SEGMENT.test(segment))) return void 0;
|
|
63
|
+
const [owner, name] = host === "github.com" ? segments.map((segment) => segment.toLowerCase()) : segments;
|
|
64
|
+
return `${host}/${owner}/${name}`;
|
|
65
|
+
}
|
|
66
|
+
//#endregion
|
|
67
|
+
//#region ../../packages/agent-core/src/object.ts
|
|
68
|
+
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
14
69
|
//#endregion
|
|
15
70
|
//#region ../../packages/agent-core/src/config/canonical.ts
|
|
16
71
|
const byCodeUnit$1 = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
@@ -29,9 +84,6 @@ function sortComponents(components) {
|
|
|
29
84
|
return [...components].sort((a, b) => byCodeUnit$1(a.kind, b.kind) || byCodeUnit$1(a.source, b.source) || byCodeUnit$1(a.id, b.id));
|
|
30
85
|
}
|
|
31
86
|
//#endregion
|
|
32
|
-
//#region ../../packages/agent-core/src/object.ts
|
|
33
|
-
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
34
|
-
//#endregion
|
|
35
87
|
//#region ../../packages/agent-core/src/config/files.ts
|
|
36
88
|
const MAX_DEPTH = 8;
|
|
37
89
|
const MAX_FILE_BYTES = 1048576;
|
|
@@ -94,26 +146,175 @@ async function listDirectories(dir, guard) {
|
|
|
94
146
|
return names;
|
|
95
147
|
}
|
|
96
148
|
//#endregion
|
|
149
|
+
//#region ../../packages/agent-core/src/config/hash-input.ts
|
|
150
|
+
function withoutSecrets(entry) {
|
|
151
|
+
const { env, headers, url, args: _args, ...rest } = entry;
|
|
152
|
+
const result = rest;
|
|
153
|
+
if (isObject(env)) result.env = Object.keys(env).sort(byCodeUnit$1);
|
|
154
|
+
if (isObject(headers)) result.headers = Object.keys(headers).sort(byCodeUnit$1);
|
|
155
|
+
const strippedUrl = typeof url === "string" ? withoutCredentials(url) : void 0;
|
|
156
|
+
if (strippedUrl !== void 0) result.url = strippedUrl;
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
function withoutCredentials(url) {
|
|
160
|
+
let parsed;
|
|
161
|
+
try {
|
|
162
|
+
parsed = new URL(url);
|
|
163
|
+
} catch {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
parsed.username = "";
|
|
167
|
+
parsed.password = "";
|
|
168
|
+
parsed.search = "";
|
|
169
|
+
parsed.hash = "";
|
|
170
|
+
return parsed.href;
|
|
171
|
+
}
|
|
172
|
+
const reduceHookGroup = (group) => isObject(group) && Array.isArray(group.hooks) ? {
|
|
173
|
+
...group,
|
|
174
|
+
hooks: group.hooks.map((handler) => isObject(handler) ? withoutSecrets(handler) : handler)
|
|
175
|
+
} : group;
|
|
176
|
+
function hashInput(kind, value) {
|
|
177
|
+
if (kind === "mcp_server" && isObject(value)) return withoutSecrets(value);
|
|
178
|
+
if (kind === "hook" && Array.isArray(value)) return value.map(reduceHookGroup);
|
|
179
|
+
return value;
|
|
180
|
+
}
|
|
181
|
+
//#endregion
|
|
182
|
+
//#region ../../packages/agent-core/src/config/scope.ts
|
|
183
|
+
const MAX_COMPONENTS = 1e3;
|
|
184
|
+
var TooManyComponents = class extends Error {};
|
|
185
|
+
var Sink = class {
|
|
186
|
+
guard;
|
|
187
|
+
components = [];
|
|
188
|
+
constructor(guard) {
|
|
189
|
+
this.guard = guard;
|
|
190
|
+
}
|
|
191
|
+
add(component) {
|
|
192
|
+
if (!isToken(component.id)) return;
|
|
193
|
+
if (this.components.length >= MAX_COMPONENTS) throw new TooManyComponents();
|
|
194
|
+
this.components.push(component);
|
|
195
|
+
}
|
|
196
|
+
hasDuplicates() {
|
|
197
|
+
return new Set(this.components.map((c) => `${c.kind}\0${c.source}\0${c.id}`)).size !== this.components.length;
|
|
198
|
+
}
|
|
199
|
+
scope = (source, idPrefix = "", version) => {
|
|
200
|
+
const add = (kind, id, hash) => this.add({
|
|
201
|
+
kind,
|
|
202
|
+
source,
|
|
203
|
+
id: `${idPrefix}${id}`,
|
|
204
|
+
...version === void 0 ? {} : { version },
|
|
205
|
+
hash
|
|
206
|
+
});
|
|
207
|
+
return {
|
|
208
|
+
guard: this.guard,
|
|
209
|
+
addValue: (kind, id, value) => add(kind, id, hashValue(hashInput(kind, value))),
|
|
210
|
+
addFile: async (kind, id, path) => {
|
|
211
|
+
const content = await readFileIfExists(path, this.guard);
|
|
212
|
+
if (content) add(kind, id, hashFileContent(content));
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
};
|
|
216
|
+
};
|
|
217
|
+
const absoluteEnv = (value) => value && (0, node_path.isAbsolute)(value) ? value : void 0;
|
|
218
|
+
const withoutExt = (path, ext) => path.slice(0, -ext.length);
|
|
219
|
+
const colonName = (path) => withoutExt(path, ".md").replace(/\//g, ":");
|
|
220
|
+
async function collectLayout(s, layout) {
|
|
221
|
+
if (layout.skills) for (const name of await listDirectories(layout.skills, s.guard)) await s.addFile("skill", name, (0, node_path.join)(layout.skills, name, "SKILL.md"));
|
|
222
|
+
if (layout.agents) for (const path of await listFiles(layout.agents, ".md", true, s.guard)) await s.addFile("agent", colonName(path), (0, node_path.join)(layout.agents, path));
|
|
223
|
+
if (layout.commands) for (const path of await listFiles(layout.commands, ".md", true, s.guard)) await s.addFile("command", colonName(path), (0, node_path.join)(layout.commands, path));
|
|
224
|
+
if (layout.workflows) for (const path of await listFiles(layout.workflows, ".js", false, s.guard)) await s.addFile("workflow", withoutExt(path, ".js"), (0, node_path.join)(layout.workflows, path));
|
|
225
|
+
}
|
|
226
|
+
async function collectRules(s, base, names, rulesDir) {
|
|
227
|
+
for (const name of names) await s.addFile("rule", name, (0, node_path.join)(base, name));
|
|
228
|
+
if (rulesDir) for (const path of await listFiles(rulesDir, ".md", true, s.guard)) await s.addFile("rule", `rules/${path}`, (0, node_path.join)(rulesDir, path));
|
|
229
|
+
}
|
|
230
|
+
function collectHooks(s, hooks) {
|
|
231
|
+
if (!isObject(hooks)) return;
|
|
232
|
+
for (const [event, value] of Object.entries(hooks)) s.addValue("hook", event, value);
|
|
233
|
+
}
|
|
234
|
+
function collectMcpServers(s, servers) {
|
|
235
|
+
if (!isObject(servers)) return;
|
|
236
|
+
for (const [name, value] of Object.entries(servers)) if (isObject(value)) s.addValue("mcp_server", name, value);
|
|
237
|
+
}
|
|
238
|
+
//#endregion
|
|
239
|
+
//#region ../../packages/agent-core/src/hook-warning.ts
|
|
240
|
+
const RESTART_FAILED_WARNING = "Could not restart the Harnessforce hook";
|
|
241
|
+
const INVALID_ENDPOINT_WARNING = "The Harnessforce destination is invalid. Run `harnessforce init` again";
|
|
242
|
+
const REGISTRATION_FAILED_WARNING = "Harnessforce session registration failed. Check your network connection";
|
|
243
|
+
const notConfiguredWarning = (agent) => agent === "claude_code" ? "Harnessforce sending is not configured. Run `/harnessforce:setup`" : "Harnessforce sending is not configured. Run `harnessforce init`";
|
|
244
|
+
const stopped = (agent) => agent === "claude_code" ? "telemetry is not being sent" : "sessions are not being registered";
|
|
245
|
+
const keyMissingWarning = (agent) => `Harnessforce cannot find your ingest key, so ${stopped(agent)}. Run \`harnessforce init\``;
|
|
246
|
+
const keyUnreadableWarning = (agent) => `Harnessforce could not read your ingest key from the OS keychain, so ${agent === "claude_code" ? "telemetry may not be sent" : "sessions may not be registered"}. Unlock the keychain or run \`harnessforce init\``;
|
|
247
|
+
const DESTINATION_MISMATCH_WARNING = {
|
|
248
|
+
claude_code: "Telemetry is not being sent because the Harnessforce destination differs from the one saved by `harnessforce init`. Check this repository's settings or run `harnessforce init` again",
|
|
249
|
+
codex: "Sessions are not being registered because the Harnessforce destination differs from the one saved by `harnessforce init`. Run `harnessforce init` again"
|
|
250
|
+
};
|
|
251
|
+
const TELEMETRY_NOT_CONFIGURED_WARNING = "Claude Code telemetry for Harnessforce is not configured. Run `harnessforce init`";
|
|
252
|
+
const warningOnlyOutput = (message) => `${JSON.stringify({ systemMessage: message })}\n`;
|
|
253
|
+
//#endregion
|
|
97
254
|
//#region ../../packages/agent-core/src/managed.ts
|
|
98
255
|
const MANAGED_DIRS = {
|
|
99
256
|
darwin: "/Library/Application Support/ClaudeCode",
|
|
100
257
|
win32: "C:\\Program Files\\ClaudeCode"
|
|
101
258
|
};
|
|
102
259
|
const managedDirFor = (platform) => MANAGED_DIRS[platform] ?? "/etc/claude-code";
|
|
103
|
-
const noGuard = () => {};
|
|
260
|
+
const noGuard$2 = () => {};
|
|
104
261
|
async function readManagedEnv(managedDir, names) {
|
|
105
262
|
const dropInDir = (0, node_path.join)(managedDir, "managed-settings.d");
|
|
106
|
-
const dropIns = (await listFiles(dropInDir, ".json", false, noGuard)).sort(byCodeUnit$1);
|
|
263
|
+
const dropIns = (await listFiles(dropInDir, ".json", false, noGuard$2)).sort(byCodeUnit$1);
|
|
107
264
|
const files = [(0, node_path.join)(managedDir, "managed-settings.json"), ...dropIns.map((name) => (0, node_path.join)(dropInDir, name))];
|
|
108
265
|
const values = Object.fromEntries(names.map((name) => [name, void 0]));
|
|
109
266
|
for (const file of files) {
|
|
110
|
-
const env = (await readJsonObjectFile(file, noGuard))?.env;
|
|
267
|
+
const env = (await readJsonObjectFile(file, noGuard$2))?.env;
|
|
111
268
|
if (!isObject(env)) continue;
|
|
112
269
|
for (const name of names) if (typeof env[name] === "string") values[name] = env[name];
|
|
113
270
|
}
|
|
114
271
|
return values;
|
|
115
272
|
}
|
|
116
273
|
//#endregion
|
|
274
|
+
//#region ../../packages/agent-core/src/plugin-auto-update.ts
|
|
275
|
+
const MARKETPLACE_NAME = "harnessforce-agent";
|
|
276
|
+
const MARKETPLACE_KEYS = ["extraKnownMarketplaces", "additionalMarketplaces"];
|
|
277
|
+
const DISABLING_VARIABLES = [
|
|
278
|
+
"DISABLE_UPDATES",
|
|
279
|
+
"DISABLE_AUTOUPDATER",
|
|
280
|
+
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"
|
|
281
|
+
];
|
|
282
|
+
const noGuard$1 = () => {};
|
|
283
|
+
function marketplacesOf(settings) {
|
|
284
|
+
const key = MARKETPLACE_KEYS.find((name) => settings?.[name] !== void 0);
|
|
285
|
+
const value = key === void 0 ? void 0 : settings?.[key];
|
|
286
|
+
return isObject(value) ? value : void 0;
|
|
287
|
+
}
|
|
288
|
+
const declaresEntry = (settings) => MARKETPLACE_KEYS.some((key) => {
|
|
289
|
+
const value = settings?.[key];
|
|
290
|
+
return isObject(value) && value["harnessforce-agent"] !== void 0;
|
|
291
|
+
});
|
|
292
|
+
async function managedFiles(managedDir) {
|
|
293
|
+
const dropInDir = (0, node_path.join)(managedDir, "managed-settings.d");
|
|
294
|
+
const dropIns = (await listFiles(dropInDir, ".json", false, noGuard$1)).sort(byCodeUnit$1);
|
|
295
|
+
return [(0, node_path.join)(managedDir, "managed-settings.json"), ...dropIns.map((name) => (0, node_path.join)(dropInDir, name))];
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Claude Codeがpluginを自動更新するとみなせるか(client-updates.md「表示」)。確かめられない場合は偽とし、outdatedを表示する。
|
|
299
|
+
* user settingsのentryが真で、優先度の高いfileが同じ名前のentryを持たず、自動更新を止める変数が無い場合だけ真とする。
|
|
300
|
+
*/
|
|
301
|
+
async function isPluginAutoUpdateEffective(options) {
|
|
302
|
+
const { env } = options;
|
|
303
|
+
if (DISABLING_VARIABLES.some((name) => env[name] === "1") && env.FORCE_AUTOUPDATE_PLUGINS !== "1") return false;
|
|
304
|
+
const higher = [
|
|
305
|
+
...await managedFiles(options.managedDir),
|
|
306
|
+
(0, node_path.join)(options.projectDir, ".claude", "settings.json"),
|
|
307
|
+
(0, node_path.join)(options.projectDir, ".claude", "settings.local.json")
|
|
308
|
+
];
|
|
309
|
+
for (const file of higher) {
|
|
310
|
+
if (!await (0, node_fs_promises.access)(file).then(() => true, () => false)) continue;
|
|
311
|
+
const settings = await readJsonObjectFile(file, noGuard$1);
|
|
312
|
+
if (!settings || declaresEntry(settings)) return false;
|
|
313
|
+
}
|
|
314
|
+
const entry = marketplacesOf(await readJsonObjectFile(options.userSettingsPath, noGuard$1))?.[MARKETPLACE_NAME];
|
|
315
|
+
return isObject(entry) && entry.autoUpdate === true;
|
|
316
|
+
}
|
|
317
|
+
//#endregion
|
|
117
318
|
//#region ../../packages/agent-core/src/process/lookup.ts
|
|
118
319
|
const DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD";
|
|
119
320
|
const NPM_SHIM_MAX_BYTES = 65536;
|
|
@@ -399,120 +600,208 @@ function relaunchWithoutRuntimeVariables({ platform, env, stash, spawnSelf: star
|
|
|
399
600
|
}));
|
|
400
601
|
});
|
|
401
602
|
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
603
|
+
//#endregion
|
|
604
|
+
//#region ../../packages/agent-core/src/telemetry-settings.ts
|
|
605
|
+
const EXPECTED = {
|
|
606
|
+
CLAUDE_CODE_ENABLE_TELEMETRY: (value) => value === "1",
|
|
607
|
+
OTEL_LOGS_EXPORTER: (value) => includesOtlp(value),
|
|
608
|
+
OTEL_METRICS_EXPORTER: (value) => includesOtlp(value),
|
|
609
|
+
OTEL_TRACES_EXPORTER: (value) => includesOtlp(value),
|
|
610
|
+
OTEL_EXPORTER_OTLP_PROTOCOL: (value) => value === "http/protobuf"
|
|
406
611
|
};
|
|
407
|
-
const
|
|
408
|
-
const
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
const
|
|
412
|
-
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
const localMs = Date.UTC(year, month - 1, day, hour, minute, second);
|
|
419
|
-
const local = new Date(localMs);
|
|
420
|
-
if (!(local.getUTCFullYear() === year && local.getUTCMonth() === month - 1 && local.getUTCDate() === day && local.getUTCHours() === hour && local.getUTCMinutes() === minute && local.getUTCSeconds() === second)) return false;
|
|
421
|
-
const secondMs = localMs - (sign === "-" ? -1 : 1) * (Number(offsetHours) * 60 + Number(offsetMinutes)) * 6e4;
|
|
422
|
-
const nanos = Number(fraction.slice(0, 9) || "0");
|
|
423
|
-
return secondMs >= MIN_INSTANT_MS && !(secondMs > MAX_INSTANT_MS || secondMs === MAX_INSTANT_MS && nanos > 0);
|
|
612
|
+
const NAMES = Object.keys(EXPECTED);
|
|
613
|
+
const includesOtlp = (value) => value.split(",").some((exporter) => exporter.trim() === "otlp");
|
|
614
|
+
const noGuard = () => {};
|
|
615
|
+
async function isTelemetryConfigured(options) {
|
|
616
|
+
const managed = await readManagedEnv(options.managedDir, NAMES);
|
|
617
|
+
const settings = await readJsonObjectFile(options.userSettingsPath, noGuard).catch(() => void 0);
|
|
618
|
+
const userEnv = isObject(settings?.env) ? settings.env : {};
|
|
619
|
+
return NAMES.every((name) => {
|
|
620
|
+
const value = managed[name] ?? userEnv[name];
|
|
621
|
+
return typeof value === "string" && EXPECTED[name](value);
|
|
622
|
+
}) && typeof settings?.otelHeadersHelper === "string";
|
|
424
623
|
}
|
|
425
624
|
//#endregion
|
|
426
|
-
//#region ../../packages/
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
return url.protocol === "file:" ? void 0 : {
|
|
440
|
-
host: url.hostname,
|
|
441
|
-
path: url.pathname
|
|
442
|
-
};
|
|
625
|
+
//#region ../../packages/agent-core/src/url.ts
|
|
626
|
+
const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
|
|
627
|
+
"localhost",
|
|
628
|
+
"127.0.0.1",
|
|
629
|
+
"[::1]"
|
|
630
|
+
]);
|
|
631
|
+
function parseAllowedUrl(value) {
|
|
632
|
+
if (!value) return void 0;
|
|
633
|
+
let url;
|
|
634
|
+
try {
|
|
635
|
+
url = new URL(value);
|
|
636
|
+
} catch {
|
|
637
|
+
return;
|
|
443
638
|
}
|
|
444
|
-
|
|
445
|
-
return scp?.[1] && scp[2] ? {
|
|
446
|
-
host: scp[1],
|
|
447
|
-
path: scp[2]
|
|
448
|
-
} : void 0;
|
|
639
|
+
return url.protocol === "https:" || url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname) ? url : void 0;
|
|
449
640
|
}
|
|
450
|
-
function
|
|
451
|
-
const
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
const host = lowerHost === "ssh.github.com" ? "github.com" : lowerHost;
|
|
455
|
-
const segments = location.path.replace(/\/+$/, "").replace(/\.git$/, "").replace(/^\/+/, "").split("/");
|
|
456
|
-
if (segments.length !== 2 || !HOST.test(host) || !segments.every((segment) => SEGMENT.test(segment))) return void 0;
|
|
457
|
-
const [owner, name] = host === "github.com" ? segments.map((segment) => segment.toLowerCase()) : segments;
|
|
458
|
-
return `${host}/${owner}/${name}`;
|
|
641
|
+
function underBase(base, path) {
|
|
642
|
+
const url = new URL(base.origin);
|
|
643
|
+
url.pathname = `${base.pathname.replace(/\/+$/, "")}/${path}`;
|
|
644
|
+
return url;
|
|
459
645
|
}
|
|
460
646
|
//#endregion
|
|
461
|
-
//#region ../../packages/agent-core/src/
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
647
|
+
//#region ../../packages/agent-core/src/client-update.ts
|
|
648
|
+
const CLIENT_UPDATE_HEADER = "harnessforce-client-update";
|
|
649
|
+
const UPDATE_COMMANDS = {
|
|
650
|
+
cli: "npm install -g @harnessforce/cli@latest",
|
|
651
|
+
"claude-code-plugin": "claude plugin update harnessforce@harnessforce-agent"
|
|
652
|
+
};
|
|
653
|
+
const DISPLAY_NAMES = {
|
|
654
|
+
cli: "CLI",
|
|
655
|
+
"claude-code-plugin": "plugin"
|
|
656
|
+
};
|
|
657
|
+
const DAY_MS = 864e5;
|
|
658
|
+
const RECHECK_TIMEOUT_MS = 2e3;
|
|
659
|
+
const userAgentOf = (client) => `harnessforce-${client.component}${client.dev ? "-dev" : ""}/${client.version}`;
|
|
660
|
+
const STATUSES = /* @__PURE__ */ new Set([
|
|
661
|
+
"current",
|
|
662
|
+
"outdated",
|
|
663
|
+
"deprecated",
|
|
664
|
+
"unsupported"
|
|
665
|
+
]);
|
|
666
|
+
const REASONS = /* @__PURE__ */ new Set(["security", "compatibility"]);
|
|
667
|
+
function parseClientUpdateHeader(value) {
|
|
668
|
+
if (!value) return void 0;
|
|
669
|
+
const members = /* @__PURE__ */ new Map();
|
|
670
|
+
for (const member of value.split(",")) {
|
|
671
|
+
const [key, raw] = member.trim().split("=", 2);
|
|
672
|
+
if (key && raw !== void 0) members.set(key, raw);
|
|
469
673
|
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
674
|
+
const status = members.get("status");
|
|
675
|
+
if (!status || !STATUSES.has(status)) return void 0;
|
|
676
|
+
const update = { status };
|
|
677
|
+
const latest = /^"([^"\\]*)"$/.exec(members.get("latest") ?? "")?.[1];
|
|
678
|
+
if (latest) update.latest = latest;
|
|
679
|
+
const reason = members.get("reason");
|
|
680
|
+
if (reason && REASONS.has(reason)) update.reason = reason;
|
|
681
|
+
const seconds = /^@(\d+)$/.exec(members.get("deadline") ?? "")?.[1];
|
|
682
|
+
if (seconds) update.deadline = (/* @__PURE__ */ new Date(Number(seconds) * 1e3)).toISOString();
|
|
683
|
+
return isComplete(update) ? update : void 0;
|
|
684
|
+
}
|
|
685
|
+
function isComplete(update) {
|
|
686
|
+
switch (update.status) {
|
|
687
|
+
case "outdated": return Boolean(update.latest);
|
|
688
|
+
case "deprecated": return Boolean(update.reason) && !Number.isNaN(Date.parse(update.deadline ?? ""));
|
|
689
|
+
case "unsupported": return Boolean(update.reason);
|
|
690
|
+
default: return true;
|
|
474
691
|
}
|
|
475
|
-
|
|
476
|
-
|
|
692
|
+
}
|
|
693
|
+
function stateFrom(value, version) {
|
|
694
|
+
if (!isObject(value) || value.version !== version) return void 0;
|
|
695
|
+
if (typeof value.receivedAt !== "string") return void 0;
|
|
696
|
+
if (typeof value.status !== "string" || !STATUSES.has(value.status)) return void 0;
|
|
697
|
+
const state = value;
|
|
698
|
+
return isComplete(state) ? state : void 0;
|
|
699
|
+
}
|
|
700
|
+
async function readUpdateState(path, version) {
|
|
701
|
+
const saved = await readSavedUpdateState(path);
|
|
702
|
+
return saved?.version === version ? saved : void 0;
|
|
703
|
+
}
|
|
704
|
+
async function readSavedUpdateState(path) {
|
|
705
|
+
try {
|
|
706
|
+
const value = JSON.parse(await (0, node_fs_promises.readFile)(path, "utf8"));
|
|
707
|
+
return isObject(value) && typeof value.version === "string" ? stateFrom(value, value.version) : void 0;
|
|
708
|
+
} catch {
|
|
709
|
+
return;
|
|
477
710
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
711
|
+
}
|
|
712
|
+
async function writeUpdateState(path, state) {
|
|
713
|
+
await (0, node_fs_promises.mkdir)((0, node_path.dirname)(path), {
|
|
714
|
+
recursive: true,
|
|
715
|
+
mode: 448
|
|
716
|
+
});
|
|
717
|
+
const temporary = `${path}.${(0, node_crypto.randomUUID)()}.tmp`;
|
|
718
|
+
await (0, node_fs_promises.writeFile)(temporary, `${JSON.stringify(state)}\n`, { mode: 384 });
|
|
719
|
+
await (0, node_fs_promises.rename)(temporary, path);
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Harnessforceへの要求に`User-Agent`を付け、応答の`Harnessforce-Client-Update`を状態のfileへ保存するfetch。
|
|
723
|
+
* 保存に失敗しても要求の結果は変えない。statePathが無い(pluginのdata directoryが無い)場合は版の申告だけを行う。
|
|
724
|
+
*/
|
|
725
|
+
function recordingFetch(fetchImpl, client, statePath, now) {
|
|
726
|
+
return async (url, init) => {
|
|
727
|
+
const headers = {
|
|
728
|
+
...Object.fromEntries(new Headers(init.headers)),
|
|
729
|
+
"user-agent": userAgentOf(client)
|
|
493
730
|
};
|
|
731
|
+
const response = await fetchImpl(url, {
|
|
732
|
+
...init,
|
|
733
|
+
headers
|
|
734
|
+
});
|
|
735
|
+
const update = parseClientUpdateHeader(response.headers.get(CLIENT_UPDATE_HEADER));
|
|
736
|
+
if (update && statePath) {
|
|
737
|
+
const previous = await readUpdateState(statePath, client.version);
|
|
738
|
+
await writeUpdateState(statePath, {
|
|
739
|
+
...update,
|
|
740
|
+
version: client.version,
|
|
741
|
+
receivedAt: now().toISOString(),
|
|
742
|
+
...previous?.outdatedShownAt ? { outdatedShownAt: previous.outdatedShownAt } : {}
|
|
743
|
+
}).catch(() => {});
|
|
744
|
+
}
|
|
745
|
+
return response;
|
|
494
746
|
};
|
|
495
|
-
};
|
|
496
|
-
const absoluteEnv = (value) => value && (0, node_path.isAbsolute)(value) ? value : void 0;
|
|
497
|
-
const withoutExt = (path, ext) => path.slice(0, -ext.length);
|
|
498
|
-
const colonName = (path) => withoutExt(path, ".md").replace(/\//g, ":");
|
|
499
|
-
async function collectLayout(s, layout) {
|
|
500
|
-
if (layout.skills) for (const name of await listDirectories(layout.skills, s.guard)) await s.addFile("skill", name, (0, node_path.join)(layout.skills, name, "SKILL.md"));
|
|
501
|
-
if (layout.agents) for (const path of await listFiles(layout.agents, ".md", true, s.guard)) await s.addFile("agent", colonName(path), (0, node_path.join)(layout.agents, path));
|
|
502
|
-
if (layout.commands) for (const path of await listFiles(layout.commands, ".md", true, s.guard)) await s.addFile("command", colonName(path), (0, node_path.join)(layout.commands, path));
|
|
503
|
-
if (layout.workflows) for (const path of await listFiles(layout.workflows, ".js", false, s.guard)) await s.addFile("workflow", withoutExt(path, ".js"), (0, node_path.join)(layout.workflows, path));
|
|
504
747
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
748
|
+
const utcDate = (instant) => new Intl.DateTimeFormat("en-CA", {
|
|
749
|
+
timeZone: "UTC",
|
|
750
|
+
year: "numeric",
|
|
751
|
+
month: "2-digit",
|
|
752
|
+
day: "2-digit"
|
|
753
|
+
}).format(new Date(instant));
|
|
754
|
+
const unsupportedLine = (component) => `This Harnessforce ${DISPLAY_NAMES[component]} version is no longer supported, so nothing is being sent to Harnessforce. Update it: \`${UPDATE_COMMANDS[component]}\``;
|
|
755
|
+
function updateLine(component, update) {
|
|
756
|
+
const name = DISPLAY_NAMES[component];
|
|
757
|
+
const command = UPDATE_COMMANDS[component];
|
|
758
|
+
switch (update.status) {
|
|
759
|
+
case "unsupported": return unsupportedLine(component);
|
|
760
|
+
case "deprecated": {
|
|
761
|
+
const stops = `stops sending on ${utcDate(update.deadline ?? "")} (00:00 UTC)`;
|
|
762
|
+
return update.reason === "security" ? `A security update for Harnessforce ${name} is available. This version ${stops}. Update it: \`${command}\`` : `This Harnessforce ${name} version ${stops}. Update it: \`${command}\``;
|
|
763
|
+
}
|
|
764
|
+
case "outdated": return `Harnessforce ${name} ${update.latest} is available. Update it: \`${command}\``;
|
|
765
|
+
default: return;
|
|
766
|
+
}
|
|
508
767
|
}
|
|
509
|
-
function
|
|
510
|
-
if (
|
|
511
|
-
|
|
768
|
+
function shouldShowOutdated(state, now, env) {
|
|
769
|
+
if (state.status !== "outdated" || env.NO_UPDATE_NOTIFIER || env.CI) return false;
|
|
770
|
+
const shownAt = state.outdatedShownAt ? Date.parse(state.outdatedShownAt) : NaN;
|
|
771
|
+
return Number.isNaN(shownAt) || now.getTime() - shownAt >= DAY_MS;
|
|
512
772
|
}
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
773
|
+
const markOutdatedShown = (path, state, now) => writeUpdateState(path, {
|
|
774
|
+
...state,
|
|
775
|
+
outdatedShownAt: now.toISOString()
|
|
776
|
+
});
|
|
777
|
+
/**
|
|
778
|
+
* 表示する1行。outdatedは24時間に1回までとし、表示する場合はその時刻を書く。isSuppressedは、pluginを
|
|
779
|
+
* Claude Codeが自動更新する間など、呼び出し側がoutdatedを示さない条件。
|
|
780
|
+
*/
|
|
781
|
+
async function noticeLine(component, statePath, state, options) {
|
|
782
|
+
if (state.status === "outdated") {
|
|
783
|
+
if (!shouldShowOutdated(state, options.now, options.env) || await options.isSuppressed?.()) return void 0;
|
|
784
|
+
await markOutdatedShown(statePath, state, options.now).catch(() => {});
|
|
785
|
+
}
|
|
786
|
+
return updateLine(component, state);
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* unsupportedを受けてから24時間以上経っていれば、資格情報を送らずに`GET /v1/client-status`で確かめ直す
|
|
790
|
+
* (client-updates.md「送信の停止」)。失敗した場合は状態を変えない。
|
|
791
|
+
*/
|
|
792
|
+
async function recheckClientStatus(options) {
|
|
793
|
+
const { state } = options;
|
|
794
|
+
if (state?.status !== "unsupported" || options.now().getTime() - Date.parse(state.receivedAt) < DAY_MS) return state;
|
|
795
|
+
try {
|
|
796
|
+
await (await options.fetch(underBase(options.ingestBase, "v1/client-status"), {
|
|
797
|
+
method: "GET",
|
|
798
|
+
redirect: "error",
|
|
799
|
+
signal: AbortSignal.timeout(RECHECK_TIMEOUT_MS)
|
|
800
|
+
})).body?.cancel();
|
|
801
|
+
} catch {
|
|
802
|
+
return state;
|
|
803
|
+
}
|
|
804
|
+
return await readUpdateState(options.statePath, options.version) ?? state;
|
|
516
805
|
}
|
|
517
806
|
//#endregion
|
|
518
807
|
//#region ../../packages/agent-core/src/config/plugins.ts
|
|
@@ -692,28 +981,6 @@ async function collectConfig(options) {
|
|
|
692
981
|
};
|
|
693
982
|
}
|
|
694
983
|
//#endregion
|
|
695
|
-
//#region ../../packages/agent-core/src/url.ts
|
|
696
|
-
const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
|
|
697
|
-
"localhost",
|
|
698
|
-
"127.0.0.1",
|
|
699
|
-
"[::1]"
|
|
700
|
-
]);
|
|
701
|
-
function parseAllowedUrl(value) {
|
|
702
|
-
if (!value) return void 0;
|
|
703
|
-
let url;
|
|
704
|
-
try {
|
|
705
|
-
url = new URL(value);
|
|
706
|
-
} catch {
|
|
707
|
-
return;
|
|
708
|
-
}
|
|
709
|
-
return url.protocol === "https:" || url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname) ? url : void 0;
|
|
710
|
-
}
|
|
711
|
-
function underBase(base, path) {
|
|
712
|
-
const url = new URL(base.origin);
|
|
713
|
-
url.pathname = `${base.pathname.replace(/\/+$/, "")}/${path}`;
|
|
714
|
-
return url;
|
|
715
|
-
}
|
|
716
|
-
//#endregion
|
|
717
984
|
//#region ../../packages/agent-core/src/ingest.ts
|
|
718
985
|
const SEND_TIMEOUT_MS = 2e3;
|
|
719
986
|
const INGEST_KEY_WORKSPACE = /^hf_ik_([A-Za-z0-9-]{1,128})_/;
|
|
@@ -755,7 +1022,7 @@ async function postItems(destination, path, items, fetchImpl, timeoutMs = SEND_T
|
|
|
755
1022
|
const isIssueIdentifier = (value) => isToken(value, 300);
|
|
756
1023
|
//#endregion
|
|
757
1024
|
//#region ../../packages/agent-core/src/using-harnessforce.ts
|
|
758
|
-
const usingHarnessforceBody = "---\nname: using-harnessforce\ndescription: Harnessforce
|
|
1025
|
+
const usingHarnessforceBody = "---\nname: using-harnessforce\ndescription: Use when starting a session in an environment with the Harnessforce hook, when starting work on an Issue, or when asked what is recorded or collected. The hook injects this text into the context at session start.\n---\n\n# Using Harnessforce\n\nThis session has the Harnessforce hook installed. Harnessforce links the work of people and AI to Issues and records it. Follow these rules.\n\n## What is recorded\n\n- Session registration: agent, session ID, repository, branch, commit, start time, and the first prompt ID.\n- Config snapshot: the structure of the configuration in use (Claude Code only).\n- OTel telemetry: what the user chooses to send.\n- For work on an Issue, `record-run` registers the session as a Run and closes it. It records no Plan, Decision, or Definition of Done.\n\n## What is not collected\n\n- Prompts, responses, tool input and output, and the body of code diffs arrive only when the user opts in with `--send-content` and the Workspace has opted in. They are not collected by default.\n- Never put these contents, secrets, or environment variable values in MCP tool input or in a record.\n\n## Which hook sends what\n\n- SessionStart: session registration and the config snapshot (Codex sends session registration only).\n- UserPromptSubmit: resends on the first prompt (Claude Code only).\n- A failing hook never stops the session. When the hook cannot send, it shows a one-line warning. Tell the user when a warning appears.\n\n## Which skill and tool to use, and when\n\n- For work on a specified Issue ID, or when the environment variable `HARNESSFORCE_ISSUE` is set, follow `record-run` and use `start_run`, `get_issue`, and `complete_run`. An agent that cannot load the skill calls the tools in the same order.\n- Pass the value of the `harnessforce session_id:` line in the context to `start_run`. If there are several, use the last one. If the line is missing, do not guess the session ID, and do not call `start_run` or `complete_run`.\n- Use `propose-improvements` for the analysis of `/harnessforce:tune`.\n- When no Issue is specified, do not start recording a Run.\n\n## Red Flags\n\nStop when one of these thoughts comes up.\n\n| Thought | Reality |\n| --- | --- |\n| An Issue was specified, but the work is small, so recording is unnecessary | Work on a specified Issue is recorded regardless of its size |\n| The session ID line is missing, so I will guess it and call `start_run` | Do not guess. The Run would no longer be linked to the session |\n| Summarizing the content before recording would help | Do not pass content to the tool. It goes beyond what is collected |\n| A warning appeared, but I can ignore it | Sending is not working. Tell the user |\n\n## Priority\n\n1. The user's instructions (CLAUDE.md, AGENTS.md, direct requests). If the user tells you to stop recording, stop recording the Run.\n2. This introduction.\n3. Default behavior.\n\n## Subagents\n\nA subagent that was delegated only a specific task by its parent does not register a Run by following this introduction. The parent registers the Run.\n".replace(/\r\n/g, "\n").replace(/^---\n[\s\S]*?\n---\n+/, "").trimEnd();
|
|
759
1026
|
//#endregion
|
|
760
1027
|
//#region ../../packages/agent-core/src/session-context.ts
|
|
761
1028
|
const sessionContextLine = (sessionId) => `harnessforce session_id: ${sessionId}`;
|
|
@@ -1205,9 +1472,12 @@ async function sendUnsentUsage(store, destination, deps) {
|
|
|
1205
1472
|
}
|
|
1206
1473
|
//#endregion
|
|
1207
1474
|
//#region src/hook.ts
|
|
1475
|
+
const pluginStatePath = (env) => env.CLAUDE_PLUGIN_DATA && (0, node_path.isAbsolute)(env.CLAUDE_PLUGIN_DATA) ? (0, node_path.join)(env.CLAUDE_PLUGIN_DATA, "update-status.json") : void 0;
|
|
1476
|
+
const cliStatePath = (homeDir) => (0, node_path.join)(homeDir, ".harnessforce", "update-status.json");
|
|
1477
|
+
const CLI_UNSUPPORTED_WARNING = unsupportedLine("cli");
|
|
1208
1478
|
const REVOKED_KEY_MESSAGES = {
|
|
1209
|
-
user: "
|
|
1210
|
-
workspace: "
|
|
1479
|
+
user: "Your ingest key has been revoked. Run `harnessforce init`",
|
|
1480
|
+
workspace: "The organization's ingest key has been revoked. Contact a Workspace administrator"
|
|
1211
1481
|
};
|
|
1212
1482
|
const REGISTERING_SOURCES = /* @__PURE__ */ new Set([
|
|
1213
1483
|
"startup",
|
|
@@ -1216,12 +1486,25 @@ const REGISTERING_SOURCES = /* @__PURE__ */ new Set([
|
|
|
1216
1486
|
]);
|
|
1217
1487
|
const logError = (deps) => (error) => deps.stderr(`harnessforce: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
1218
1488
|
const report = (deps, subject, detail) => deps.stderr(`harnessforce: ${subject} ${detail}\n`);
|
|
1489
|
+
const KEY_WARNINGS = {
|
|
1490
|
+
absent: keyMissingWarning("claude_code"),
|
|
1491
|
+
withheld: DESTINATION_MISMATCH_WARNING.claude_code,
|
|
1492
|
+
unreadable: keyUnreadableWarning("claude_code")
|
|
1493
|
+
};
|
|
1219
1494
|
async function selectUserKey(deps, cwd) {
|
|
1220
|
-
if (!deps.env.HARNESSFORCE_WORKSPACE_ID) return
|
|
1495
|
+
if (!deps.env.HARNESSFORCE_WORKSPACE_ID) return {};
|
|
1221
1496
|
const read = await deps.readUserKey(cwd);
|
|
1222
|
-
if (read.kind === "
|
|
1223
|
-
|
|
1497
|
+
if (read.kind === "found") return { key: read.key };
|
|
1498
|
+
if (read.kind !== "missing") deps.stderr("harnessforce: no user key in keychain or read failed\n");
|
|
1499
|
+
return {
|
|
1500
|
+
warning: KEY_WARNINGS[read.kind],
|
|
1501
|
+
isCliUnsupported: read.kind === "cliUnsupported"
|
|
1502
|
+
};
|
|
1224
1503
|
}
|
|
1504
|
+
const sendingNotice = (message) => message === void 0 ? void 0 : {
|
|
1505
|
+
kind: "sending",
|
|
1506
|
+
message
|
|
1507
|
+
};
|
|
1225
1508
|
async function resolveDestination(deps, cwd, withUserKey = true) {
|
|
1226
1509
|
const managed = await readManagedEnv(deps.managedDir, ["HARNESSFORCE_INGEST_KEY", "HARNESSFORCE_ENDPOINT"]);
|
|
1227
1510
|
const workspaceKey = managed.HARNESSFORCE_INGEST_KEY;
|
|
@@ -1231,17 +1514,24 @@ async function resolveDestination(deps, cwd, withUserKey = true) {
|
|
|
1231
1514
|
const ingestBase = ingestBaseFrom(endpoint);
|
|
1232
1515
|
if (!ingestBase) {
|
|
1233
1516
|
report(deps, "session registration", "skipped (invalid endpoint)");
|
|
1234
|
-
return { warning: notConfigured ? notConfiguredWarning("claude_code") : INVALID_ENDPOINT_WARNING };
|
|
1517
|
+
return { warning: sendingNotice(notConfigured ? notConfiguredWarning("claude_code") : INVALID_ENDPOINT_WARNING) };
|
|
1235
1518
|
}
|
|
1236
1519
|
if (workspaceKey) return { destination: {
|
|
1237
1520
|
ingestBase,
|
|
1238
1521
|
key: workspaceKey,
|
|
1239
1522
|
keyKind: "workspace"
|
|
1240
1523
|
} };
|
|
1241
|
-
const userKey = await selectUserKey(deps, cwd);
|
|
1524
|
+
const { key: userKey, warning, isCliUnsupported } = await selectUserKey(deps, cwd);
|
|
1242
1525
|
if (!userKey) {
|
|
1243
1526
|
report(deps, "session registration", "skipped (no ingest key)");
|
|
1244
|
-
|
|
1527
|
+
if (isCliUnsupported) {
|
|
1528
|
+
await recheckCliStatus(deps, ingestBase);
|
|
1529
|
+
return { warning: {
|
|
1530
|
+
kind: "clientUnsupported",
|
|
1531
|
+
message: CLI_UNSUPPORTED_WARNING
|
|
1532
|
+
} };
|
|
1533
|
+
}
|
|
1534
|
+
return { warning: sendingNotice(notConfigured ? notConfiguredWarning("claude_code") : warning) };
|
|
1245
1535
|
}
|
|
1246
1536
|
return { destination: {
|
|
1247
1537
|
ingestBase,
|
|
@@ -1249,6 +1539,24 @@ async function resolveDestination(deps, cwd, withUserKey = true) {
|
|
|
1249
1539
|
keyKind: "user"
|
|
1250
1540
|
} };
|
|
1251
1541
|
}
|
|
1542
|
+
async function recheckCliStatus(deps, ingestBase) {
|
|
1543
|
+
const statePath = cliStatePath(deps.homeDir);
|
|
1544
|
+
const state = await readSavedUpdateState(statePath);
|
|
1545
|
+
if (!state) return;
|
|
1546
|
+
const cli = {
|
|
1547
|
+
component: "cli",
|
|
1548
|
+
version: state.version,
|
|
1549
|
+
dev: false
|
|
1550
|
+
};
|
|
1551
|
+
await recheckClientStatus({
|
|
1552
|
+
state,
|
|
1553
|
+
ingestBase,
|
|
1554
|
+
fetch: recordingFetch(deps.baseFetch, cli, statePath, deps.now),
|
|
1555
|
+
statePath,
|
|
1556
|
+
version: state.version,
|
|
1557
|
+
now: deps.now
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1252
1560
|
function registrationSource(destination, env) {
|
|
1253
1561
|
const issue = env.HARNESSFORCE_ISSUE;
|
|
1254
1562
|
return destination.keyKind === "user" && issue && isIssueIdentifier(issue) ? {
|
|
@@ -1330,25 +1638,68 @@ async function sendSessionStart(input, usage, deps) {
|
|
|
1330
1638
|
const { destination, warning } = await resolveDestination(deps, input.cwd);
|
|
1331
1639
|
if (!destination) return registers ? warning : void 0;
|
|
1332
1640
|
const collected = registers || usage && !await readState(usage) ? collectComponents(input, deps) : Promise.resolve(void 0);
|
|
1333
|
-
const
|
|
1641
|
+
const isConfigured = registers && destination.keyKind === "user" ? deps.isTelemetryConfigured() : Promise.resolve(true);
|
|
1642
|
+
const [registration, snapshot, unsent, configured] = await Promise.all([
|
|
1334
1643
|
registers ? registerSession(input, destination, deps).catch(logError(deps)) : void 0,
|
|
1335
1644
|
registers ? collected.then((components) => components ? sendConfigSnapshot(input, components, destination, deps) : void 0).catch(logError(deps)) : void 0,
|
|
1336
|
-
usage ? startUsage(usage, destination, collected, deps) : void 0
|
|
1645
|
+
usage ? startUsage(usage, destination, collected, deps) : void 0,
|
|
1646
|
+
isConfigured
|
|
1337
1647
|
]);
|
|
1338
1648
|
if ([
|
|
1339
1649
|
registration,
|
|
1340
1650
|
snapshot,
|
|
1341
1651
|
unsent
|
|
1342
|
-
].some((outcome) => outcome?.kind === "unauthorized")) return
|
|
1343
|
-
|
|
1652
|
+
].some((outcome) => outcome?.kind === "unauthorized")) return {
|
|
1653
|
+
kind: "revoked",
|
|
1654
|
+
message: await reportRevokedKey(destination, input.scratchpad, deps)
|
|
1655
|
+
};
|
|
1656
|
+
if (!configured) return sendingNotice(TELEMETRY_NOT_CONFIGURED_WARNING);
|
|
1657
|
+
return sendingNotice(registration?.kind === "failed" ? REGISTRATION_FAILED_WARNING : void 0);
|
|
1658
|
+
}
|
|
1659
|
+
async function statusCheckBase(deps) {
|
|
1660
|
+
const managed = await readManagedEnv(deps.managedDir, ["HARNESSFORCE_INGEST_KEY", "HARNESSFORCE_ENDPOINT"]);
|
|
1661
|
+
return ingestBaseFrom(managed.HARNESSFORCE_INGEST_KEY ? managed.HARNESSFORCE_ENDPOINT : deps.env.HARNESSFORCE_ENDPOINT);
|
|
1662
|
+
}
|
|
1663
|
+
/**
|
|
1664
|
+
* pluginの状態のfileがunsupportedなら送信を止める(client-updates.md「送信の停止」)。recheckなら、受けてから24時間以上
|
|
1665
|
+
* 経った状態を資格情報を送らずに確かめ直す。SessionEndは1秒の予算に収めるため確かめ直さない。
|
|
1666
|
+
*/
|
|
1667
|
+
async function isPluginBlocked(deps, recheck) {
|
|
1668
|
+
const statePath = pluginStatePath(deps.env);
|
|
1669
|
+
if (!statePath) return false;
|
|
1670
|
+
const state = await readUpdateState(statePath, deps.client.version);
|
|
1671
|
+
if (state?.status !== "unsupported") return false;
|
|
1672
|
+
const ingestBase = recheck ? await statusCheckBase(deps) : void 0;
|
|
1673
|
+
return (ingestBase ? await recheckClientStatus({
|
|
1674
|
+
state,
|
|
1675
|
+
ingestBase,
|
|
1676
|
+
fetch: deps.fetch,
|
|
1677
|
+
statePath,
|
|
1678
|
+
version: deps.client.version,
|
|
1679
|
+
now: deps.now
|
|
1680
|
+
}) : state)?.status === "unsupported";
|
|
1681
|
+
}
|
|
1682
|
+
async function withUpdateLine(notice, input, deps) {
|
|
1683
|
+
if (notice && notice.kind !== "sending") return notice.message;
|
|
1684
|
+
const statePath = pluginStatePath(deps.env);
|
|
1685
|
+
const state = statePath ? await readUpdateState(statePath, deps.client.version) : void 0;
|
|
1686
|
+
if (state?.status === "unsupported") return updateLine("claude-code-plugin", state);
|
|
1687
|
+
if (notice || !state || !statePath) return notice?.message;
|
|
1688
|
+
return noticeLine("claude-code-plugin", statePath, state, {
|
|
1689
|
+
now: deps.now(),
|
|
1690
|
+
env: deps.env,
|
|
1691
|
+
isSuppressed: () => deps.isPluginAutoUpdateEffective(input.cwd)
|
|
1692
|
+
});
|
|
1344
1693
|
}
|
|
1345
1694
|
async function onSessionStart(input, deps) {
|
|
1346
1695
|
const usage = usageStoreOf(input.sessionId, deps.env);
|
|
1347
1696
|
const startedAt = deps.now();
|
|
1348
1697
|
if (usage) await removeExpiredFiles(usage, startedAt).catch(logError(deps));
|
|
1349
|
-
const
|
|
1698
|
+
const registers = input.source === void 0 || REGISTERING_SOURCES.has(input.source);
|
|
1699
|
+
const sent = await isPluginBlocked(deps, true).catch(() => false) ? void 0 : await sendSessionStart(input, usage, deps).catch((error) => {
|
|
1350
1700
|
logError(deps)(error);
|
|
1351
1701
|
});
|
|
1702
|
+
const message = registers ? await withUpdateLine(sent, input, deps).catch(() => sent?.message) : sent?.message;
|
|
1352
1703
|
if (usage) await appendIfRecording(usage, {
|
|
1353
1704
|
at: startedAt.toISOString(),
|
|
1354
1705
|
kind: "start",
|
|
@@ -1368,6 +1719,7 @@ async function onUserPromptSubmit(input, deps) {
|
|
|
1368
1719
|
}).catch(logError(deps));
|
|
1369
1720
|
const pad = input.scratchpad;
|
|
1370
1721
|
if (!input.promptId || !pad || await isMarkedUnauthorized(pad)) return;
|
|
1722
|
+
if (await isPluginBlocked(deps, false)) return;
|
|
1371
1723
|
const saved = await loadRegistration(pad);
|
|
1372
1724
|
if (!saved || await isFirstPromptSent(pad)) return;
|
|
1373
1725
|
const { destination } = await resolveDestination(deps, input.cwd);
|
|
@@ -1382,6 +1734,7 @@ async function onSessionEnd(input, deps) {
|
|
|
1382
1734
|
const usage = usageStoreOf(input.sessionId, deps.env);
|
|
1383
1735
|
if (!usage) return;
|
|
1384
1736
|
if (input.scratchpad && await isMarkedUnauthorized(input.scratchpad)) return;
|
|
1737
|
+
if (await isPluginBlocked(deps, false)) return;
|
|
1385
1738
|
const { destination } = await resolveDestination(deps, input.cwd, false);
|
|
1386
1739
|
if (!destination) return;
|
|
1387
1740
|
const outcome = await sendSessionUsage(usage, destination, deps.processStartMs + SESSION_END_BUDGET_MS, deps);
|
|
@@ -1403,10 +1756,15 @@ const HANDLERS = new Map([
|
|
|
1403
1756
|
["session-end", onSessionEnd],
|
|
1404
1757
|
...COUNTING_EVENTS.map((event) => [event, onCountingEvent(event)])
|
|
1405
1758
|
]);
|
|
1406
|
-
async function runHook(event, rawInput,
|
|
1759
|
+
async function runHook(event, rawInput, hookDeps) {
|
|
1407
1760
|
const handler = HANDLERS.get(event);
|
|
1408
1761
|
const input = parseHookInput(rawInput);
|
|
1409
1762
|
if (!handler || !input) return;
|
|
1763
|
+
const deps = {
|
|
1764
|
+
...hookDeps,
|
|
1765
|
+
baseFetch: hookDeps.fetch,
|
|
1766
|
+
fetch: recordingFetch(hookDeps.fetch, hookDeps.client, pluginStatePath(hookDeps.env), hookDeps.now)
|
|
1767
|
+
};
|
|
1410
1768
|
await handler(input, deps).catch(logError(deps));
|
|
1411
1769
|
}
|
|
1412
1770
|
//#endregion
|
|
@@ -1414,6 +1772,11 @@ async function runHook(event, rawInput, deps) {
|
|
|
1414
1772
|
const HF_TIMEOUT_MS = 1e3;
|
|
1415
1773
|
const HF_OUTPUT_LIMIT_BYTES = 65536;
|
|
1416
1774
|
const BEARER = /^Bearer (\S+)$/;
|
|
1775
|
+
const FAILURES = {
|
|
1776
|
+
3: { kind: "absent" },
|
|
1777
|
+
4: { kind: "withheld" },
|
|
1778
|
+
5: { kind: "cliUnsupported" }
|
|
1779
|
+
};
|
|
1417
1780
|
function parseKey(stdout) {
|
|
1418
1781
|
try {
|
|
1419
1782
|
const value = JSON.parse(stdout);
|
|
@@ -1438,13 +1801,17 @@ function createUserKeyReader({ platform, env, exec, processCwd, fs }) {
|
|
|
1438
1801
|
const result = await exec(command.file, command.args, {
|
|
1439
1802
|
timeoutMs: HF_TIMEOUT_MS,
|
|
1440
1803
|
windowsVerbatimArguments: command.verbatim,
|
|
1441
|
-
env
|
|
1804
|
+
env: {
|
|
1805
|
+
...env,
|
|
1806
|
+
HARNESSFORCE_NO_MIGRATE: "1"
|
|
1807
|
+
}
|
|
1442
1808
|
});
|
|
1443
1809
|
const key = result.ok ? parseKey(result.stdout) : void 0;
|
|
1444
|
-
|
|
1810
|
+
if (key) return {
|
|
1445
1811
|
kind: "found",
|
|
1446
1812
|
key
|
|
1447
|
-
}
|
|
1813
|
+
};
|
|
1814
|
+
return result.exitCode !== void 0 && FAILURES[result.exitCode] || { kind: "unreadable" };
|
|
1448
1815
|
};
|
|
1449
1816
|
}
|
|
1450
1817
|
const execHf = (file, args, options) => new Promise((resolve) => {
|
|
@@ -1458,7 +1825,8 @@ const execHf = (file, args, options) => new Promise((resolve) => {
|
|
|
1458
1825
|
windowsVerbatimArguments: options.windowsVerbatimArguments
|
|
1459
1826
|
}, (error, stdout) => resolve({
|
|
1460
1827
|
ok: !error,
|
|
1461
|
-
stdout
|
|
1828
|
+
stdout,
|
|
1829
|
+
...error && !error.killed && typeof error.code === "number" ? { exitCode: error.code } : {}
|
|
1462
1830
|
}));
|
|
1463
1831
|
});
|
|
1464
1832
|
//#endregion
|
|
@@ -1483,6 +1851,8 @@ async function main() {
|
|
|
1483
1851
|
}
|
|
1484
1852
|
if (relaunch.kind !== "not-needed") return;
|
|
1485
1853
|
const raw = await readStdin().catch(() => "");
|
|
1854
|
+
const managedDir = managedDirFor(process.platform);
|
|
1855
|
+
const userSettingsPath = (0, node_path.join)(absoluteEnv(process.env.CLAUDE_CONFIG_DIR) ?? (0, node_path.join)((0, node_os.homedir)(), ".claude"), "settings.json");
|
|
1486
1856
|
await runHook(process.argv[2] ?? "", raw, {
|
|
1487
1857
|
env: process.env,
|
|
1488
1858
|
now: () => /* @__PURE__ */ new Date(),
|
|
@@ -1497,12 +1867,27 @@ async function main() {
|
|
|
1497
1867
|
stdout: (text) => process.stdout.write(text),
|
|
1498
1868
|
stderr: (text) => process.stderr.write(text),
|
|
1499
1869
|
homeDir: (0, node_os.homedir)(),
|
|
1500
|
-
managedDir
|
|
1870
|
+
managedDir,
|
|
1501
1871
|
readUserKey: createUserKeyReader({
|
|
1502
1872
|
platform: process.platform,
|
|
1503
1873
|
env: process.env,
|
|
1504
1874
|
exec: execHf,
|
|
1505
1875
|
processCwd: process.cwd()
|
|
1876
|
+
}),
|
|
1877
|
+
isTelemetryConfigured: () => isTelemetryConfigured({
|
|
1878
|
+
userSettingsPath,
|
|
1879
|
+
managedDir
|
|
1880
|
+
}),
|
|
1881
|
+
client: {
|
|
1882
|
+
component: "claude-code-plugin",
|
|
1883
|
+
version: "0.2.0",
|
|
1884
|
+
dev: false
|
|
1885
|
+
},
|
|
1886
|
+
isPluginAutoUpdateEffective: (cwd) => isPluginAutoUpdateEffective({
|
|
1887
|
+
userSettingsPath,
|
|
1888
|
+
managedDir,
|
|
1889
|
+
projectDir: absoluteEnv(process.env.CLAUDE_PROJECT_DIR) ?? cwd,
|
|
1890
|
+
env: process.env
|
|
1506
1891
|
})
|
|
1507
1892
|
});
|
|
1508
1893
|
}
|