human-machine-swapper 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -7,7 +7,27 @@ Instructions for coding agents adding this component to a site.
7
7
  A dependency-free web component that adds a Human / Machine pill. Machine shows the site's
8
8
  `llms.txt` rendered in place. One script tag, one custom element, no build step.
9
9
 
10
- ## Install it in three steps, all three required
10
+ ## Do this first
11
+
12
+ ```bash
13
+ npx human-machine-swapper init
14
+ ```
15
+
16
+ It detects the framework, writes a starter `llms.txt` if the site has none, declares it,
17
+ loads the component pinned to a version with an integrity hash, mounts the element, and adds
18
+ the `:not(:defined)` guard. It is idempotent, so running it twice is safe, and it never
19
+ overwrites an existing `llms.txt`.
20
+
21
+ Add `--dry-run` to see the changes without writing them.
22
+
23
+ **Read the output.** It reports anything it could not work out rather than guessing, and it
24
+ tells you how many `TODO`s are left in the generated `llms.txt`. Exit codes: `0` installed,
25
+ `1` could not identify the project, `2` installed but something needs a human.
26
+
27
+ If the command cannot identify the project, or you are editing a site by hand, the install
28
+ is these three things plus one CSS rule.
29
+
30
+ ## By hand, if you have to
11
31
 
12
32
  1. **Load the script.**
13
33
 
@@ -30,8 +50,8 @@ A dependency-free web component that adds a Human / Machine pill. Machine shows
30
50
  human-machine-swapper:not(:defined) { display: none; }
31
51
  ```
32
52
 
33
- Without it, the element is laid out in flow at ~50px tall until the script loads and pushes
34
- the rest of the page down.
53
+ Without it, the element is laid out in flow at ~50px tall until the script loads and
54
+ pushes the rest of the page down.
35
55
 
36
56
  ## The site must have an llms.txt
37
57
 
package/README.md CHANGED
@@ -16,6 +16,20 @@ and one custom element.
16
16
 
17
17
  ## Install
18
18
 
19
+ ### One command
20
+
21
+ ```bash
22
+ npx human-machine-swapper init
23
+ ```
24
+
25
+ Detects your framework, writes a starter `llms.txt` if you have none, declares it, loads the
26
+ component **pinned to a version with an integrity hash**, mounts the element, and adds the
27
+ `:not(:defined)` guard below. Idempotent, never overwrites an existing `llms.txt`, and
28
+ `--dry-run` shows the changes without making them.
29
+
30
+ Knows Next (app and pages router), Astro, SvelteKit, Nuxt, WordPress themes and plain HTML.
31
+ If it cannot identify your project it changes nothing and prints the manual steps.
32
+
19
33
  ### Script tag
20
34
 
21
35
  ```html
@@ -38,7 +52,8 @@ import "human-machine-swapper";
38
52
 
39
53
  ## Required setup
40
54
 
41
- Three things. Skip any one of them and the pill will not appear, or will appear broken.
55
+ Three things. `init` does all of them; this is what it is doing, and what to do by hand.
56
+ Skip any one and the pill will not appear, or will appear broken.
42
57
 
43
58
  ### 1. Publish an `llms.txt`
44
59
 
@@ -268,12 +283,31 @@ curl -s https://unpkg.com/human-machine-swapper@1.0.0/src/human-machine-swapper.
268
283
  | openssl dgst -sha384 -binary | openssl base64 -A
269
284
  ```
270
285
 
286
+ ### Provenance
287
+
288
+ From 1.0.2 this package is published by GitHub Actions through npm's trusted publishing, with
289
+ no long-lived token anywhere. Every release carries a signed attestation naming the commit and
290
+ the workflow that built it, so you can check the tarball came from this repo rather than from
291
+ somebody's credentials:
292
+
293
+ ```bash
294
+ npm audit signatures
295
+ ```
296
+
297
+ That is a stronger claim than an integrity hash. A hash says the bytes did not change in
298
+ transit; the attestation says where the bytes came from.
299
+
271
300
  A pinned build does not update, so you take on watching for releases. Self-hosting the file is
272
301
  the third option and works identically: it is one dependency-free script with no network calls
273
302
  of its own beyond reading your `llms.txt`.
274
303
 
275
304
  ---
276
305
 
306
+ ## Contributing
307
+
308
+ Releases are cut by pushing a tag; see [RELEASING.md](RELEASING.md). There is no npm token —
309
+ publishing uses trusted publishing, and every release carries a provenance attestation.
310
+
277
311
  ## Licence
278
312
 
279
313
  Apache-2.0.
package/bin/cli.mjs ADDED
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env node
2
+ import { createHash } from "node:crypto";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ import { detect, readSiteFacts } from "../src/init/detect.mjs";
8
+ import { buildLlmsTxt, countTodos } from "../src/init/llms.mjs";
9
+ import { apply, buildScriptTag, isJsxLayout, ELEMENT_TAG, LINK_TAG, CSS_GUARD } from "../src/init/apply.mjs";
10
+
11
+ /**
12
+ * `npx human-machine-swapper init`
13
+ *
14
+ * Non-interactive on purpose. The most common caller is a coding agent that was told "add a
15
+ * human/machine toggle to this site", and a prompt it cannot answer is a hang. Everything
16
+ * is decided from what is on disk; anything undecidable is reported as manual rather than
17
+ * guessed at.
18
+ *
19
+ * Exit codes are meaningful, because an agent reads them:
20
+ * 0 installed, or already installed
21
+ * 1 could not tell what this project is
22
+ * 2 installed, but something needs a human
23
+ */
24
+
25
+ const HERE = dirname(fileURLToPath(import.meta.url));
26
+ const PKG = JSON.parse(readFileSync(join(HERE, "../package.json"), "utf8"));
27
+ const DOCS = "https://advancelabs.dev/human-machine-swapper";
28
+
29
+ const C = process.stdout.isTTY && !process.env.NO_COLOR
30
+ ? { dim: "\x1b[2m", b: "\x1b[1m", g: "\x1b[32m", y: "\x1b[33m", r: "\x1b[31m", x: "\x1b[0m" }
31
+ : { dim: "", b: "", g: "", y: "", r: "", x: "" };
32
+
33
+ function usage() {
34
+ console.log(`${C.b}human-machine-swapper${C.x} ${PKG.version}
35
+
36
+ npx human-machine-swapper init [options]
37
+
38
+ Adds the Human/Machine pill to this project: writes a starter llms.txt, declares it,
39
+ loads the component, mounts it, and adds the :not(:defined) guard that stops the
40
+ element being laid out in flow before the script arrives.
41
+
42
+ Options
43
+ --dry-run Show what would change, write nothing
44
+ --latest Use the floating CDN url instead of pinning this version with an
45
+ integrity hash. Auto-updates, but cannot be verified
46
+ --dir <path> Project root (default: cwd)
47
+ --help This
48
+
49
+ Docs: ${DOCS}`);
50
+ }
51
+
52
+ function parseArgs(argv) {
53
+ // The first NON-flag is the command. Taking argv[0] blindly made `--help` an unknown
54
+ // command, which is the first thing anybody types.
55
+ const args = { cmd: null, dryRun: false, pin: true, dir: process.cwd() };
56
+ for (let i = 0; i < argv.length; i++) {
57
+ const a = argv[i];
58
+ if (a === "--dry-run" || a === "-n") args.dryRun = true;
59
+ else if (a === "--latest") args.pin = false;
60
+ else if (a === "--dir") args.dir = argv[++i];
61
+ else if (a === "--help" || a === "-h") args.help = true;
62
+ else if (!a.startsWith("-") && !args.cmd) args.cmd = a;
63
+ }
64
+ return args;
65
+ }
66
+
67
+ /** sha384 of the file this very package will have published. Verifiable, not asserted. */
68
+ function integrityOfSelf() {
69
+ try {
70
+ const bytes = readFileSync(join(HERE, "../src/human-machine-swapper.js"));
71
+ return "sha384-" + createHash("sha384").update(bytes).digest("base64");
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function report(actions) {
78
+ const mark = { done: `${C.g}+${C.x}`, skipped: `${C.dim}=${C.x}`, manual: `${C.y}!${C.x}` };
79
+ for (const a of actions) {
80
+ const where = a.file ? ` ${C.dim}${a.file}${C.x}` : "";
81
+ const note = a.note ? ` ${C.dim}(${a.note})${C.x}` : "";
82
+ console.log(` ${mark[a.status]} ${a.what}${where}${note}`);
83
+ }
84
+ }
85
+
86
+ function main() {
87
+ const args = parseArgs(process.argv.slice(2));
88
+
89
+ if (args.help || !args.cmd) {
90
+ usage();
91
+ process.exit(0);
92
+ }
93
+ if (args.cmd !== "init") {
94
+ console.error(`Unknown command "${args.cmd}". Try: npx human-machine-swapper init`);
95
+ process.exit(1);
96
+ }
97
+
98
+ const framework = detect(args.dir);
99
+ if (!framework) {
100
+ console.error(`${C.r}Could not tell what kind of project this is.${C.x}
101
+
102
+ Looked for a Next, Astro, SvelteKit, Nuxt or WordPress project, or an index.html.
103
+ Nothing matched, so nothing was changed.
104
+
105
+ Install it by hand, which is three things:
106
+
107
+ 1. ${LINK_TAG}
108
+ 2. ${buildScriptTag({ pin: false })}
109
+ 3. ${ELEMENT_TAG}
110
+
111
+ and this CSS, which is not optional:
112
+
113
+ ${CSS_GUARD.split("\n").map((l) => " " + l).join("\n")}
114
+
115
+ Then make sure the site serves an llms.txt. Full docs: ${DOCS}`);
116
+ process.exit(1);
117
+ }
118
+
119
+ const facts = readSiteFacts(args.dir);
120
+ const llmsTxt = buildLlmsTxt(facts, { framework: framework.name });
121
+ const scriptTag = buildScriptTag({
122
+ version: PKG.version,
123
+ integrity: integrityOfSelf(),
124
+ pin: args.pin,
125
+ jsx: isJsxLayout(framework.layout),
126
+ });
127
+
128
+ console.log(`\n${C.b}${framework.name}${C.x}${args.dryRun ? ` ${C.y}(dry run, nothing written)${C.x}` : ""}\n`);
129
+
130
+ const { actions, wrote } = apply({ dir: args.dir, framework, llmsTxt, scriptTag, dryRun: args.dryRun });
131
+ report(actions);
132
+
133
+ const todos = countTodos(llmsTxt);
134
+ const needsHuman = actions.filter((a) => a.status === "manual");
135
+ const wroteLlms = actions.some((a) => a.what === "llms.txt" && a.status === "done");
136
+
137
+ console.log("");
138
+ if (wroteLlms && todos) {
139
+ console.log(
140
+ `${C.y}The llms.txt is a starting point and has ${todos} TODO${todos === 1 ? "" : "s"} in it.${C.x}\n` +
141
+ `Machine view shows that file verbatim, so whatever is in it is what an agent reads\n` +
142
+ `about this site. Ten minutes editing it is the whole value of installing this.\n`
143
+ );
144
+ }
145
+ if (needsHuman.length) {
146
+ console.log(`${C.y}${needsHuman.length} thing${needsHuman.length === 1 ? "" : "s"} still need${needsHuman.length === 1 ? "s" : ""} you:${C.x}`);
147
+ for (const a of needsHuman) console.log(` ! ${a.what}: ${a.note}`);
148
+ console.log("");
149
+ }
150
+ if (args.pin) {
151
+ console.log(
152
+ `${C.dim}Pinned to ${PKG.version} with an integrity hash, so the tag cannot change under you.\n` +
153
+ `Re-run with --latest if you would rather auto-update. Docs: ${DOCS}${C.x}`
154
+ );
155
+ }
156
+
157
+ if (args.dryRun) console.log(`\n${C.dim}Dry run. ${wrote.length} file${wrote.length === 1 ? "" : "s"} would change.${C.x}`);
158
+ process.exit(needsHuman.length ? 2 : 0);
159
+ }
160
+
161
+ main();
package/llms.txt CHANGED
@@ -7,6 +7,19 @@
7
7
 
8
8
  ## Install
9
9
 
10
+ One command, from the project root:
11
+
12
+ npx human-machine-swapper init
13
+
14
+ It detects the framework, writes a starter llms.txt if there is none, declares it, loads the
15
+ component pinned with an integrity hash, mounts the element, and adds the :not(:defined)
16
+ guard. Idempotent; never overwrites an existing llms.txt. Add --dry-run to preview.
17
+
18
+ Exit codes: 0 installed, 1 could not identify the project, 2 installed but something needs a
19
+ human.
20
+
21
+ By hand instead:
22
+
10
23
  <script src="https://unpkg.com/human-machine-swapper" defer></script>
11
24
  <human-machine-swapper data-position="bottom-center"></human-machine-swapper>
12
25
 
@@ -56,9 +69,10 @@ A: An Apache-2.0 web component published by Advance Labs Inc. that puts a Human
56
69
  toggle on a website, where Machine shows the site's llms.txt rendered in place.
57
70
 
58
71
  Q: How do I add a human machine toggle to a website?
59
- A: Add the script tag, add the <human-machine-swapper> element, add the
60
- human-machine-swapper:not(:defined){display:none} CSS rule, and make sure the site serves an
61
- llms.txt.
72
+ A: Run `npx human-machine-swapper init` in the project root. It does all of it: writes a
73
+ starter llms.txt, declares it, loads the component, mounts the element and adds the required
74
+ CSS guard. By hand it is a script tag, a <human-machine-swapper> element, the
75
+ human-machine-swapper:not(:defined){display:none} rule, and an llms.txt the site serves.
62
76
 
63
77
  Q: Why is the pill not showing up?
64
78
  A: Almost always because the site has no llms.txt. The component hides itself when it has
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "human-machine-swapper",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "A web component that adds a Human / Machine pill to any website. Machine shows the site's llms.txt, the plain-text reading written for language models, rendered in place. One file, about 6 KB gzipped, no dependencies and no build step.",
5
5
  "keywords": [
6
6
  "llms.txt",
@@ -37,6 +37,7 @@
37
37
  "./package.json": "./package.json"
38
38
  },
39
39
  "files": [
40
+ "bin",
40
41
  "src",
41
42
  "llms.txt",
42
43
  "AGENTS.md",
@@ -52,5 +53,8 @@
52
53
  },
53
54
  "publishConfig": {
54
55
  "access": "public"
56
+ },
57
+ "bin": {
58
+ "human-machine-swapper": "bin/cli.mjs"
55
59
  }
56
60
  }
@@ -0,0 +1,213 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+
4
+ /**
5
+ * The three edits, applied to whatever the detector found.
6
+ *
7
+ * Everything here is idempotent and additive. `init` runs inside somebody else's
8
+ * repository, so the rules are: never overwrite a file that already has content we did not
9
+ * write, never touch a line we did not add, and when a file does not look the way we
10
+ * expect, report it as manual rather than guessing. A wrong guess here breaks a stranger's
11
+ * layout and reads as our bug.
12
+ */
13
+
14
+ /**
15
+ * The script tag init writes into somebody else's repository.
16
+ *
17
+ * Pinned with an integrity hash by default, because a floating CDN URL cannot be pinned at
18
+ * all and this command is writing into a repo whose owner never chose to trust unpkg. The
19
+ * hash is computed from the bytes on disk in the very package doing the writing, so it is
20
+ * verifiable rather than asserted. `--latest` opts into auto-updating instead.
21
+ */
22
+ export function buildScriptTag({ version, integrity, pin = true, jsx = false }) {
23
+ // JSX is not HTML: React wants crossOrigin, and the lowercase spelling is not the DOM
24
+ // property. Emitting the HTML spelling into a .jsx file is a real bug, not a nit.
25
+ const cross = jsx ? "crossOrigin" : "crossorigin";
26
+ if (!pin || !version || !integrity) {
27
+ return '<script src="https://unpkg.com/human-machine-swapper" defer></script>';
28
+ }
29
+ const src = `https://unpkg.com/human-machine-swapper@${version}/src/human-machine-swapper.js`;
30
+ return (
31
+ `<script\n` +
32
+ ` src="${src}"\n` +
33
+ ` integrity="${integrity}"\n` +
34
+ ` ${cross}="anonymous"\n` +
35
+ ` defer\n` +
36
+ `></script>`
37
+ );
38
+ }
39
+
40
+ /** JSX-flavoured layouts, where attribute spelling differs from HTML. */
41
+ export function isJsxLayout(path) {
42
+ return /\.(jsx|tsx)$/.test(path || "") || /\.vue$/.test(path || "");
43
+ }
44
+ export const LINK_TAG = '<link rel="llms" href="/llms.txt" />';
45
+ export const ELEMENT_TAG = '<human-machine-swapper data-position="bottom-center"></human-machine-swapper>';
46
+ export const CSS_GUARD = `/* Until the script loads the browser lays this unknown element out in flow, about
47
+ 50px tall, and pushes the page down. Added by human-machine-swapper init. */
48
+ human-machine-swapper:not(:defined) {
49
+ display: none;
50
+ }`;
51
+
52
+ /** One entry per thing init tried to do, so the CLI can print an honest report. */
53
+ const done = (what, file, note) => ({ what, file, status: "done", note });
54
+ const skip = (what, file, note) => ({ what, file, status: "skipped", note });
55
+ const manual = (what, note) => ({ what, file: null, status: "manual", note });
56
+
57
+ function read(dir, rel) {
58
+ return readFileSync(join(dir, rel), "utf8");
59
+ }
60
+
61
+ function write(dir, rel, text, { dryRun }) {
62
+ if (dryRun) return;
63
+ const abs = join(dir, rel);
64
+ mkdirSync(dirname(abs), { recursive: true });
65
+ writeFileSync(abs, text);
66
+ }
67
+
68
+ /**
69
+ * Inserts `snippet` on its own line before the first occurrence of `marker`, matching the
70
+ * marker line's indentation so the result does not look bolted on.
71
+ */
72
+ function insertBefore(source, marker, snippet) {
73
+ const at = source.indexOf(marker);
74
+ if (at === -1) return null;
75
+ const lineStart = source.lastIndexOf("\n", at) + 1;
76
+ // The marker is a CLOSING tag, so its own indentation is the parent's. Children sit one
77
+ // level in, and every line of a multi-line snippet gets the same treatment or the
78
+ // continuation lines land wherever they happened to be authored.
79
+ const indent = source.slice(lineStart, at).match(/^\s*/)[0] + " ";
80
+ // Keep the snippet's OWN relative indentation: a multi-line tag authors its attributes
81
+ // one level in and its closing bracket flush, and flattening that puts `></script>`
82
+ // deeper than the tag it closes.
83
+ const body = snippet
84
+ .split("\n")
85
+ .map((l) => (l.trim() ? indent + l : l))
86
+ .join("\n");
87
+ return source.slice(0, lineStart) + body + "\n" + source.slice(lineStart);
88
+ }
89
+
90
+ /** Already installed? Then there is nothing to do and we say so instead of doubling up. */
91
+ const hasElement = (s) => s.includes("<human-machine-swapper");
92
+ const hasScript = (s) => s.includes("human-machine-swapper") && /<script[^>]+unpkg\.com\/human-machine-swapper/.test(s);
93
+ const hasLink = (s) => /rel=["']llms["']/.test(s);
94
+ const hasGuard = (s) => s.includes("human-machine-swapper:not(:defined)");
95
+
96
+ /**
97
+ * Where `</head>` effectively is for this framework. SvelteKit's app.html has no literal
98
+ * head close in some templates, and WordPress puts wp_head() where the tags belong.
99
+ */
100
+ function headMarker(source, frameworkId) {
101
+ if (frameworkId === "sveltekit" && source.includes("%sveltekit.head%")) return "%sveltekit.head%";
102
+ if (frameworkId === "wordpress-theme" && source.includes("wp_head()")) return "<?php wp_head(); ?>";
103
+ if (source.includes("</head>")) return "</head>";
104
+ return null;
105
+ }
106
+
107
+ function bodyMarker(source) {
108
+ if (source.includes("</body>")) return "</body>";
109
+ return null;
110
+ }
111
+
112
+ /**
113
+ * @param {object} opts
114
+ * @param {string} opts.dir project root
115
+ * @param {object} opts.framework from detect()
116
+ * @param {string} opts.llmsTxt generated llms.txt contents
117
+ * @param {string} opts.scriptTag from buildScriptTag(); pinned + hashed by default
118
+ * @param {boolean} opts.dryRun
119
+ * @returns {{actions: Array, wrote: string[]}}
120
+ */
121
+ export function apply({ dir, framework, llmsTxt, scriptTag, dryRun = false }) {
122
+ const SCRIPT = scriptTag || buildScriptTag({ pin: false });
123
+ const actions = [];
124
+ const wrote = [];
125
+
126
+ // 1. llms.txt. Never overwritten: an existing one is the site's own machine reading and
127
+ // is certainly better than anything generated from a README.
128
+ const llmsPath = join(framework.public === "." ? "" : framework.public, "llms.txt").replace(/^\//, "");
129
+ if (existsSync(join(dir, llmsPath))) {
130
+ actions.push(skip("llms.txt", llmsPath, "already exists, left untouched"));
131
+ } else {
132
+ write(dir, llmsPath, llmsTxt, { dryRun });
133
+ wrote.push(llmsPath);
134
+ actions.push(done("llms.txt", llmsPath, "starter file, edit before shipping"));
135
+ }
136
+
137
+ // 2. The markup: link, script, element.
138
+ if (!framework.layout) {
139
+ actions.push(manual("markup", "no layout file found; add the link, script and element by hand"));
140
+ } else {
141
+ let source = read(dir, framework.layout);
142
+ const before = source;
143
+
144
+ const head = headMarker(source, framework.id);
145
+ if (!head) {
146
+ actions.push(manual("head tags", `no <head> found in ${framework.layout}`));
147
+ } else {
148
+ if (hasLink(source)) {
149
+ actions.push(skip("link rel=llms", framework.layout, "already declared"));
150
+ } else {
151
+ source = insertBefore(source, head, LINK_TAG) ?? source;
152
+ actions.push(done("link rel=llms", framework.layout));
153
+ }
154
+ if (hasScript(source)) {
155
+ actions.push(skip("script tag", framework.layout, "already loaded"));
156
+ } else {
157
+ source = insertBefore(source, head, SCRIPT) ?? source;
158
+ actions.push(done("script tag", framework.layout));
159
+ }
160
+ }
161
+
162
+ const body = bodyMarker(source);
163
+ if (hasElement(before)) {
164
+ actions.push(skip("<human-machine-swapper>", framework.layout, "already mounted"));
165
+ } else if (!body) {
166
+ actions.push(manual("element", `no </body> in ${framework.layout}; mount the element by hand`));
167
+ } else {
168
+ source = insertBefore(source, body, ELEMENT_TAG) ?? source;
169
+ actions.push(done("<human-machine-swapper>", framework.layout));
170
+ }
171
+
172
+ if (source !== before) {
173
+ write(dir, framework.layout, source, { dryRun });
174
+ wrote.push(framework.layout);
175
+ }
176
+ }
177
+
178
+ // 3. The guard. This is the one that silently breaks a layout if it is missing, so when
179
+ // there is no stylesheet to put it in we inline a <style> rather than skipping it.
180
+ if (framework.styles && existsSync(join(dir, framework.styles))) {
181
+ const css = read(dir, framework.styles);
182
+ if (hasGuard(css)) {
183
+ actions.push(skip(":defined guard", framework.styles, "already present"));
184
+ } else {
185
+ write(dir, framework.styles, css.trimEnd() + "\n\n" + CSS_GUARD + "\n", { dryRun });
186
+ wrote.push(framework.styles);
187
+ actions.push(done(":defined guard", framework.styles));
188
+ }
189
+ } else if (framework.layout) {
190
+ const source = read(dir, framework.layout);
191
+ if (hasGuard(source)) {
192
+ actions.push(skip(":defined guard", framework.layout, "already present"));
193
+ } else {
194
+ const head = headMarker(source, framework.id);
195
+ if (!head) {
196
+ actions.push(manual(":defined guard", "add human-machine-swapper:not(:defined){display:none} to your CSS"));
197
+ } else {
198
+ const styled = insertBefore(source, head, `<style>\n${CSS_GUARD}\n</style>`);
199
+ if (styled) {
200
+ write(dir, framework.layout, styled, { dryRun });
201
+ if (!wrote.includes(framework.layout)) wrote.push(framework.layout);
202
+ actions.push(done(":defined guard", framework.layout, "inlined, no global stylesheet found"));
203
+ } else {
204
+ actions.push(manual(":defined guard", "could not find a place for it"));
205
+ }
206
+ }
207
+ }
208
+ } else {
209
+ actions.push(manual(":defined guard", "add human-machine-swapper:not(:defined){display:none} to your CSS"));
210
+ }
211
+
212
+ return { actions, wrote };
213
+ }
@@ -0,0 +1,208 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * Works out what kind of project this is, and where its three insertion points live.
6
+ *
7
+ * Detection is by file, never by dependency name: a project can list `next` in
8
+ * devDependencies and not be a Next app, and plenty of real sites have no package.json at
9
+ * all. What is on disk is the truth.
10
+ *
11
+ * Every framework resolves to the same three answers, because the install is always the
12
+ * same three edits:
13
+ * head - where <link rel="llms"> goes
14
+ * body - where <human-machine-swapper> goes
15
+ * styles - where the :not(:defined) guard goes
16
+ */
17
+
18
+ const exists = (dir, ...parts) => existsSync(join(dir, ...parts));
19
+
20
+ /** The first of these that exists, or null. Order is the preference order. */
21
+ function firstFile(dir, candidates) {
22
+ for (const c of candidates) if (exists(dir, c)) return c;
23
+ return null;
24
+ }
25
+
26
+ /** Where a framework serves static files from, which is where llms.txt has to land. */
27
+ function publicDir(dir, candidates) {
28
+ for (const c of candidates) if (exists(dir, c)) return c;
29
+ return candidates[0];
30
+ }
31
+
32
+ /**
33
+ * Finds the stylesheet a framework actually loads globally. Guessing wrong here is the
34
+ * worst outcome the CLI has: the guard lands in a file nothing imports, the element is
35
+ * laid out in flow at ~50px, and the page breaks in a way that looks like our fault.
36
+ * When nothing matches we say so rather than picking one.
37
+ */
38
+ function globalStylesheet(dir, candidates) {
39
+ const found = firstFile(dir, candidates);
40
+ return found;
41
+ }
42
+
43
+ const DETECTORS = [
44
+ {
45
+ id: "next-app",
46
+ name: "Next.js (App Router)",
47
+ test: (dir) =>
48
+ firstFile(dir, ["next.config.js", "next.config.mjs", "next.config.ts"]) &&
49
+ firstFile(dir, ["app/layout.js", "app/layout.jsx", "app/layout.tsx", "src/app/layout.js", "src/app/layout.jsx", "src/app/layout.tsx"]),
50
+ resolve: (dir) => ({
51
+ layout: firstFile(dir, [
52
+ "app/layout.tsx", "app/layout.jsx", "app/layout.js",
53
+ "src/app/layout.tsx", "src/app/layout.jsx", "src/app/layout.js",
54
+ ]),
55
+ styles: globalStylesheet(dir, [
56
+ "app/globals.css", "src/app/globals.css", "styles/globals.css", "app/global.css",
57
+ ]),
58
+ public: publicDir(dir, ["public"]),
59
+ }),
60
+ },
61
+ {
62
+ id: "next-pages",
63
+ name: "Next.js (Pages Router)",
64
+ test: (dir) =>
65
+ firstFile(dir, ["next.config.js", "next.config.mjs", "next.config.ts"]) &&
66
+ firstFile(dir, ["pages/_document.js", "pages/_document.jsx", "pages/_document.tsx", "src/pages/_document.tsx"]),
67
+ resolve: (dir) => ({
68
+ layout: firstFile(dir, [
69
+ "pages/_document.tsx", "pages/_document.jsx", "pages/_document.js",
70
+ "src/pages/_document.tsx",
71
+ ]),
72
+ styles: globalStylesheet(dir, ["styles/globals.css", "src/styles/globals.css"]),
73
+ public: publicDir(dir, ["public"]),
74
+ }),
75
+ },
76
+ {
77
+ id: "astro",
78
+ name: "Astro",
79
+ test: (dir) => firstFile(dir, ["astro.config.mjs", "astro.config.js", "astro.config.ts"]),
80
+ resolve: (dir) => ({
81
+ layout: firstFile(dir, [
82
+ "src/layouts/Layout.astro", "src/layouts/BaseLayout.astro", "src/pages/index.astro",
83
+ ]),
84
+ styles: globalStylesheet(dir, ["src/styles/global.css", "src/styles/globals.css"]),
85
+ public: publicDir(dir, ["public"]),
86
+ }),
87
+ },
88
+ {
89
+ id: "sveltekit",
90
+ name: "SvelteKit",
91
+ test: (dir) => firstFile(dir, ["svelte.config.js"]) && exists(dir, "src/app.html"),
92
+ resolve: (dir) => ({
93
+ layout: "src/app.html",
94
+ styles: globalStylesheet(dir, ["src/app.css", "src/routes/styles.css"]),
95
+ public: publicDir(dir, ["static"]),
96
+ }),
97
+ },
98
+ {
99
+ id: "nuxt",
100
+ name: "Nuxt",
101
+ test: (dir) => firstFile(dir, ["nuxt.config.ts", "nuxt.config.js"]),
102
+ resolve: (dir) => ({
103
+ layout: firstFile(dir, ["app.vue", "layouts/default.vue"]),
104
+ styles: globalStylesheet(dir, ["assets/css/main.css", "assets/main.css"]),
105
+ public: publicDir(dir, ["public"]),
106
+ }),
107
+ },
108
+ {
109
+ id: "wordpress-theme",
110
+ name: "WordPress theme",
111
+ test: (dir) => exists(dir, "functions.php") && exists(dir, "style.css"),
112
+ resolve: (dir) => ({
113
+ layout: firstFile(dir, ["header.php"]),
114
+ styles: "style.css",
115
+ public: publicDir(dir, ["."]),
116
+ }),
117
+ },
118
+ {
119
+ id: "html",
120
+ name: "Static HTML",
121
+ test: (dir) => firstFile(dir, ["index.html", "public/index.html", "src/index.html"]),
122
+ resolve: (dir) => {
123
+ const layout = firstFile(dir, ["index.html", "public/index.html", "src/index.html"]);
124
+ // For a plain page the markup carries its own styles; we inject a <style> block
125
+ // rather than hunting for a stylesheet that may not exist.
126
+ return {
127
+ layout,
128
+ styles: null,
129
+ public: publicDir(dir, [layout && layout.includes("/") ? layout.split("/")[0] : "."]),
130
+ };
131
+ },
132
+ },
133
+ ];
134
+
135
+ /**
136
+ * @returns {{id:string,name:string,layout:string|null,styles:string|null,public:string}|null}
137
+ */
138
+ export function detect(dir = process.cwd()) {
139
+ for (const d of DETECTORS) {
140
+ if (!d.test(dir)) continue;
141
+ return { id: d.id, name: d.name, ...d.resolve(dir) };
142
+ }
143
+ return null;
144
+ }
145
+
146
+ /**
147
+ * Facts about the site, scavenged from whatever this project happens to have. Everything
148
+ * is optional; llms.txt generation degrades rather than failing, because a half-filled
149
+ * llms.txt a human then edits is worth far more than an error telling them to write one.
150
+ */
151
+ export function readSiteFacts(dir = process.cwd()) {
152
+ const facts = { name: null, description: null, url: null, headings: [] };
153
+
154
+ try {
155
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
156
+ if (pkg.name) facts.name = titleise(pkg.name);
157
+ if (pkg.description) facts.description = pkg.description;
158
+ if (typeof pkg.homepage === "string") facts.url = pkg.homepage;
159
+ } catch {}
160
+
161
+ // An index.html usually states the real title and description, which beats a package name.
162
+ for (const p of ["index.html", "public/index.html", "src/index.html"]) {
163
+ if (!exists(dir, p)) continue;
164
+ try {
165
+ const html = readFileSync(join(dir, p), "utf8");
166
+ const title = html.match(/<title[^>]*>([^<]+)<\/title>/i);
167
+ if (title) facts.name = title[1].trim();
168
+ const desc = html.match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i);
169
+ if (desc) facts.description = desc[1].trim();
170
+ } catch {}
171
+ break;
172
+ }
173
+
174
+ // A README's first heading and first real paragraph are usually the best prose on disk.
175
+ for (const p of ["README.md", "readme.md"]) {
176
+ if (!exists(dir, p)) continue;
177
+ try {
178
+ const md = readFileSync(join(dir, p), "utf8");
179
+ const h1 = md.match(/^#\s+(.+)$/m);
180
+ if (h1 && !facts.name) facts.name = h1[1].trim();
181
+ if (!facts.description) {
182
+ const para = md
183
+ .split("\n")
184
+ .find((l) => l.trim() && !l.startsWith("#") && !l.startsWith("!") && !l.startsWith("["));
185
+ if (para) facts.description = para.trim();
186
+ }
187
+ facts.headings = [...md.matchAll(/^##\s+(.+)$/gm)].map((m) => m[1].trim()).slice(0, 8);
188
+ } catch {}
189
+ break;
190
+ }
191
+
192
+ return facts;
193
+ }
194
+
195
+ function titleise(slug) {
196
+ return String(slug)
197
+ .replace(/^@[^/]+\//, "")
198
+ .split(/[-_]/)
199
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
200
+ .join(" ");
201
+ }
202
+
203
+ /** Only used by the report, to say what we looked at. */
204
+ export function listDetectors() {
205
+ return DETECTORS.map((d) => ({ id: d.id, name: d.name }));
206
+ }
207
+
208
+ export { exists, firstFile, readdirSync };
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Builds a starter llms.txt from whatever the project told us about itself.
3
+ *
4
+ * This is deliberately a *starter*. A generated llms.txt that nobody edits is a worse
5
+ * machine reading than a short one somebody wrote, so the file says so in its own text and
6
+ * marks what it could not work out. The goal is to get the site from "no machine reading,
7
+ * so the pill cannot render" to "a real file that a human improves in ten minutes".
8
+ *
9
+ * Offline by design. An agent running `npx human-machine-swapper init` is often in a
10
+ * sandbox with no network; a generator that needs one fails exactly where it matters most.
11
+ */
12
+
13
+ const TODO = "TODO";
14
+
15
+ export function buildLlmsTxt(facts = {}, { framework } = {}) {
16
+ const name = facts.name || "This site";
17
+ const summary = facts.description || `${TODO}: one sentence on what this site is.`;
18
+
19
+ const lines = [];
20
+ lines.push(`# ${name}`);
21
+ lines.push("");
22
+ lines.push(`> ${wrapOne(summary)}`);
23
+ lines.push("");
24
+ lines.push("## About");
25
+ lines.push("");
26
+ if (facts.description) {
27
+ lines.push(wrap(facts.description));
28
+ } else {
29
+ lines.push(`${TODO}: a paragraph or two a language model can quote about ${name}.`);
30
+ }
31
+ lines.push("");
32
+
33
+ if (facts.headings && facts.headings.length) {
34
+ lines.push("## Sections");
35
+ lines.push("");
36
+ for (const h of facts.headings) lines.push(`- ${h}`);
37
+ lines.push("");
38
+ }
39
+
40
+ lines.push("## Key facts");
41
+ lines.push("");
42
+ lines.push(`Q: What is ${name}?`);
43
+ lines.push(`A: ${facts.description ? wrapOne(facts.description) : `${TODO}`}`);
44
+ lines.push("");
45
+ lines.push(`Q: What can I do on ${name}?`);
46
+ lines.push(`A: ${TODO}`);
47
+ lines.push("");
48
+
49
+ if (facts.url) {
50
+ lines.push("## Links");
51
+ lines.push("");
52
+ lines.push(`- Website: ${facts.url}`);
53
+ lines.push("");
54
+ }
55
+
56
+ lines.push("---");
57
+ lines.push("");
58
+ lines.push(
59
+ wrap(
60
+ `This file was generated by human-machine-swapper init${
61
+ framework ? ` from a ${framework} project` : ""
62
+ }. It is a starting point, not a finished machine reading: replace every ${TODO} and cut anything that is not true. The convention is documented at https://llmstxt.org`
63
+ )
64
+ );
65
+ lines.push("");
66
+
67
+ return lines.join("\n");
68
+ }
69
+
70
+ /** How many TODOs a human still owes this file. The CLI reports it rather than pretending. */
71
+ export function countTodos(text) {
72
+ return (text.match(new RegExp(TODO, "g")) || []).length;
73
+ }
74
+
75
+ function wrap(text, width = 92) {
76
+ const words = String(text).split(/\s+/).filter(Boolean);
77
+ const out = [];
78
+ let line = "";
79
+ for (const w of words) {
80
+ if (line && (line + " " + w).length > width) {
81
+ out.push(line);
82
+ line = w;
83
+ } else {
84
+ line = line ? line + " " + w : w;
85
+ }
86
+ }
87
+ if (line) out.push(line);
88
+ return out.join("\n");
89
+ }
90
+
91
+ /** Single line, for places where a newline would break the format (the > blurb, a Q/A). */
92
+ function wrapOne(text) {
93
+ return String(text).replace(/\s+/g, " ").trim();
94
+ }