activate-agentmd 2.4.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 +158 -0
- package/bin/agentmd.js +186 -0
- package/package.json +55 -0
- package/src/commands/agents.js +44 -0
- package/src/commands/analytics.js +128 -0
- package/src/commands/auth.js +144 -0
- package/src/commands/ci.js +296 -0
- package/src/commands/extract.js +254 -0
- package/src/commands/info.js +105 -0
- package/src/commands/init.js +166 -0
- package/src/commands/install.js +216 -0
- package/src/commands/link.js +206 -0
- package/src/commands/list.js +99 -0
- package/src/commands/outdated.js +92 -0
- package/src/commands/remove.js +72 -0
- package/src/commands/review.js +293 -0
- package/src/commands/search.js +110 -0
- package/src/commands/sync.js +245 -0
- package/src/commands/telemetry.js +82 -0
- package/src/commands/test.js +226 -0
- package/src/commands/update.js +115 -0
- package/src/commands/validate.js +125 -0
- package/src/config/license-public-keys.json +17 -0
- package/src/detect.json +452 -0
- package/src/lib/agents.js +301 -0
- package/src/lib/analytics.js +137 -0
- package/src/lib/coordinates.js +103 -0
- package/src/lib/credentials.js +115 -0
- package/src/lib/detect.js +352 -0
- package/src/lib/diff.js +142 -0
- package/src/lib/enterprise.js +140 -0
- package/src/lib/fetcher.js +174 -0
- package/src/lib/invocation.js +47 -0
- package/src/lib/license.js +234 -0
- package/src/lib/manifest.js +191 -0
- package/src/lib/patterns.js +135 -0
- package/src/lib/pro.js +149 -0
- package/src/lib/ranking.js +287 -0
- package/src/lib/registry.js +207 -0
- package/src/lib/star.js +105 -0
- package/src/lib/status.js +75 -0
- package/src/lib/telemetry.js +169 -0
- package/src/lib/versions.js +190 -0
- package/src/registry.json +33907 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* registry.js
|
|
3
|
+
*
|
|
4
|
+
* Reads the generated registry index (registry.json) and resolves package
|
|
5
|
+
* coordinates to raw-content URLs.
|
|
6
|
+
*
|
|
7
|
+
* registry.json is produced by scripts/build-registry.js from the models/
|
|
8
|
+
* tree and is verified in CI, so it can never list a preset that does not
|
|
9
|
+
* exist on disk. Do not hand-edit it.
|
|
10
|
+
*
|
|
11
|
+
* Coordinates are `<model>/<category>/<preset>`, e.g. claude/Security/owasp.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
"use strict";
|
|
15
|
+
|
|
16
|
+
const INDEX = require("../registry.json");
|
|
17
|
+
const { rank } = require("./ranking");
|
|
18
|
+
const MODELS = INDEX.models;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Base URL for raw preset markdown.
|
|
22
|
+
*
|
|
23
|
+
* The public registry repo (Aaditya1273/Agent.md) holds the model directories
|
|
24
|
+
* at its root — `claude/`, `deepseek/`, … — whereas this monorepo keeps them
|
|
25
|
+
* under `models/`. The base must NOT include a `models` segment; it did until
|
|
26
|
+
* v1.4.0, which made every single install 404.
|
|
27
|
+
*
|
|
28
|
+
* Overridable for testing and for self-hosted registry mirrors.
|
|
29
|
+
*/
|
|
30
|
+
const RAW_REPO = "https://raw.githubusercontent.com/Aaditya1273/Agent.md";
|
|
31
|
+
const CLI_VERSION = require("../../package.json").version;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Downloads are pinned to the release tag matching this CLI's version, so the
|
|
35
|
+
* files a user installs are the files this registry index describes. Fetching
|
|
36
|
+
* from `main` meant a half-pushed tree or a package added after the last
|
|
37
|
+
* release produced 404s at install time. `RAW_FALLBACK` (main) is tried only
|
|
38
|
+
* when the tag is missing a file, with a warning — never when the base was
|
|
39
|
+
* overridden for a mirror.
|
|
40
|
+
*/
|
|
41
|
+
const RAW_BASE = process.env.AGENTMD_REGISTRY_BASE || `${RAW_REPO}/v${CLI_VERSION}`;
|
|
42
|
+
const RAW_FALLBACK = process.env.AGENTMD_REGISTRY_BASE ? null : `${RAW_REPO}/main`;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* REGISTRY keeps the { model: { category: [preset, ...] } } shape the rest of
|
|
46
|
+
* the CLI (and the VS Code extension) already consumes.
|
|
47
|
+
*/
|
|
48
|
+
const REGISTRY = Object.create(null);
|
|
49
|
+
for (const [model, categories] of Object.entries(MODELS)) {
|
|
50
|
+
REGISTRY[model] = Object.create(null);
|
|
51
|
+
for (const [category, presets] of Object.entries(categories)) {
|
|
52
|
+
REGISTRY[model][category] = Object.keys(presets);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Own-property lookup — a bare `obj[key]` would resolve "constructor". */
|
|
57
|
+
function own(obj, key) {
|
|
58
|
+
if (typeof key !== "string") return undefined;
|
|
59
|
+
return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Compare form for coordinate lookups: lowercase, separators removed.
|
|
64
|
+
*
|
|
65
|
+
* This is what makes `claude/system-design/architecture` resolve to
|
|
66
|
+
* `System Design`. The website slugifies category names into its URLs, so
|
|
67
|
+
* every install command it generated for the two categories whose names
|
|
68
|
+
* contain a space — "Open Source" and "System Design", 30 packages — was
|
|
69
|
+
* uncopyable. Normalizing here fixes it for the website, the extension and
|
|
70
|
+
* anyone who just types a guess.
|
|
71
|
+
*
|
|
72
|
+
* Verified to produce no collisions across the registry; `agentmd validate`
|
|
73
|
+
* re-checks that, so a future package can't silently become ambiguous.
|
|
74
|
+
*/
|
|
75
|
+
function normalizeKey(key) {
|
|
76
|
+
return key.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Match a key exactly, then case-insensitively, then by normalized form.
|
|
81
|
+
* Ordered most-specific first so an exact name always wins.
|
|
82
|
+
*/
|
|
83
|
+
function resolveKey(obj, key) {
|
|
84
|
+
if (typeof key !== "string") return null;
|
|
85
|
+
if (Object.prototype.hasOwnProperty.call(obj, key)) return key;
|
|
86
|
+
|
|
87
|
+
const keys = Object.keys(obj);
|
|
88
|
+
const lower = key.toLowerCase();
|
|
89
|
+
const exactIgnoringCase = keys.find((k) => k.toLowerCase() === lower);
|
|
90
|
+
if (exactIgnoringCase) return exactIgnoringCase;
|
|
91
|
+
|
|
92
|
+
const normalized = normalizeKey(key);
|
|
93
|
+
if (!normalized) return null;
|
|
94
|
+
return keys.find((k) => normalizeKey(k) === normalized) || null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function listModels() {
|
|
98
|
+
return Object.keys(MODELS);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function listCategories(model) {
|
|
102
|
+
const key = resolveKey(MODELS, model);
|
|
103
|
+
return key ? Object.keys(MODELS[key]) : [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Resolve a preset to its raw content URL.
|
|
108
|
+
*
|
|
109
|
+
* The filename comes from the generated index rather than being guessed from
|
|
110
|
+
* the category, so a preset that breaks the naming convention is caught at
|
|
111
|
+
* build time instead of 404-ing at install time.
|
|
112
|
+
*/
|
|
113
|
+
function buildUrl(model, category, preset, file) {
|
|
114
|
+
const segments = [model, category, preset, file].map((s) => encodeURIComponent(s));
|
|
115
|
+
return `${RAW_BASE}/${segments.join("/")}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Look up one preset.
|
|
120
|
+
* Returns { model, category, preset, file, version, description, bytes, url }
|
|
121
|
+
* with canonical (registry-cased) names, or null.
|
|
122
|
+
*/
|
|
123
|
+
function getPreset(model, category, preset) {
|
|
124
|
+
const m = resolveKey(MODELS, model);
|
|
125
|
+
if (!m) return null;
|
|
126
|
+
const c = resolveKey(MODELS[m], category);
|
|
127
|
+
if (!c) return null;
|
|
128
|
+
const p = resolveKey(MODELS[m][c], preset);
|
|
129
|
+
if (!p) return null;
|
|
130
|
+
|
|
131
|
+
const meta = own(MODELS[m][c], p);
|
|
132
|
+
return {
|
|
133
|
+
model: m,
|
|
134
|
+
category: c,
|
|
135
|
+
preset: p,
|
|
136
|
+
id: `${m}/${c}/${p}`,
|
|
137
|
+
file: meta.file,
|
|
138
|
+
version: meta.version,
|
|
139
|
+
description: meta.description,
|
|
140
|
+
bytes: meta.bytes,
|
|
141
|
+
author: meta.author || null,
|
|
142
|
+
lastVerified: meta.lastVerified || null,
|
|
143
|
+
reviewedBy: meta.reviewedBy || null,
|
|
144
|
+
license: meta.license || "MIT",
|
|
145
|
+
url: buildUrl(m, c, p, meta.file),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** All presets in a category, or [] if the category is unknown. */
|
|
150
|
+
function getCategoryPresets(model, category) {
|
|
151
|
+
const m = resolveKey(MODELS, model);
|
|
152
|
+
if (!m) return [];
|
|
153
|
+
const c = resolveKey(MODELS[m], category);
|
|
154
|
+
if (!c) return [];
|
|
155
|
+
return Object.keys(MODELS[m][c]).map((p) => getPreset(m, c, p));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Every preset of a model, flattened. */
|
|
159
|
+
function getAllPresets(model) {
|
|
160
|
+
const m = resolveKey(MODELS, model);
|
|
161
|
+
if (!m) return [];
|
|
162
|
+
return Object.keys(MODELS[m]).flatMap((c) => getCategoryPresets(m, c));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Which model families ship a given Category/preset.
|
|
167
|
+
*
|
|
168
|
+
* Every other accessor here takes the model as its first, required argument,
|
|
169
|
+
* which is the registry's model-first shape leaking into the API. Browsing is
|
|
170
|
+
* category-first — you know you want MongoDB standards before you know which
|
|
171
|
+
* family you want them phrased for — so this answers the other direction.
|
|
172
|
+
*
|
|
173
|
+
* Returns registry-cased model names, in registry order.
|
|
174
|
+
*/
|
|
175
|
+
function getModelsForPreset(category, preset) {
|
|
176
|
+
return listModels().filter((m) => {
|
|
177
|
+
const c = resolveKey(MODELS[m], category);
|
|
178
|
+
return Boolean(c && resolveKey(MODELS[m][c], preset));
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Search a model's presets by name, category or description.
|
|
184
|
+
*
|
|
185
|
+
* Delegates ranking to lib/ranking.js — the same algorithm the website uses,
|
|
186
|
+
* with stemming, domain synonyms, transposition-tolerant fuzzy matching and
|
|
187
|
+
* coordination scoring for multi-word queries. The previous substring match
|
|
188
|
+
* failed six of ten realistic queries, including "BMW design".
|
|
189
|
+
*/
|
|
190
|
+
function searchPresets(model, query) {
|
|
191
|
+
const m = resolveKey(MODELS, model);
|
|
192
|
+
if (!m) return [];
|
|
193
|
+
return rank(getAllPresets(m), query);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
module.exports = {
|
|
197
|
+
REGISTRY,
|
|
198
|
+
RAW_BASE,
|
|
199
|
+
RAW_FALLBACK,
|
|
200
|
+
listModels,
|
|
201
|
+
listCategories,
|
|
202
|
+
getPreset,
|
|
203
|
+
getCategoryPresets,
|
|
204
|
+
getModelsForPreset,
|
|
205
|
+
getAllPresets,
|
|
206
|
+
searchPresets,
|
|
207
|
+
};
|
package/src/lib/star.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place the CLI asks for a GitHub star.
|
|
3
|
+
*
|
|
4
|
+
* Context for whoever reads this next: the package was getting a few hundred
|
|
5
|
+
* installs a month against single-digit stars. Not because people disliked it
|
|
6
|
+
* — they were never asked. The whole fix is the ask.
|
|
7
|
+
*
|
|
8
|
+
* The rules below exist so it stays an ask and does not become nagging:
|
|
9
|
+
*
|
|
10
|
+
* - only after a command that actually delivered something (`init`, not
|
|
11
|
+
* `search`), and only when it succeeded;
|
|
12
|
+
* - at most once every 30 days;
|
|
13
|
+
* - never in CI, never when output is piped, never when the user has
|
|
14
|
+
* already said no;
|
|
15
|
+
* - never on a failure, where it would read as tone-deaf.
|
|
16
|
+
*
|
|
17
|
+
* It writes one small file next to the other CLI state and fails silently if
|
|
18
|
+
* it cannot. A prompt for a favour must never be the thing that breaks
|
|
19
|
+
* somebody's install.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
"use strict";
|
|
23
|
+
|
|
24
|
+
const fs = require("fs");
|
|
25
|
+
const path = require("path");
|
|
26
|
+
const pc = require("picocolors");
|
|
27
|
+
const { configDir } = require("./credentials");
|
|
28
|
+
|
|
29
|
+
const FILE = "star.json";
|
|
30
|
+
const REPO = "https://github.com/Aaditya1273/Agent.md";
|
|
31
|
+
const EVERY_MS = 30 * 24 * 60 * 60 * 1000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Commands that leave the user better off than they started.
|
|
35
|
+
*
|
|
36
|
+
* `search` and `list` are browsing — the person has not received anything yet
|
|
37
|
+
* and asking mid-browse is begging. `validate` and `outdated` are checks that
|
|
38
|
+
* often end in bad news. Those are the wrong moments.
|
|
39
|
+
*/
|
|
40
|
+
const EARNED = new Set(["init", "link", "install", "update", "review", "test", "extract", "ci"]);
|
|
41
|
+
|
|
42
|
+
const statePath = () => path.join(configDir(), FILE);
|
|
43
|
+
|
|
44
|
+
function read() {
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(fs.readFileSync(statePath(), "utf-8"));
|
|
47
|
+
if (parsed && typeof parsed === "object") return parsed;
|
|
48
|
+
} catch {
|
|
49
|
+
// absent or unreadable — treat as never shown
|
|
50
|
+
}
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function write(state) {
|
|
55
|
+
try {
|
|
56
|
+
fs.mkdirSync(configDir(), { recursive: true, mode: 0o700 });
|
|
57
|
+
fs.writeFileSync(statePath(), JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
|
|
58
|
+
} catch {
|
|
59
|
+
// A read-only home directory is not our problem to escalate. Worst case
|
|
60
|
+
// the ask shows again next month, which is the same as never storing it.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Permanently stop asking. Exported for `agentmd telemetry --no-star` style opt-outs. */
|
|
65
|
+
function dismiss() {
|
|
66
|
+
write({ ...read(), dismissed: true });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function shouldShow(command) {
|
|
70
|
+
if (!EARNED.has(command)) return false;
|
|
71
|
+
|
|
72
|
+
// Not a terminal means the output is being piped, redirected or parsed.
|
|
73
|
+
if (!process.stdout.isTTY) return false;
|
|
74
|
+
if (process.env.CI) return false;
|
|
75
|
+
if (process.env.AGENTMD_NO_STAR) return false;
|
|
76
|
+
|
|
77
|
+
const state = read();
|
|
78
|
+
if (state.dismissed) return false;
|
|
79
|
+
if (state.shownAt && Date.now() - state.shownAt < EVERY_MS) return false;
|
|
80
|
+
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Print the ask, if this is a moment that earned one.
|
|
86
|
+
*
|
|
87
|
+
* Deliberately two lines and no prompt. An interactive y/n here would block a
|
|
88
|
+
* script, and the honest truth is that anyone who wants to star it will click
|
|
89
|
+
* the link — making them answer a question first only costs goodwill.
|
|
90
|
+
*/
|
|
91
|
+
function maybeAsk(command) {
|
|
92
|
+
if (!shouldShow(command)) return false;
|
|
93
|
+
|
|
94
|
+
console.log("");
|
|
95
|
+
console.log(
|
|
96
|
+
` ${pc.yellow("★")} ${pc.dim("If this saved you a debugging session, a star is how we know to keep going.")}`
|
|
97
|
+
);
|
|
98
|
+
console.log(` ${pc.dim(pc.underline(REPO))}`);
|
|
99
|
+
console.log("");
|
|
100
|
+
|
|
101
|
+
write({ ...read(), shownAt: Date.now() });
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { maybeAsk, dismiss, shouldShow, EARNED, REPO };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* status.js
|
|
3
|
+
*
|
|
4
|
+
* Works out, for every installed package, whether it is current, has an
|
|
5
|
+
* update waiting, or has been edited locally.
|
|
6
|
+
*
|
|
7
|
+
* Shared by `outdated` and `update` so the two can never disagree about what
|
|
8
|
+
* counts as an update.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
"use strict";
|
|
12
|
+
|
|
13
|
+
const fs = require("fs");
|
|
14
|
+
const path = require("path");
|
|
15
|
+
const { getPreset } = require("./registry");
|
|
16
|
+
const { fetchText } = require("./fetcher");
|
|
17
|
+
const { checksum } = require("./manifest");
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* State of one installed package:
|
|
21
|
+
*
|
|
22
|
+
* current local matches the registry
|
|
23
|
+
* outdated the registry has different content
|
|
24
|
+
* modified the local file was edited after install (registry unchanged)
|
|
25
|
+
* conflict edited locally AND the registry moved on
|
|
26
|
+
* missing the file is gone from disk
|
|
27
|
+
* removed the package is no longer in the registry
|
|
28
|
+
* failed could not be checked
|
|
29
|
+
*/
|
|
30
|
+
async function checkOne(cwd, entry) {
|
|
31
|
+
const remote = getPreset(entry.model, entry.category, entry.preset);
|
|
32
|
+
if (!remote) return { entry, state: "removed" };
|
|
33
|
+
|
|
34
|
+
const filePath = path.join(cwd, entry.file);
|
|
35
|
+
const exists = fs.existsSync(filePath);
|
|
36
|
+
|
|
37
|
+
// A missing file still needs its remote content fetched. Returning early
|
|
38
|
+
// without it left `update` calling writeFileSync(undefined, undefined) —
|
|
39
|
+
// it crashed on exactly the state `outdated` tells you to run it for.
|
|
40
|
+
const localContent = exists ? fs.readFileSync(filePath, "utf-8") : "";
|
|
41
|
+
const localSum = checksum(localContent);
|
|
42
|
+
const editedLocally = exists && Boolean(entry.checksum) && localSum !== entry.checksum;
|
|
43
|
+
|
|
44
|
+
let remoteContent;
|
|
45
|
+
try {
|
|
46
|
+
remoteContent = await fetchText(remote.url);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
return { entry, state: "failed", remote, error: err.message };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const remoteSum = checksum(remoteContent);
|
|
52
|
+
if (!exists) {
|
|
53
|
+
return { entry, state: "missing", remote, remoteContent, remoteSum, filePath, localContent };
|
|
54
|
+
}
|
|
55
|
+
const registryMoved = remoteSum !== (entry.checksum || localSum);
|
|
56
|
+
|
|
57
|
+
let state = "current";
|
|
58
|
+
if (registryMoved && editedLocally) state = "conflict";
|
|
59
|
+
else if (registryMoved) state = "outdated";
|
|
60
|
+
else if (editedLocally) state = "modified";
|
|
61
|
+
|
|
62
|
+
return { entry, state, remote, remoteContent, remoteSum, filePath, localContent };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Check every installed package. Runs in small batches to stay polite. */
|
|
66
|
+
async function checkAll(cwd, presets, batchSize = 6) {
|
|
67
|
+
const results = [];
|
|
68
|
+
for (let i = 0; i < presets.length; i += batchSize) {
|
|
69
|
+
const batch = presets.slice(i, i + batchSize);
|
|
70
|
+
results.push(...(await Promise.all(batch.map((entry) => checkOne(cwd, entry)))));
|
|
71
|
+
}
|
|
72
|
+
return results;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { checkOne, checkAll };
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* telemetry.js
|
|
3
|
+
*
|
|
4
|
+
* Anonymous usage counters. **Off until you turn it on.**
|
|
5
|
+
*
|
|
6
|
+
* Opt-in rather than opt-out is not a courtesy here, it is the only defensible
|
|
7
|
+
* setting: this tool reads your repository. A developer tool that phones home
|
|
8
|
+
* by default and asks forgiveness in a blog post is the thing this registry
|
|
9
|
+
* exists to be the opposite of.
|
|
10
|
+
*
|
|
11
|
+
* When it is on, an event is nine fields — the command name, whether it
|
|
12
|
+
* succeeded, a duration bucket, and enough version information to know which
|
|
13
|
+
* platforms to keep supporting. There is no field for a path, a repository
|
|
14
|
+
* name, a package name, a diff, a finding, or a hostname, because the payload
|
|
15
|
+
* is built by an allowlist below and anything not named there cannot travel.
|
|
16
|
+
*
|
|
17
|
+
* `posthog-node` was the obvious choice and was declined: it is 40-odd
|
|
18
|
+
* transitive packages to send a JSON object, in a CLI that promises one
|
|
19
|
+
* runtime dependency. This is that JSON object.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
"use strict";
|
|
23
|
+
|
|
24
|
+
const fs = require("fs");
|
|
25
|
+
const path = require("path");
|
|
26
|
+
const https = require("https");
|
|
27
|
+
const crypto = require("crypto");
|
|
28
|
+
const { configDir } = require("./credentials");
|
|
29
|
+
|
|
30
|
+
const FILE = "telemetry.json";
|
|
31
|
+
const ENDPOINT = process.env.AGENTMD_TELEMETRY_URL || "https://agent-dot-md.vercel.app/api/telemetry";
|
|
32
|
+
const settingsPath = () => path.join(configDir(), FILE);
|
|
33
|
+
|
|
34
|
+
/** Exactly what may be sent. A field not in here cannot reach the wire. */
|
|
35
|
+
const ALLOWED_FIELDS = ["event", "command", "ok", "durationBucket", "findings", "cliVersion", "nodeMajor", "platform", "ci", "anonymousId", "sentAt"];
|
|
36
|
+
|
|
37
|
+
/** Events we emit. Named so the whole vocabulary is visible in one place. */
|
|
38
|
+
const EVENTS = {
|
|
39
|
+
COMMAND_RUN: "command_run",
|
|
40
|
+
HALLUCINATIONS_BLOCKED: "hallucinations_blocked",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function readSettings() {
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(fs.readFileSync(settingsPath(), "utf-8"));
|
|
46
|
+
if (parsed && typeof parsed === "object") return parsed;
|
|
47
|
+
} catch {
|
|
48
|
+
// absent or unreadable — telemetry stays off, which is the safe default
|
|
49
|
+
}
|
|
50
|
+
return { enabled: false, anonymousId: null };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function writeSettings(settings) {
|
|
54
|
+
const dir = configDir();
|
|
55
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
56
|
+
fs.writeFileSync(settingsPath(), JSON.stringify(settings, null, 2) + "\n", { mode: 0o600 });
|
|
57
|
+
return settingsPath();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Is telemetry on right now?
|
|
62
|
+
*
|
|
63
|
+
* Every common kill switch is honoured, and each one wins over the stored
|
|
64
|
+
* setting: a user who exports DO_NOT_TRACK has already answered this question
|
|
65
|
+
* for every tool on their machine and should not have to answer it again.
|
|
66
|
+
*/
|
|
67
|
+
function isEnabled() {
|
|
68
|
+
if (process.env.AGENTMD_TELEMETRY_DISABLED || process.env.DO_NOT_TRACK || process.env.DONT_TRACK) return false;
|
|
69
|
+
if (process.env.AGENTMD_TELEMETRY === "0" || process.env.AGENTMD_TELEMETRY === "off") return false;
|
|
70
|
+
if (process.env.AGENTMD_TELEMETRY === "1" || process.env.AGENTMD_TELEMETRY === "on") return true;
|
|
71
|
+
return readSettings().enabled === true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Turn it on, minting an anonymous id if there is not one yet. */
|
|
75
|
+
function enable() {
|
|
76
|
+
const settings = readSettings();
|
|
77
|
+
settings.enabled = true;
|
|
78
|
+
settings.anonymousId = settings.anonymousId || crypto.randomUUID();
|
|
79
|
+
settings.decidedAt = new Date().toISOString();
|
|
80
|
+
writeSettings(settings);
|
|
81
|
+
return settings;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Turn it off and forget the id.
|
|
86
|
+
*
|
|
87
|
+
* Dropping the identifier matters: leaving it behind means re-enabling later
|
|
88
|
+
* silently rejoins the old history, which is not what "off" means to anyone.
|
|
89
|
+
*/
|
|
90
|
+
function disable() {
|
|
91
|
+
const settings = readSettings();
|
|
92
|
+
writeSettings({ enabled: false, anonymousId: null, decidedAt: new Date().toISOString() });
|
|
93
|
+
return settings;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Durations as buckets, because an exact millisecond is a fingerprint. */
|
|
97
|
+
function bucket(ms) {
|
|
98
|
+
if (ms < 250) return "<250ms";
|
|
99
|
+
if (ms < 1000) return "<1s";
|
|
100
|
+
if (ms < 5000) return "<5s";
|
|
101
|
+
if (ms < 30000) return "<30s";
|
|
102
|
+
return "30s+";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Strip anything not on the allowlist, then coerce what is left. */
|
|
106
|
+
function sanitise(payload) {
|
|
107
|
+
const out = {};
|
|
108
|
+
for (const key of ALLOWED_FIELDS) {
|
|
109
|
+
const value = payload[key];
|
|
110
|
+
if (value === undefined || value === null) continue;
|
|
111
|
+
out[key] = typeof value === "number" || typeof value === "boolean" ? value : String(value).slice(0, 64);
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Send one event. Fire and forget: never awaited, never retried, never allowed
|
|
118
|
+
* to fail a command or hold the process open.
|
|
119
|
+
*/
|
|
120
|
+
function track(event, fields = {}) {
|
|
121
|
+
if (!isEnabled()) return false;
|
|
122
|
+
|
|
123
|
+
const settings = readSettings();
|
|
124
|
+
const body = sanitise({
|
|
125
|
+
event,
|
|
126
|
+
cliVersion: require("../../package.json").version,
|
|
127
|
+
nodeMajor: process.versions.node.split(".")[0],
|
|
128
|
+
platform: process.platform,
|
|
129
|
+
ci: !!process.env.CI,
|
|
130
|
+
anonymousId: settings.anonymousId || "unknown",
|
|
131
|
+
sentAt: new Date().toISOString(),
|
|
132
|
+
...fields,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const payload = Buffer.from(JSON.stringify(body));
|
|
137
|
+
const req = https.request(
|
|
138
|
+
new URL(ENDPOINT),
|
|
139
|
+
{ method: "POST", headers: { "content-type": "application/json", "content-length": payload.length, "user-agent": "activate-agentmd" } },
|
|
140
|
+
(res) => res.resume()
|
|
141
|
+
);
|
|
142
|
+
req.setTimeout(2000, () => req.destroy());
|
|
143
|
+
req.on("error", () => {}); // an unreachable endpoint is not the user's problem
|
|
144
|
+
if (typeof req.unref === "function") req.unref(); // never hold the CLI open
|
|
145
|
+
req.end(payload);
|
|
146
|
+
return true;
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Time a command and report how it went. Returns whatever `run` returns. */
|
|
153
|
+
async function timed(command, run) {
|
|
154
|
+
const started = Date.now();
|
|
155
|
+
let ok = true;
|
|
156
|
+
try {
|
|
157
|
+
return await run();
|
|
158
|
+
} catch (err) {
|
|
159
|
+
ok = false;
|
|
160
|
+
throw err;
|
|
161
|
+
} finally {
|
|
162
|
+
track(EVENTS.COMMAND_RUN, { command, ok, durationBucket: bucket(Date.now() - started) });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = {
|
|
167
|
+
isEnabled, enable, disable, track, timed, bucket, sanitise,
|
|
168
|
+
readSettings, settingsPath, EVENTS, ALLOWED_FIELDS, ENDPOINT,
|
|
169
|
+
};
|