pipeup 0.5.4 → 0.7.0-beta.1

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.
Files changed (42) hide show
  1. package/README.md +3 -3
  2. package/cli/link.d.mts +17 -0
  3. package/cli/link.mjs +207 -0
  4. package/cli/pipeup.mjs +226 -0
  5. package/cli/skills.mjs +154 -0
  6. package/dist/pipeup.agents.js +2 -0
  7. package/dist/pipeup.core.js +1 -1
  8. package/dist/pipeup.esm.js +1 -1
  9. package/dist/pipeup.min.js +1 -1
  10. package/dist/types/core.d.ts +3 -1
  11. package/dist/types/crypto/portable.d.ts +15 -0
  12. package/dist/types/document.d.ts +20 -4
  13. package/dist/types/export/agent.d.ts +35 -0
  14. package/dist/types/export/format.d.ts +5 -1
  15. package/dist/types/index.d.ts +5 -2
  16. package/dist/types/model/animals.d.ts +7 -1
  17. package/dist/types/model/fold.d.ts +2 -0
  18. package/dist/types/model/ops.d.ts +5 -0
  19. package/dist/types/model/types.d.ts +35 -2
  20. package/dist/types/ui/addons.d.ts +22 -1
  21. package/dist/types/ui/animals.d.ts +1 -1
  22. package/dist/types/ui/app.d.ts +6 -0
  23. package/dist/types/ui/composer.d.ts +6 -1
  24. package/dist/types/ui/context.d.ts +16 -2
  25. package/dist/types/ui/draft-view.d.ts +5 -1
  26. package/dist/types/ui/highlights.d.ts +2 -0
  27. package/dist/types/ui/icons.d.ts +2 -0
  28. package/dist/types/ui/identity.d.ts +27 -0
  29. package/dist/types/ui/launcher.d.ts +0 -5
  30. package/dist/types/ui/legible.d.ts +27 -0
  31. package/dist/types/ui/mentions.d.ts +46 -0
  32. package/dist/types/ui/mount.d.ts +12 -0
  33. package/dist/types/ui/reactions.d.ts +31 -0
  34. package/dist/types/ui/styles.d.ts +1 -1
  35. package/dist/types/ui/theme.d.ts +31 -2
  36. package/dist/types/ui/thread-view.d.ts +14 -2
  37. package/package.json +7 -2
  38. package/skills/pipeup-add/SKILL.md +53 -0
  39. package/skills/pipeup-apply/SKILL.md +77 -0
  40. package/skills/pipeup-integrate/SKILL.md +240 -0
  41. package/skills/pipeup-send-to-git-setup/SKILL.md +107 -0
  42. package/skills/pipeup-summarise/SKILL.md +39 -0
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Comments and feedback right on any HTML page: documents, slide decks and whole sites.
4
4
 
5
- > **Alpha (0.5.4).** The API and the stored comment format may change before 1.0; pin an exact version.
5
+ > **Alpha (0.7.0-beta.1).** The API and the stored comment format may change before 1.0; pin an exact version.
6
6
  > Website and Try pages: https://pipeup-ai.github.io/pipeup/ · Source: https://github.com/pipeup-ai/pipeup
7
7
 
8
8
  Pipeup: signed comment operations, threads with one-level replies, anchors that survive page changes,
@@ -21,14 +21,14 @@ As a classic script (also works from `file://`), it mounts itself on pages that
21
21
  ```html
22
22
  <html data-pipeup-doc="<id>:<key>">
23
23
  ...
24
- <script src="https://cdn.jsdelivr.net/npm/pipeup@0.5.4/dist/pipeup.min.js" integrity="sha384-…" crossorigin="anonymous" defer></script>
24
+ <script src="https://cdn.jsdelivr.net/npm/pipeup@0.7.0-beta.1/dist/pipeup.min.js" integrity="sha384-…" crossorigin="anonymous" defer></script>
25
25
  ```
26
26
 
27
27
  Each release's notes on GitHub give the `integrity` value
28
28
  (`node scripts/sri.mjs` prints it for a local build). Make a document key with
29
29
  `await Pipeup.newDocumentAttribute()` and keep it once people have commented.
30
30
 
31
- **Add-ons.** Optional extra scripts add features (`@pipeup/share`, `voice`, `live`, `assist`), and anyone can write their own
31
+ **Add-ons.** Optional extra scripts add features (`@pipeup/share`, `voice`, `live`, `assist`, `agents`, `emoji`, `github`, `updates`, `inbox`), and anyone can write their own
32
32
  (a company's, for example) without the Pipeup project's permission: see the
33
33
  [guide](https://pipeup-ai.github.io/pipeup/addons-guide.html). Add-ons run with the page's full power, so keep them
34
34
  intentional (best effort): pin each script with its `integrity` hash, name the add-ons that may run with
package/cli/link.d.mts ADDED
@@ -0,0 +1,17 @@
1
+ export type Support = "old" | "builtin" | "addon" | "unknown";
2
+ export function served(
3
+ html: string,
4
+ o: { base: string; agent: string; key: string; file: string; addon?: boolean },
5
+ ): string;
6
+ export function pageSupport(
7
+ html: string,
8
+ dir: string,
9
+ ): Promise<{ support: Support; version: string; loaded: boolean }>;
10
+ export function watch(o: {
11
+ page: string;
12
+ agent: string;
13
+ key: string;
14
+ port?: number;
15
+ log?: (line: string) => void;
16
+ }): Promise<{ url: string; sidecar: string; support: Support; addon: boolean; close(): Promise<void> }>;
17
+ export function comments(o: { page: string; all?: boolean; json?: boolean }): Promise<string>;
package/cli/link.mjs ADDED
@@ -0,0 +1,207 @@
1
+ // pipeup watch / pipeup comments: the live link between a page shown from this computer and an agent on it.
2
+ // Everything stays on this computer: the server listens on 127.0.0.1 only, and answers only the page it serves.
3
+ import { randomBytes } from "node:crypto";
4
+ import { createServer } from "node:http";
5
+ import { readFile, rename, writeFile } from "node:fs/promises";
6
+ import { basename, dirname, extname, join, resolve, sep } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ /** The agents add-on that ships with this install; `watch` serves it to the pages that need it. */
10
+ const ADDON_FILE = fileURLToPath(new URL("../dist/pipeup.agents.js", import.meta.url));
11
+
12
+ const TYPES = {
13
+ ".html": "text/html; charset=utf-8",
14
+ ".js": "text/javascript; charset=utf-8",
15
+ ".mjs": "text/javascript; charset=utf-8",
16
+ ".css": "text/css; charset=utf-8",
17
+ ".json": "application/json; charset=utf-8",
18
+ ".svg": "image/svg+xml",
19
+ ".png": "image/png",
20
+ ".jpg": "image/jpeg",
21
+ ".jpeg": "image/jpeg",
22
+ ".gif": "image/gif",
23
+ ".webp": "image/webp",
24
+ ".ico": "image/x-icon",
25
+ ".woff2": "font/woff2",
26
+ ".txt": "text/plain; charset=utf-8",
27
+ ".md": "text/markdown; charset=utf-8",
28
+ };
29
+ const MAX_BODY = 8 * 1024 * 1024;
30
+ const attr = (v) => v.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
31
+
32
+ /** The page for the browser: its own html tag, plus the link's address and the agent it lists, and the agents add-on when it needs one. */
33
+ export function served(html, { base, agent, key, file, addon = false }) {
34
+ const out = html.replace(/<html\b([^>]*)>/i, (_, rest) => {
35
+ let tag = rest;
36
+ const listed = /\sdata-pipeup-agents\s*=\s*"([^"]*)"/i.exec(tag);
37
+ if (listed) tag = tag.replace(listed[0], ` data-pipeup-agents="${attr(`${listed[1]} ${key}`.trim())}"`);
38
+ else tag += ` data-pipeup-agents="${key}"`;
39
+ tag += ` data-pipeup-link="${base}" data-pipeup-link-agent="${attr(agent)}" data-pipeup-link-file="${attr(file)}"`;
40
+ return `<html${tag}>`;
41
+ });
42
+ if (!addon) return out;
43
+ // After the page's own scripts: an add-on registers in any order. The page's file on disk is not touched.
44
+ const tag = `<script src="${base}/agents.js"></script>`;
45
+ return /<\/body>/i.test(out) ? out.replace(/<\/body>/i, () => `${tag}</body>`) : out + tag;
46
+ }
47
+
48
+ /** Which of Pipeup's agent support a version has: before 0.6 none, 0.6.x built in, 0.7 and later in the agents add-on. */
49
+ function byVersion(v) {
50
+ const m = /^(\d+)\.(\d+)\.\d+/.exec(v);
51
+ if (!m) return null;
52
+ const [major, minor] = [Number(m[1]), Number(m[2])];
53
+ return major > 0 || minor >= 7 ? "addon" : minor === 6 ? "builtin" : "old";
54
+ }
55
+
56
+ /** The same, read from a Pipeup script's own text: it carries the names of what it supports. */
57
+ function bySignature(text) {
58
+ if (!text.includes("pipeup-root")) return null;
59
+ if (text.includes("pipeupReplies")) return "builtin";
60
+ return text.includes("listnote") ? "addon" : "old";
61
+ }
62
+
63
+ /**
64
+ * What the page's Pipeup can do for an agent, found without running the page: "old" (before 0.6, can't take an
65
+ * agent), "builtin" (0.6.x, agent support is part of it), "addon" (0.7 or later: the agents add-on serves an agent),
66
+ * or "unknown" (a floating address, or no Pipeup script to read). `loaded` says the page already includes the add-on.
67
+ */
68
+ export async function pageSupport(html, dir) {
69
+ const result = { support: "unknown", version: "", loaded: false };
70
+ for (const [, src] of html.matchAll(/<script\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi)) {
71
+ if (/agents(\.min)?\.js|@pipeup\/agents/i.test(src)) result.loaded = true;
72
+ const named = /pipeup(?:@|-)(\d+\.\d+\.\d+(?:-[\w.]+)?)\b/.exec(src);
73
+ if (named && result.support === "unknown") {
74
+ result.support = byVersion(named[1]) ?? "unknown";
75
+ result.version = named[1];
76
+ continue;
77
+ }
78
+ if (result.support !== "unknown" || /^(https?:)?\/\//i.test(src)) continue;
79
+ try {
80
+ const path = /^file:/i.test(src)
81
+ ? fileURLToPath(src.split(/[?#]/)[0])
82
+ : resolve(dir, decodeURIComponent(src.split(/[?#]/)[0]));
83
+ result.support = bySignature(await readFile(path, "utf8")) ?? "unknown";
84
+ } catch {
85
+ // a script that can't be read says nothing about Pipeup
86
+ }
87
+ }
88
+ // A Pipeup carried inside the page itself.
89
+ if (result.support === "unknown") result.support = bySignature(html) ?? "unknown";
90
+ return result;
91
+ }
92
+
93
+ /** Starts the link for a page file. Resolves to { url, close }. */
94
+ export async function watch({ page, agent, key, port = 0, log = console.log }) {
95
+ const file = resolve(page);
96
+ const dir = dirname(file);
97
+ const name = basename(file);
98
+ const base = `/__pipeup/${randomBytes(18).toString("base64url")}`;
99
+ const sidecar = file.replace(/\.[^./\\]*$/, "") + ".comments.json";
100
+ let origin = "";
101
+ let host = "";
102
+
103
+ // Which Pipeup the page uses decides whether the agents add-on is added to what it shows.
104
+ const found = await pageSupport(await readFile(file, "utf8"), dir);
105
+ if (found.support === "old")
106
+ throw new Error(
107
+ `this page uses ${found.version ? `Pipeup ${found.version}` : "a Pipeup older than 0.6"}, which can't take an agent. Use Pipeup 0.6 or later (pin an exact version) and run this again`,
108
+ );
109
+ const addon = found.support !== "builtin" && !found.loaded;
110
+ let addonCode = "";
111
+ if (addon) {
112
+ try {
113
+ addonCode = await readFile(ADDON_FILE, "utf8");
114
+ } catch {
115
+ throw new Error("the agents add-on isn't in this install of pipeup (dist/pipeup.agents.js is missing)");
116
+ }
117
+ }
118
+
119
+ const send = (res, status, body = "", type = "text/plain; charset=utf-8") => {
120
+ res.writeHead(status, {
121
+ "content-type": type,
122
+ "cache-control": "no-store",
123
+ "x-content-type-options": "nosniff",
124
+ });
125
+ res.end(body);
126
+ };
127
+
128
+ const server = createServer(async (req, res) => {
129
+ try {
130
+ // Only this computer's own address: a name that points here from another site is refused.
131
+ if (req.headers.host !== host) return send(res, 403, "wrong host");
132
+ const url = new URL(req.url, origin);
133
+ if (url.pathname.startsWith("/__pipeup/")) {
134
+ if (addon && url.pathname === `${base}/agents.js` && req.method === "GET")
135
+ return send(res, 200, addonCode, TYPES[".js"]);
136
+ if (url.pathname !== `${base}/comments` || req.method !== "POST") return send(res, 404, "not found");
137
+ if (req.headers.origin !== origin) return send(res, 403, "wrong origin");
138
+ const chunks = [];
139
+ let size = 0;
140
+ for await (const c of req) {
141
+ size += c.length;
142
+ if (size > MAX_BODY) return send(res, 413, "too big");
143
+ chunks.push(c);
144
+ }
145
+ let body;
146
+ try {
147
+ body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
148
+ } catch {
149
+ return send(res, 400, "not json");
150
+ }
151
+ if (body?.v !== 1 || !body.open || !body.all) return send(res, 400, "not a comments snapshot");
152
+ const tmp = `${sidecar}.${process.pid}.tmp`;
153
+ await writeFile(tmp, JSON.stringify(body));
154
+ await rename(tmp, sidecar);
155
+ log(`Shared ${body.open.threads.length} open comment${body.open.threads.length === 1 ? "" : "s"}.`);
156
+ return send(res, 204);
157
+ }
158
+ if (req.method !== "GET" && req.method !== "HEAD") return send(res, 405, "method not allowed");
159
+ let path = decodeURIComponent(url.pathname);
160
+ if (path === "/") path = `/${name}`;
161
+ const target = resolve(join(dir, path));
162
+ if (target !== dir && !target.startsWith(dir + sep)) return send(res, 404, "not found");
163
+ let bytes;
164
+ try {
165
+ bytes = await readFile(target);
166
+ } catch {
167
+ return send(res, 404, "not found");
168
+ }
169
+ if (target === file)
170
+ bytes = Buffer.from(served(bytes.toString("utf8"), { base, agent, key, file, addon }));
171
+ send(
172
+ res,
173
+ 200,
174
+ req.method === "HEAD" ? "" : bytes,
175
+ TYPES[extname(target).toLowerCase()] ?? "application/octet-stream",
176
+ );
177
+ } catch {
178
+ send(res, 500, "error");
179
+ }
180
+ });
181
+ await new Promise((ok) => server.listen(port, "127.0.0.1", ok));
182
+ const { port: bound } = server.address();
183
+ origin = `http://127.0.0.1:${bound}`;
184
+ host = `127.0.0.1:${bound}`;
185
+ return {
186
+ url: `${origin}/${encodeURIComponent(name)}`,
187
+ sidecar,
188
+ support: found.support,
189
+ addon,
190
+ close: () => new Promise((ok) => server.close(ok)),
191
+ };
192
+ }
193
+
194
+ /** What `pipeup comments` prints: the shared comments, as Markdown or as data. */
195
+ export async function comments({ page, all = false, json = false }) {
196
+ const sidecar = resolve(page).replace(/\.[^./\\]*$/, "") + ".comments.json";
197
+ let shared;
198
+ try {
199
+ shared = JSON.parse(await readFile(sidecar, "utf8"));
200
+ } catch {
201
+ throw new Error(
202
+ `nothing is shared for ${page} yet. Start the link (pipeup watch ${page}), open the page it prints, and switch on Share with my agent`,
203
+ );
204
+ }
205
+ const view = all ? shared.all : shared.open;
206
+ return json ? JSON.stringify({ at: shared.at, ...view }, null, 2) : view.markdown;
207
+ }
package/cli/pipeup.mjs ADDED
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env node
2
+ // pipeup agent / pipeup reply: how an AI agent answers comments on a Pipeup page.
3
+ // The agent's key stays in ~/.pipeup/agent.json (owner-only) and never goes in the page or its folder.
4
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
5
+ import { homedir } from "node:os";
6
+ import { basename, dirname, join, resolve } from "node:path";
7
+ import { comments, watch } from "./link.mjs";
8
+ import * as skills from "./skills.mjs";
9
+
10
+ // The page library, loaded only by the commands that sign or read a page: `skills` and `comments` need nothing built.
11
+ const core = () => import("../dist/pipeup.core.js");
12
+
13
+ const KEYFILE = process.env.PIPEUP_AGENT_FILE || join(homedir(), ".pipeup", "agent.json");
14
+ const AI = "AI assistant";
15
+
16
+ const USAGE = `pipeup skills install|remove|list [--project] [--agent claude|codex|all] [--yes] [--replace] [--dry-run]
17
+ Puts Pipeup's agent skills where Claude Code (.claude/skills) and Codex (.agents/skills) look: for you, in every
18
+ project, or with --project for this folder only. It says what it will write, asks first (--yes to go ahead without
19
+ asking), and never replaces a skill you changed unless you say --replace.
20
+
21
+ pipeup agent [--name <name>]
22
+ Makes this computer's agent key (once) and prints the public key to list on the page:
23
+ <html data-pipeup-agents="<key>">
24
+
25
+ pipeup watch <page.html> [--agent <name>] [--port <number>]
26
+ Shows the page from this computer, with a live link to the agent: the page offers "Share with my agent"
27
+ (off until the person switches it on), and the agent is listed for this page while the link runs. It adds the
28
+ agents add-on to the page it shows (the file is not changed), unless the page's Pipeup already has agent support
29
+ (0.6.x), and refuses a page whose Pipeup is older than 0.6.
30
+
31
+ pipeup comments <page.html> [--all] [--json]
32
+ Prints the comments the page has shared (open threads, or all with --all), as Markdown or, with --json, as data.
33
+
34
+ pipeup reply --page <file.html> --thread <id> "<words>" [--addressed "<short note>"]
35
+ Writes a signed reply (and, with --addressed, an addressed note) to <file>.replies.js next to the page.
36
+ It never resolves, reopens, edits or deletes anything: the author closes threads.`;
37
+
38
+ function fail(message) {
39
+ console.error(`pipeup: ${message}`);
40
+ process.exit(1);
41
+ }
42
+
43
+ function flags(argv) {
44
+ const out = { _: [] };
45
+ for (let i = 0; i < argv.length; i++) {
46
+ const a = argv[i];
47
+ if (a.startsWith("--")) {
48
+ const next = argv[i + 1];
49
+ if (next === undefined || next.startsWith("--")) out[a.slice(2)] = true;
50
+ else ((out[a.slice(2)] = next), i++);
51
+ } else out._.push(a);
52
+ }
53
+ return out;
54
+ }
55
+
56
+ const b64u = (bytes) => Buffer.from(bytes).toString("base64url");
57
+
58
+ async function loadAgent(name) {
59
+ let saved = null;
60
+ try {
61
+ saved = JSON.parse(await readFile(KEYFILE, "utf8"));
62
+ } catch {
63
+ // none yet
64
+ }
65
+ if (!saved) {
66
+ const pair = await crypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]);
67
+ const raw = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey));
68
+ saved = {
69
+ name: typeof name === "string" ? name : "Agent",
70
+ publicKey: b64u(raw),
71
+ privateKey: await crypto.subtle.exportKey("jwk", pair.privateKey),
72
+ };
73
+ await mkdir(dirname(KEYFILE), { recursive: true, mode: 0o700 });
74
+ await writeFile(KEYFILE, JSON.stringify(saved), { mode: 0o600 });
75
+ await chmod(KEYFILE, 0o600);
76
+ } else if (typeof name === "string" && name !== saved.name) {
77
+ saved.name = name;
78
+ await writeFile(KEYFILE, JSON.stringify(saved), { mode: 0o600 });
79
+ }
80
+ const privateKey = await crypto.subtle.importKey("jwk", saved.privateKey, { name: "Ed25519" }, false, [
81
+ "sign",
82
+ ]);
83
+ return { identity: { publicKey: saved.publicKey, privateKey }, name: saved.name };
84
+ }
85
+
86
+ /** A name that shows it is an agent: "AI assistant · Claude". */
87
+ const signedName = (name) => (name && name !== AI ? `${AI} · ${name}`.slice(0, 80) : AI);
88
+
89
+ async function pageDoc(file) {
90
+ const html = await readFile(file, "utf8");
91
+ const m = /<html\b[^>]*\sdata-pipeup-doc\s*=\s*["']([^"']+)["']/i.exec(html);
92
+ if (!m) fail(`${file} has no data-pipeup-doc attribute; add Pipeup to the page first`);
93
+ const { id } = await (await core()).parseDocumentAttribute(m[1]);
94
+ const listed = /<html\b[^>]*\sdata-pipeup-agents\s*=\s*["']([^"']*)["']/i.exec(html)?.[1] ?? "";
95
+ return { id, listed: listed.split(/[\s,]+/).filter(Boolean) };
96
+ }
97
+
98
+ const headerOf = (file) =>
99
+ `// Replies to the comments on ${basename(file)}. Written by \`pipeup reply\`; safe to keep with the page.\n`;
100
+
101
+ async function readReplies(path, doc) {
102
+ try {
103
+ const text = await readFile(path, "utf8");
104
+ const m = /push\((\{.*\})\);\s*$/m.exec(text);
105
+ const file = m && JSON.parse(m[1]);
106
+ return file && file.doc === doc && Array.isArray(file.ops) ? file.ops : [];
107
+ } catch {
108
+ return [];
109
+ }
110
+ }
111
+
112
+ async function reply(args) {
113
+ const page = args.page;
114
+ const thread = args.thread;
115
+ const words = args._[0]?.trim();
116
+ if (typeof page !== "string" || typeof thread !== "string" || !words)
117
+ fail(`missing words, --page or --thread\n\n${USAGE}`);
118
+ const note = typeof args.addressed === "string" ? args.addressed.trim() : "";
119
+ const { id: doc, listed } = await pageDoc(page);
120
+ const { identity, name } = await loadAgent();
121
+ const linked = await readFile(page.replace(/\.[^./\\]*$/, "") + ".comments.json", "utf8").then(
122
+ () => true,
123
+ () => false,
124
+ );
125
+ if (!listed.includes(identity.publicKey) && !linked)
126
+ console.warn(
127
+ `pipeup: this page doesn't list your key yet, so it will ignore these replies.\n Add: <html data-pipeup-agents="${identity.publicKey}">`,
128
+ );
129
+ const out = resolve(page.replace(/\.[^./\\]*$/, "") + ".replies.js");
130
+ const ops = await readReplies(out, doc);
131
+ // Seconds since 1970: later than any counter a browser has kept, so these follow the thread they answer.
132
+ let clock = Math.max(Math.floor(Date.now() / 1000), ...ops.map((o) => o.body.clock + 1));
133
+ let at = Date.now();
134
+ const make = async (fields) => {
135
+ const body = {
136
+ v: 1,
137
+ ...fields,
138
+ id: "",
139
+ doc,
140
+ author: identity.publicKey,
141
+ name: signedName(name),
142
+ clock: clock++,
143
+ at: at++,
144
+ };
145
+ const { computeOpId, signOp } = await core();
146
+ body.id = await computeOpId(body);
147
+ return signOp(body, identity);
148
+ };
149
+ const added = [await make({ kind: "reply", thread, target: thread, text: words })];
150
+ if (note) added.push(await make({ kind: "addressed", thread, text: note }));
151
+ const file = { format: "pipeup-replies", v: 1, doc, ops: [...ops, ...added] };
152
+ await writeFile(out, `${headerOf(page)}(globalThis.pipeupReplies ||= []).push(${JSON.stringify(file)});\n`);
153
+ console.log(`Wrote ${added.length} ${added.length === 1 ? "entry" : "entries"} to ${out}`);
154
+ }
155
+
156
+ const [command, ...rest] = process.argv.slice(2);
157
+ const args = flags(rest);
158
+ if (command === "agent") {
159
+ const { identity, name } = await loadAgent(args.name);
160
+ console.log(
161
+ `${name} (${KEYFILE})\n\nList this key on the page:\n <html data-pipeup-agents="${identity.publicKey}">`,
162
+ );
163
+ } else if (command === "reply") await reply(args);
164
+ else if (command === "skills") {
165
+ const sub = args._[0];
166
+ const opts = {
167
+ agent: typeof args.agent === "string" ? args.agent : "all",
168
+ project: args.project === true,
169
+ yes: args.yes === true,
170
+ replace: args.replace === true,
171
+ dryRun: args["dry-run"] === true,
172
+ };
173
+ try {
174
+ if (sub === "install" || sub === "update") await skills.install(opts);
175
+ else if (sub === "remove") await skills.remove(opts);
176
+ else if (sub === "list") await skills.list(opts);
177
+ else fail(`skills: say install, remove or list\n\n${USAGE}`);
178
+ } catch (e) {
179
+ fail(e.message);
180
+ }
181
+ } else if (command === "comments") {
182
+ const page = args._[0];
183
+ if (!page) fail(`missing the page\n\n${USAGE}`);
184
+ try {
185
+ console.log(await comments({ page, all: args.all === true, json: args.json === true }));
186
+ } catch (e) {
187
+ fail(e.message);
188
+ }
189
+ } else if (command === "watch") {
190
+ const page = args._[0];
191
+ if (!page) fail(`missing the page\n\n${USAGE}`);
192
+ await pageDoc(page);
193
+ const { identity, name } = await loadAgent(typeof args.agent === "string" ? args.agent : undefined);
194
+ let link;
195
+ try {
196
+ link = await watch({
197
+ page,
198
+ agent: name,
199
+ key: identity.publicKey,
200
+ port: typeof args.port === "string" ? Number(args.port) : 0,
201
+ });
202
+ } catch (e) {
203
+ fail(e.message);
204
+ }
205
+ console.log(`Open this page: ${link.url}\n`);
206
+ console.log(
207
+ `While this runs, ${name} is listed for the page and the page offers "Share with my agent" (off until you switch it on).`,
208
+ );
209
+ if (link.addon)
210
+ console.log(
211
+ link.support === "unknown"
212
+ ? "It couldn't tell which Pipeup the page uses, so it adds the agents add-on to the page it shows (the add-on stays off on a Pipeup that already has agent support). The page's file is not changed."
213
+ : "It adds the agents add-on to the page it shows. The page's file is not changed.",
214
+ );
215
+ else if (link.support === "builtin")
216
+ console.log("This page's Pipeup (0.6) has agent support built in, so nothing is added to it.");
217
+ console.log(
218
+ `Read: npx pipeup comments ${page}\nReply: npx pipeup reply --page ${page} --thread <id> "words" --addressed "note"\nStop: Ctrl-C (comments stay in ${basename(link.sidecar)})`,
219
+ );
220
+ const stop = () => link.close().then(() => process.exit(0));
221
+ process.on("SIGINT", stop);
222
+ process.on("SIGTERM", stop);
223
+ } else {
224
+ console.log(USAGE);
225
+ process.exit(command ? 1 : 0);
226
+ }
package/cli/skills.mjs ADDED
@@ -0,0 +1,154 @@
1
+ // pipeup skills install | remove | list: puts Pipeup's agent skills where Claude Code and Codex look for them.
2
+ // It writes only SKILL.md files and a small marker beside each (so it can tell its own from yours), asks before
3
+ // replacing one you changed, and makes no network request: the skills ship inside this package.
4
+ import { createHash } from "node:crypto";
5
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
6
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { createInterface } from "node:readline/promises";
10
+
11
+ const MARKER = ".pipeup-installed";
12
+ const sha = (text) => createHash("sha256").update(text).digest("hex");
13
+
14
+ /** Where the skills come from: inside this package, or (running from the repository) the one source. */
15
+ function sourceDir() {
16
+ for (const rel of ["../skills", "../../../../apps/agent-skills"]) {
17
+ const dir = new URL(`${rel}/`, import.meta.url).pathname;
18
+ if (existsSync(dir)) return dir;
19
+ }
20
+ throw new Error("this copy of pipeup doesn't carry the skills");
21
+ }
22
+
23
+ function loadSkills() {
24
+ const dir = sourceDir();
25
+ return readdirSync(dir)
26
+ .filter((n) => n.startsWith("pipeup-") && existsSync(join(dir, n, "SKILL.md")))
27
+ .sort()
28
+ .map((name) => ({ name, text: readFileSync(join(dir, name, "SKILL.md"), "utf8") }));
29
+ }
30
+
31
+ const version = () => JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
32
+
33
+ /** The places to write: each agent's skills folder, for the person (every project) or for this project only. */
34
+ export function targets({
35
+ agent = "all",
36
+ project = false,
37
+ home = process.env.PIPEUP_SKILLS_HOME || homedir(),
38
+ cwd = process.cwd(),
39
+ }) {
40
+ const base = project ? cwd : home;
41
+ const out = [];
42
+ if (agent === "all" || agent === "claude")
43
+ out.push({ agent: "Claude Code", dir: join(base, ".claude", "skills") });
44
+ if (agent === "all" || agent === "codex")
45
+ out.push({ agent: "Codex", dir: join(base, ".agents", "skills") });
46
+ if (!out.length) throw new Error(`unknown agent "${agent}": use claude, codex or all`);
47
+ return out;
48
+ }
49
+
50
+ /** What is there now for one skill: nothing, ours and unchanged, or changed by you (or not ours). */
51
+ async function stateOf(dir, name) {
52
+ const file = join(dir, name, "SKILL.md");
53
+ if (!existsSync(file)) return "new";
54
+ const text = await readFile(file, "utf8");
55
+ try {
56
+ const marker = JSON.parse(await readFile(join(dir, name, MARKER), "utf8"));
57
+ return marker.sha256 === sha(text) ? "ours" : "changed";
58
+ } catch {
59
+ return "changed";
60
+ }
61
+ }
62
+
63
+ async function ask(question) {
64
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
65
+ try {
66
+ return /^y(es)?$/i.test((await rl.question(`${question} [y/N] `)).trim());
67
+ } finally {
68
+ rl.close();
69
+ }
70
+ }
71
+
72
+ export async function install(o) {
73
+ const skills = loadSkills();
74
+ const where = targets(o);
75
+ const plan = [];
76
+ for (const t of where)
77
+ for (const s of skills)
78
+ plan.push({ ...t, ...s, state: await stateOf(t.dir, s.name), path: join(t.dir, s.name, "SKILL.md") });
79
+ const say = o.log ?? console.log;
80
+ say(`Pipeup skills ${version()} (${skills.length}) will be written to:`);
81
+ for (const t of where) say(` ${t.agent}: ${t.dir}`);
82
+ const mine = (st) => plan.filter((p) => p.state === st).length;
83
+ say(
84
+ ` ${mine("new")} new, ${mine("ours")} already from Pipeup (updated if different), ${mine("changed")} you have changed or didn't come from here`,
85
+ );
86
+ if (mine("changed")) say(" Skills you have changed are left alone unless you choose to replace them.");
87
+ if (o.dryRun) return { written: 0, skipped: 0, dry: true };
88
+ if (!o.yes) {
89
+ if (!process.stdin.isTTY) {
90
+ say(
91
+ "Nothing written. Run it again with --yes to go ahead (--replace also replaces skills you changed).",
92
+ );
93
+ return { written: 0, skipped: plan.length };
94
+ }
95
+ if (!(await ask("Go ahead?"))) return { written: 0, skipped: plan.length };
96
+ }
97
+ let written = 0;
98
+ let skipped = 0;
99
+ for (const p of plan) {
100
+ if (p.state === "changed") {
101
+ const replace =
102
+ o.replace ||
103
+ (process.stdin.isTTY && !o.yes ? await ask(`${p.path} has been changed. Replace it?`) : false);
104
+ if (!replace) {
105
+ say(` kept yours: ${p.path}`);
106
+ skipped++;
107
+ continue;
108
+ }
109
+ }
110
+ await mkdir(join(p.dir, p.name), { recursive: true });
111
+ await writeFile(p.path, p.text);
112
+ await writeFile(
113
+ join(p.dir, p.name, MARKER),
114
+ JSON.stringify({ version: version(), sha256: sha(p.text) }) + "\n",
115
+ );
116
+ written++;
117
+ }
118
+ say(
119
+ `Wrote ${written} skill${written === 1 ? "" : "s"}${skipped ? `, kept ${skipped} of yours` : ""}. Restart the agent if it was open.`,
120
+ );
121
+ return { written, skipped };
122
+ }
123
+
124
+ export async function remove(o) {
125
+ const say = o.log ?? console.log;
126
+ const skills = loadSkills();
127
+ let removed = 0;
128
+ for (const t of targets(o))
129
+ for (const s of skills) {
130
+ const state = await stateOf(t.dir, s.name);
131
+ if (state === "new") continue;
132
+ if (state === "changed" && !o.replace) {
133
+ say(` kept (not from Pipeup, or changed by you): ${join(t.dir, s.name)}`);
134
+ continue;
135
+ }
136
+ if (o.dryRun) say(` would remove ${join(t.dir, s.name)}`);
137
+ else await rm(join(t.dir, s.name), { recursive: true, force: true });
138
+ removed++;
139
+ }
140
+ say(`${o.dryRun ? "Would remove" : "Removed"} ${removed} skill${removed === 1 ? "" : "s"}.`);
141
+ return { removed };
142
+ }
143
+
144
+ export async function list(o) {
145
+ const say = o.log ?? console.log;
146
+ const skills = loadSkills();
147
+ for (const t of targets(o)) {
148
+ say(`${t.agent}: ${t.dir}`);
149
+ for (const s of skills)
150
+ say(
151
+ ` ${s.name}: ${{ new: "not installed", ours: "installed", changed: "there, but not from Pipeup or changed" }[await stateOf(t.dir, s.name)]}`,
152
+ );
153
+ }
154
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";(()=>{function b(e){let t=globalThis;(t.pipeupAddons||=[]).push(e),typeof addEventListener=="function"&&addEventListener("load",()=>{t.Pipeup||console.error(`pipeup: the "${e.id}" add-on needs Pipeup on the page too; load pipeup.min.js.`)},{once:!0})}var re=2n**256n-2n**32n-977n;var L=/^\/__pipeup\/[A-Za-z0-9_-]{16,}$/;function P(e){let t=document.documentElement,n=t.getAttribute("data-pipeup-link")??"",s=L.test(n),o=!1,c=0,y=()=>{};async function r(){let d=!1;try{d=(await fetch(`${n}/comments`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(await e.snapshot())})).ok}catch{}if(!d)throw new Error("pipeup: the link to your agent isn't reachable; your comments are safe here")}let u=()=>{window.clearTimeout(c),y(),y=()=>{},o=!1};return{enabled:s,agent:t.getAttribute("data-pipeup-link-agent")??"",file:t.getAttribute("data-pipeup-link-file")??"",on:()=>o,set:async d=>{if(s){if(!d){u(),e.refresh();return}await r(),o=!0,y=e.changes(()=>{window.clearTimeout(c),c=window.setTimeout(()=>{r().catch(h=>{u(),e.refresh(),globalThis.reportError?.(h)})},300)}),e.refresh()}},destroy:u}}function T(e){let t=n=>n.replace(/\s+/g," ").trim();return[`Act on the review comments on this page, which uses Pipeup: ${t(e.title)} (${t(e.url)}).`,"",e.file?`1. Read the comments: \`npx pipeup comments ${e.file}\` (add --all for resolved ones). If it says nothing is shared, ask me to switch on Share with my agent.`:"1. Read the comments. Open the page in a browser and run `await Pipeup.comments()`, then read `.markdown`. If you can't run the page, ask me to paste its Copy as Markdown.","2. Work through the open threads in order. For each, make the change I approved, or say why you didn't. Ask me before changing anything I haven't approved, and answer a question with an answer.",e.replies?`3. Answer in each thread: \`npx pipeup reply --page ${e.file||"<the page's file>"} --thread <Thread id> "what you changed and where" --addressed "short note"\`. That writes ${t(e.replies)}.`+(e.listed?"":" The page doesn't list your key yet: run `npx pipeup agent --name <your name>` and tell me the attribute to add."):"3. Tell me what you would reply in each thread, in words I can paste.","4. Only mark a thread addressed. Never resolve, reopen, edit or delete: I close threads. Keep your key out of the page and its folder, and never answer as me.","","Guide: https://pipeup-ai.github.io/pipeup/skills/pipeup-apply/SKILL.md"].join(`
2
+ `)}var C=/^[A-Za-z0-9_-]{43}$/,_="pipeupReplies";var D=/^AI assistant\s*·?\s*/;function x(e=document.documentElement){let t=e.getAttribute("data-pipeup-agents")??"";return[...new Set(t.split(/[\s,]+/).filter(n=>C.test(n)))]}function k(e=location,t=document.documentElement){let n=t.getAttribute("data-pipeup-replies");try{let s=e.href.split(/[?#]/)[0],o=new URL(n??(/\/$/.test(s)?`${s}index.replies.js`:s.replace(/\.[^./]*$/,"")+".replies.js"),e.href),c=o.protocol==="file:"?e.protocol==="file:":o.origin===e.origin;return c||console.warn("pipeup: the replies file must be on this site or this computer; ignoring it"),c?o.href:null}catch{return null}}function E(e){let t=x(),n=0,s="",o=null;async function c(){let l=globalThis[_];if(!Array.isArray(l))return 0;let p=l.splice(0),g=[];for(let f of p){let m=f;if(!(m?.format!=="pipeup-replies"||m.doc!==e.document.id||!Array.isArray(m.ops)))for(let i of m.ops){let a=i?.body;a&&t.includes(a.author)&&(a.kind==="reply"||a.kind==="addressed"||a.kind==="react"||a.kind==="unreact")?(g.push(i),(a.name==="AI assistant"||a.name.startsWith("AI assistant "))&&(s=a.name.replace(D,""))):n++}}return g.length?e.merge(g):0}function y(){let l=k();return l?(o?.remove(),new Promise((p,g)=>{let f=document.createElement("script");o=f,f.src=`${l}?${Date.now()}`;let m=a=>{window.clearTimeout(i),f.remove(),o===f&&(o=null),a?g(new Error(`pipeup: ${a}`)):p()},i=window.setTimeout(()=>m("the replies file didn't answer"),4e3);f.addEventListener("load",()=>m()),f.addEventListener("error",()=>m("there is no replies file yet")),e.overlay().append(f)})):Promise.reject(new Error("pipeup: the replies file can't be read from here"))}async function r(){if(!t.length)return 0;let l=await c();return await y(),l+await c()}let u=!1,d=()=>{u||document.hidden||(u=!0,r().catch(()=>{}).finally(()=>u=!1))},h=0;return t.length?(d(),h=window.setInterval(d,4e3),document.addEventListener("visibilitychange",d)):c(),{enabled:t.length>0,ignored:()=>n,who:()=>s,check:r,tally:l=>{let p=l.filter(i=>!i.resolved),g=i=>i.root.replies.some(a=>t.includes(a.author)),f=new Set(l.map(i=>i.id));return{stray:new Set(e.document.ops().filter(i=>t.includes(i.body.author)&&!f.has(i.body.thread)).map(i=>i.body.thread)).size,open:p.length,answered:p.filter(g).length,addressed:p.filter(i=>i.addressed).length}},destroy:()=>{window.clearInterval(h),document.removeEventListener("visibilitychange",d),o?.remove()}}}var R=()=>globalThis.Pipeup??{},H=["M10 8h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-8a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2z","M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"],N=["M21 12L3.5 4.5 6.2 12l-2.7 7.5zM6.2 12H13"],z="Reads a replies file next to this page. When the page is shown through pipeup watch, and only after you switch it on, it sends this page's comments to the program running on this computer. Nothing leaves this computer.",S=e=>e instanceof Error?e.message.replace(/^pipeup: /,""):"Something went wrong";async function K(e,t){try{await navigator.clipboard.writeText(e);return}catch{}let n=document.createElement("textarea");n.value=e,n.setAttribute("readonly",""),n.style.cssText="position:fixed;opacity:0;pointer-events:none",t.append(n),n.select();let s=document.execCommand("copy");if(n.remove(),!s)throw new Error("pipeup: your browser blocked copying")}function U(){return{id:"agents",api:1,version:"0.6.0",needs:["menu","status","notify","listnote"],network:{when:"page-configured",to:["page"],says:z},setup(e){let t=E(e),n=P({snapshot:async()=>{let r=R();if(!r.comments)throw new Error("pipeup: this page has no Pipeup to read comments from");let[u,d]=await Promise.all([r.comments(),r.comments({all:!0})]);return{v:1,at:new Date().toISOString(),title:document.title,open:u,all:d}},changes:r=>e.document.onChange(r),refresh:()=>o()}),s=()=>t.who()||n.agent,o=()=>{if(e.setStatus(t.enabled||n.enabled?{text:n.on()?`Sharing comments with ${s()||"your agent"}`:`${s()||"An AI agent"} can answer comments here`}:null),!t.enabled)return e.setListNote(null);let r=t.tally(e.document.threads()),u=s()||"An agent",d=r.answered?`${u} has answered ${r.answered} of ${r.open} open comments${r.addressed?`; ${r.addressed} ${r.addressed===1?"is":"are"} marked addressed`:""}`:"No replies from an agent yet",h=t.ignored(),l=(n.on()?`Sharing with ${n.agent||"your agent"} \xB7 `:"")+d+(h?` \xB7 ${h} from other writers ignored`:"")+(r.stray?` \xB7 replies for ${r.stray} comment${r.stray===1?"":"s"} this page doesn't have`:"");e.setListNote({text:l,action:{label:"Check for replies",run:async()=>{try{let p=await t.check();e.notify(p?`${p} new ${p===1?"reply":"replies"}`:"No new replies")}catch(p){e.notify(S(p))}finally{o()}}}})},c=[e.addMenuItem({id:"prompt",icon:H,label:()=>"Copy a prompt for my agent",hint:()=>"Tells an AI agent where the comments are and how to answer them",select:async()=>{try{await K(T({title:document.title,url:location.href,replies:k(),file:n.enabled?n.file:"",listed:x().length>0}),e.overlay()),e.notify("Copied a prompt for your agent")}catch(r){e.notify(S(r))}}})];n.enabled&&c.push(e.addMenuItem({id:"link",icon:N,label:()=>"Share with my agent",hint:()=>"Sends this page's comments to the program running on this computer, and nothing else",checked:()=>n.on(),select:async()=>{try{await n.set(!n.on())}catch(r){e.notify(S(r))}o()}}));let y=e.document.onChange(o);return o(),()=>{y(),t.destroy(),n.destroy()}}}}b(U());})();