cpyany 0.2.5
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 +56 -0
- package/index.mjs +1110 -0
- package/package.json +51 -0
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# cpyany
|
|
2
|
+
|
|
3
|
+
One-command installer for the PingHumans MCP server.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx cpyany setup
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Auto-detects every supported AI client installed on your machine (Claude Code,
|
|
10
|
+
Claude Desktop, Cursor), opens a browser to authorize, and writes the resulting
|
|
11
|
+
bearer token into each client's MCP config from a single OAuth flow. Restart
|
|
12
|
+
the affected clients to load `ping_humans` and `get_ping`.
|
|
13
|
+
|
|
14
|
+
## Install paths
|
|
15
|
+
|
|
16
|
+
| Client | MCP config we patch | Rule + skill we install | Auto-detected via |
|
|
17
|
+
|---|---|---|---|
|
|
18
|
+
| Claude Code | runs `claude mcp add --scope user --transport http …` (writes `~/.claude.json`) | `~/.claude/rules/pinghumans.md` + `~/.claude/skills/pinghumans-qa/SKILL.md` | `claude` on `PATH` |
|
|
19
|
+
| Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` (mac) / `%APPDATA%\Claude\claude_desktop_config.json` (win) — bridged via `mcp-remote` | _(none — Claude Desktop has no rules/skills mechanism)_ | the per-platform Claude config dir exists |
|
|
20
|
+
| Cursor | `~/.cursor/mcp.json` | `~/.cursor/rules/pinghumans.mdc` (`alwaysApply: true`) + `~/.cursor/skills/pinghumans-qa/SKILL.md` | `~/.cursor` exists |
|
|
21
|
+
|
|
22
|
+
Guidance is two-tier (the same layout Context7 uses): a short always-loaded
|
|
23
|
+
rule telling the agent _when_ to reach for PingHumans, and a `pinghumans-qa`
|
|
24
|
+
skill with the full QA workflow that the agent loads only when it actually
|
|
25
|
+
files or collects a human test. Installs that predate 0.1.0 (which appended a
|
|
26
|
+
marker block to `~/.claude/CLAUDE.md`) are migrated automatically on any CLI
|
|
27
|
+
invocation. `cpyany remove` cleans everything up. The published package
|
|
28
|
+
also keeps `copyanything` and `pinghumans` as legacy binary aliases during
|
|
29
|
+
the rename.
|
|
30
|
+
|
|
31
|
+
## Restrict to one client
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx cpyany setup --client claude-code
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`--client` accepts `claude-code`, `claude-desktop`, or `cursor`. Aliases:
|
|
38
|
+
`--claude` (= `--claude-desktop`), `--code` (= `--claude-code`).
|
|
39
|
+
|
|
40
|
+
## Skip the browser
|
|
41
|
+
|
|
42
|
+
If you'd rather paste the snippet manually, sign in at
|
|
43
|
+
[pinghumans.com/dashboard](https://pinghumans.com/dashboard) and copy the
|
|
44
|
+
config block from the **MCP integration** section.
|
|
45
|
+
|
|
46
|
+
## Remove
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npx cpyany remove # removes from every detected client
|
|
50
|
+
npx cpyany remove --client claude-desktop # just one
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
UNLICENSED — the CLI is published for ease of installation, but the source
|
|
56
|
+
of the PingHumans server is not open-licensed.
|
package/index.mjs
ADDED
|
@@ -0,0 +1,1110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// pinghumans — install the PingHumans MCP server in your AI client.
|
|
3
|
+
// One command: shows a one-time code, you approve it in the browser
|
|
4
|
+
// (RFC 8628 device flow), your client configs get patched, you're done.
|
|
5
|
+
|
|
6
|
+
import { writeFile, readFile, mkdir, stat } from "node:fs/promises";
|
|
7
|
+
import { homedir, platform, hostname, userInfo } from "node:os";
|
|
8
|
+
import { join, dirname } from "node:path";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { execFile } from "node:child_process";
|
|
11
|
+
import { promisify } from "node:util";
|
|
12
|
+
import open from "open";
|
|
13
|
+
|
|
14
|
+
const VERSION = "0.2.5";
|
|
15
|
+
const execFileP = promisify(execFile);
|
|
16
|
+
const APP_URL = process.env.PINGHUMANS_APP_URL ?? "https://pinghumans.com";
|
|
17
|
+
// Hoisted with the other top-of-module consts — the entry try-block runs
|
|
18
|
+
// setup()/refreshStaleRules() via top-level await BEFORE lower const
|
|
19
|
+
// declarations initialize (the 0.0.4 TDZ lesson). validateStoredToken
|
|
20
|
+
// reaches NUDGE_INTERVAL_MS transitively from that path, so it lives here.
|
|
21
|
+
const CLI_CLIENT_ID = "pinghumans-cli";
|
|
22
|
+
const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
23
|
+
const SUPPORTED_CLIENTS = ["claude-desktop", "claude-code", "cursor"];
|
|
24
|
+
// Throttle the passive token-health check to once/day so it neither spams
|
|
25
|
+
// the nudge nor adds a whoami round-trip to every incidental invocation.
|
|
26
|
+
const NUDGE_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
27
|
+
|
|
28
|
+
// ─── Telemetry ────────────────────────────────────────────────────────────
|
|
29
|
+
//
|
|
30
|
+
// Fire-and-forget POST to /api/cli/telemetry so /admin/usage can see real
|
|
31
|
+
// installs vs npm's bot-heavy download counter. No PII: a stable hash of
|
|
32
|
+
// (hostname + username) is the only identifier sent pre-auth. Honors the
|
|
33
|
+
// DO_NOT_TRACK env var (https://consoledonottrack.com) and a --no-telemetry
|
|
34
|
+
// flag. Never blocks setup, never throws.
|
|
35
|
+
const TELEMETRY_OFF =
|
|
36
|
+
process.env.DO_NOT_TRACK === "1" ||
|
|
37
|
+
process.argv.includes("--no-telemetry");
|
|
38
|
+
const ANON_ID = (() => {
|
|
39
|
+
try {
|
|
40
|
+
return createHash("sha256")
|
|
41
|
+
.update(`${hostname()}|${userInfo().username}`)
|
|
42
|
+
.digest("hex")
|
|
43
|
+
.slice(0, 16);
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
})();
|
|
48
|
+
// Capture the bearer once setup completes so subsequent events (remove)
|
|
49
|
+
// can attach to the user server-side. Module-scoped: lives only for this
|
|
50
|
+
// process.
|
|
51
|
+
let _bearerForTelemetry = null;
|
|
52
|
+
|
|
53
|
+
function track(eventType, extra = {}) {
|
|
54
|
+
if (TELEMETRY_OFF) return Promise.resolve();
|
|
55
|
+
const body = {
|
|
56
|
+
event_type: eventType,
|
|
57
|
+
cli_version: VERSION,
|
|
58
|
+
client_os: platform(),
|
|
59
|
+
node_version: process.version,
|
|
60
|
+
anon_id: ANON_ID,
|
|
61
|
+
...extra,
|
|
62
|
+
};
|
|
63
|
+
const headers = { "content-type": "application/json" };
|
|
64
|
+
if (_bearerForTelemetry) {
|
|
65
|
+
headers.authorization = `Bearer ${_bearerForTelemetry}`;
|
|
66
|
+
}
|
|
67
|
+
// Best-effort: short timeout, swallow every error so telemetry can never
|
|
68
|
+
// fail a user's `cpyany setup`.
|
|
69
|
+
const ctrl = new AbortController();
|
|
70
|
+
const timer = setTimeout(() => ctrl.abort(), 3000);
|
|
71
|
+
return fetch(`${APP_URL}/api/cli/telemetry`, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers,
|
|
74
|
+
body: JSON.stringify(body),
|
|
75
|
+
signal: ctrl.signal,
|
|
76
|
+
})
|
|
77
|
+
.catch(() => {})
|
|
78
|
+
.finally(() => clearTimeout(timer));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Hoisted to the top of the module so they're initialized before the
|
|
82
|
+
// entry-point `try` block below calls setup() → patchRules(). Function
|
|
83
|
+
// declarations hoist; `const` does not, and 0.0.4 hit a TDZ ReferenceError
|
|
84
|
+
// because these lived alongside their consumer functions further down.
|
|
85
|
+
const RULES_MARKER_START =
|
|
86
|
+
"<!-- pinghumans-mcp:start (managed by `npx pinghumans` — do not edit) -->";
|
|
87
|
+
const RULES_MARKER_END = "<!-- pinghumans-mcp:end -->";
|
|
88
|
+
|
|
89
|
+
// Two-tier guidance (Context7-style):
|
|
90
|
+
// RULE_BODY — short, always-loaded: WHEN to reach for PingHumans.
|
|
91
|
+
// SKILL_BODY — full workflow, loaded on demand when the agent does QA.
|
|
92
|
+
const RULE_BODY = `Use the PingHumans MCP to put a real human in the verification loop whenever you build or change something you can't verify yourself — rendered UI, animation smoothness, real-device behavior, real third-party side-effects (OAuth round-trips, receipt emails, payments), or judgment calls about taste, clarity, and confusion. Even when the code looks right: you can't see the page, a human can. Don't caveat with "I'm guessing" — file a test or a poll instead.
|
|
93
|
+
|
|
94
|
+
Do not use for: anything verifiable with code, tests, or a local browser; objective questions answerable from docs or search.
|
|
95
|
+
|
|
96
|
+
## Steps
|
|
97
|
+
|
|
98
|
+
1. Built/changed a UI? Deploy or tunnel it to a PUBLICLY reachable URL (localhost won't work — a remote human opens it), make sure \`<script src="https://pinghumans.com/qa-toolbar.js"></script>\` is in the preview build's HTML, then call \`request_human_test\` with structured \`steps\` — machine-verifiable \`check\` rules plus inline \`options\` (e.g. \`["Smooth","Janky"]\`) for qualitative steps
|
|
99
|
+
2. Quick taste/preference read with no build to test? Call \`ping_humans\` (blocks ~50s)
|
|
100
|
+
3. File tests early and keep working while the human tests — collect results with \`get_test_results\` at checkpoints, \`wait_for_results\` when blocked, or \`npx cpyany wait <ping_id>\` as a background task (foreground in one-shot runs)
|
|
101
|
+
4. When results arrive, treat the human's verdict/comments as acceptance criteria. Use browser/Chrome checks to implement and confirm those criteria, not to overrule the worker.
|
|
102
|
+
5. For the full QA workflow (verifiable steps, evidence, waiting modes, acting on results), use the pinghumans-qa skill
|
|
103
|
+
`;
|
|
104
|
+
|
|
105
|
+
const SKILL_BODY = `---
|
|
106
|
+
name: pinghumans-qa
|
|
107
|
+
description: This skill should be used when the user has built or changed a UI and wants it verified by a real human, asks for human QA / human testing / human feedback on a build, or wants to check the results of a previously filed human test. Also activates for subjective gut-checks (taste, copy, design preference) via PingHumans.
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
When you've built or changed something you can't verify yourself, use PingHumans to have a real human test it and return structured results — a verdict, pinned component comments with CSS selectors, a per-step proof-of-work report, and screenshots you can open.
|
|
111
|
+
|
|
112
|
+
## When to Use This Skill
|
|
113
|
+
|
|
114
|
+
- You shipped a UI change and can't see the rendered result ("does this look right on a real phone?", "is this animation smooth or janky?")
|
|
115
|
+
- The check needs real-world reach a sandbox doesn't have ("sign in with Google — did it actually log you in?", "did the test purchase email a receipt?")
|
|
116
|
+
- Sense-making ("complete the checkout — anything confusing or broken?")
|
|
117
|
+
- A previously filed test needs its results collected
|
|
118
|
+
- A quick subjective read with no build to test ("which logo looks more professional?") — use \`ping_humans\` for these
|
|
119
|
+
|
|
120
|
+
## How to File a Test
|
|
121
|
+
|
|
122
|
+
### Step 1: Make the build reachable and instrumented
|
|
123
|
+
|
|
124
|
+
The \`url\` must be PUBLICLY reachable — a remote human opens it, so localhost won't work: tunnel it first (ngrok, cloudflared) or deploy a preview. The toolbar script is REQUIRED for pinned comments and step auto-verification — one line, once per project, in the preview build's HTML (root layout / index template), preview/dev only:
|
|
125
|
+
|
|
126
|
+
\`\`\`html
|
|
127
|
+
<script src="https://pinghumans.com/qa-toolbar.js"></script>
|
|
128
|
+
\`\`\`
|
|
129
|
+
|
|
130
|
+
No per-task id needed — the tester's claim link carries the task token. \`request_human_test\`'s result reports whether the script was detected (\`toolbar_detected\`) — if it warns, fix the build and redeploy before testers arrive.
|
|
131
|
+
|
|
132
|
+
### Step 2: File with verifiable steps
|
|
133
|
+
|
|
134
|
+
Call \`request_human_test\` with a \`url\`, structured \`steps\`, and optional \`verdict_options\` / \`require_evidence\`. You wrote the code, so you know what "done" looks like — attach a \`check\` rule to every step you can:
|
|
135
|
+
|
|
136
|
+
- \`{type:"url", pattern:"/pricing"}\` — tester actually navigated there
|
|
137
|
+
- \`{type:"click", selector:".checkout button"}\` — tester actually clicked it
|
|
138
|
+
- \`{type:"fill", selector:"input[type=email]"}\` — tester actually typed
|
|
139
|
+
- \`{type:"comment"}\` — tester pinned at least one comment
|
|
140
|
+
|
|
141
|
+
The toolbar observes the page and auto-verifies them; results report each step as ✓ auto (machine-verified), ✓ manual, or ✗ not done — your proof the tester did the work. Leave \`check\` null only for pure-judgment steps.
|
|
142
|
+
|
|
143
|
+
When a step asks a question rather than performs an action, add \`options\` (2–4 short labels, e.g. \`{text:"Scroll the homepage — smooth or janky?", options:["Smooth","Janky"]}\`): the tester answers with one tap right on the step and the pick comes back in \`steps_result[].answer\` — far richer than a bare checkmark. A step with a \`selector\` in its check also gets highlighted on the page for the tester, so prefer real selectors.
|
|
144
|
+
|
|
145
|
+
### Step 3: Wait the right way
|
|
146
|
+
|
|
147
|
+
The call does NOT block (QA takes minutes) — it returns \`status='pending'\` immediately. In order of preference:
|
|
148
|
+
|
|
149
|
+
1. **File early, keep working.** The human tests in parallel — their time is free to you.
|
|
150
|
+
2. **Interactive session with background shell tasks:** launch \`npx cpyany wait <ping_id>\` in the background — it exits the moment results land, waking you with the results.
|
|
151
|
+
3. **One-shot run (subagent / final answer due this run):** do NOT end your run while results are pending — nothing can wake you afterwards, and a backgrounded waiter dies with you. Run \`npx cpyany wait <ping_id>\` in the FOREGROUND, or call \`wait_for_results(ping_id)\` in a loop until complete.
|
|
152
|
+
4. **Checkpoint checks:** \`get_test_results(ping_id)\` at natural pauses.
|
|
153
|
+
5. **Genuinely blocked:** \`wait_for_results(ping_id)\` blocks server-side (~45s per call, free) and returns early on news. Never hand-roll sleep loops.
|
|
154
|
+
|
|
155
|
+
### Step 4: Act on the results
|
|
156
|
+
|
|
157
|
+
Each result has a verdict, notes, pinned comments, per-step truth, and a screenshot URL — open and look at it. Comments carry CSS selectors that point at the exact component to change. Treat the human's verdict/comments as acceptance criteria, not suggestions. Browser, Chrome, screenshots, source checks, and getComputedStyle are implementation aids: use them to satisfy and confirm the worker's requested changes, never to decide the worker was wrong.
|
|
158
|
+
|
|
159
|
+
**Clone-compare tasks** (filed with both \`url\` = the original and \`draft_url\` = your clone): before you touch the clone, call \`check_og_source(ping_id)\`. It reads the ORIGINAL's authored HTML source for exactly the elements a human flagged and diffs each against your clone. The worker's alignment feedback is the acceptance criteria: modify HTML/CSS so every requested move/match/delete is met with reasonable visual accuracy. Allow small manual error in hand-placed move/align markers, but preserve the worker's intended direction and target. Use the returned authored classes to implement the feedback; only fall back to measuring/live inspection for pairs marked \`needs_live_inspection\`, and only to satisfy the human request, not to replace it with your own visual judgment.
|
|
160
|
+
|
|
161
|
+
## Guidelines
|
|
162
|
+
|
|
163
|
+
- **Pricing**: ~$0.05 per response received; filing is free, results are charged
|
|
164
|
+
- **Quick polls**: \`ping_humans\` for taste questions blocks ~50s; poll \`get_ping(ping_id)\` for late arrivals
|
|
165
|
+
- **Pending isn't dead**: "a tester has claimed the task and is testing right now" means results are imminent — keep waiting
|
|
166
|
+
`;
|
|
167
|
+
|
|
168
|
+
// ─── Entry ────────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
const args = process.argv.slice(2);
|
|
171
|
+
const cmd = args[0];
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
if (cmd === "setup") {
|
|
175
|
+
await setup(parseClient(args.slice(1)));
|
|
176
|
+
} else if (cmd === "remove" || cmd === "uninstall") {
|
|
177
|
+
await remove(parseClient(args.slice(1)));
|
|
178
|
+
} else if (cmd === "wait") {
|
|
179
|
+
await waitForResultsCli(args.slice(1));
|
|
180
|
+
} else if (cmd === "whoami") {
|
|
181
|
+
await whoamiCli();
|
|
182
|
+
} else if (cmd === "rules") {
|
|
183
|
+
await refreshStaleRules({ verbose: true });
|
|
184
|
+
} else if (cmd === "version" || cmd === "--version" || cmd === "-v") {
|
|
185
|
+
console.log(VERSION);
|
|
186
|
+
track("version");
|
|
187
|
+
await refreshStaleRules().catch(() => {});
|
|
188
|
+
} else {
|
|
189
|
+
// Even a bare/unknown invocation heals stale rules — `npx cpyany`
|
|
190
|
+
// always runs the newest package, so this is the "auto-update on
|
|
191
|
+
// package update" path.
|
|
192
|
+
await refreshStaleRules().catch(() => {});
|
|
193
|
+
printHelp();
|
|
194
|
+
process.exit(cmd ? 1 : 0);
|
|
195
|
+
}
|
|
196
|
+
} catch (err) {
|
|
197
|
+
if (cmd === "setup") {
|
|
198
|
+
await track("setup_failed", {
|
|
199
|
+
failure_reason: String(err?.message ?? err).slice(0, 500),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
console.error(`\n✗ ${err.message}\n`);
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ─── Commands ─────────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
async function setup(client) {
|
|
209
|
+
track("setup_start", { client_label: client ?? null });
|
|
210
|
+
if (!TELEMETRY_OFF) {
|
|
211
|
+
console.log(
|
|
212
|
+
"(anonymous usage stats — opt out with DO_NOT_TRACK=1 or --no-telemetry)"
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
// Resolve targets: explicit --client X wins; otherwise detect what's
|
|
216
|
+
// installed and (Context7-style) let the user multi-select, defaulting to
|
|
217
|
+
// everything. Non-interactive shells skip the prompt and take all.
|
|
218
|
+
let targets;
|
|
219
|
+
if (client) {
|
|
220
|
+
targets = [client];
|
|
221
|
+
} else {
|
|
222
|
+
const detected = await detectClients();
|
|
223
|
+
if (detected.length === 0) {
|
|
224
|
+
throw new Error(
|
|
225
|
+
"Couldn't find Claude Code, Claude Desktop, or Cursor on this machine. " +
|
|
226
|
+
"Install one of them first, or pass --client to specify manually."
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
if (detected.length > 1 && process.stdin.isTTY && process.stdout.isTTY) {
|
|
230
|
+
const { checkbox } = await import("@inquirer/prompts");
|
|
231
|
+
targets = await checkbox({
|
|
232
|
+
message: "Which agents do you want to set up?",
|
|
233
|
+
choices: detected.map((c) => ({
|
|
234
|
+
name: prettyClient(c),
|
|
235
|
+
value: c,
|
|
236
|
+
checked: true,
|
|
237
|
+
})),
|
|
238
|
+
validate: (sel) => sel.length > 0 || "Pick at least one agent.",
|
|
239
|
+
});
|
|
240
|
+
} else {
|
|
241
|
+
targets = detected;
|
|
242
|
+
console.log(`Detected ${targets.map(prettyClient).join(", ")}.`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// One device-code sign-in regardless of how many configs we'll patch
|
|
247
|
+
// (RFC 8628 — works over SSH too, no localhost listener needed).
|
|
248
|
+
const token = await performDeviceLogin();
|
|
249
|
+
if (!token) process.exit(1);
|
|
250
|
+
_bearerForTelemetry = token;
|
|
251
|
+
const pc0 = await import("picocolors").then((m) => m.default);
|
|
252
|
+
console.log(`${pc0.green("✔")} Authenticated`);
|
|
253
|
+
await saveLocalToken(token); // lets `cpyany wait`/`whoami` authenticate later
|
|
254
|
+
|
|
255
|
+
// Install everything, then print a Context7-style per-client summary.
|
|
256
|
+
const pc = await import("picocolors").then((m) => m.default);
|
|
257
|
+
const summary = [];
|
|
258
|
+
let desktopDetected = false;
|
|
259
|
+
for (const t of targets) {
|
|
260
|
+
// Claude Desktop can't use a remote MCP server from its config file, so we
|
|
261
|
+
// do NOT write the old `npx mcp-remote` bridge (the brittle path: plaintext
|
|
262
|
+
// token in args + an OAuth race when the token dies). Desktop gets the
|
|
263
|
+
// one-click extension or the native OAuth connector instead — point there.
|
|
264
|
+
if (t === "claude-desktop") {
|
|
265
|
+
desktopDetected = true;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
await patchConfig(t, token);
|
|
269
|
+
const entries = [
|
|
270
|
+
{
|
|
271
|
+
label: "MCP server configured with Bearer token",
|
|
272
|
+
path:
|
|
273
|
+
t === "claude-code"
|
|
274
|
+
? join(homedir(), ".claude.json")
|
|
275
|
+
: configPath(t),
|
|
276
|
+
},
|
|
277
|
+
...(await patchRules(t)),
|
|
278
|
+
];
|
|
279
|
+
summary.push({ client: t, entries });
|
|
280
|
+
track("setup_complete", { client_label: t });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
console.log(`\n${pc.green("✔")} PingHumans setup complete\n`);
|
|
284
|
+
for (const { client: c, entries } of summary) {
|
|
285
|
+
console.log(` ${pc.bold(prettyClient(c))}`);
|
|
286
|
+
for (const e of entries) {
|
|
287
|
+
console.log(` ${pc.green("+")} ${e.label}`);
|
|
288
|
+
console.log(` ${pc.dim(e.path)}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (desktopDetected) {
|
|
293
|
+
console.log(` ${pc.bold("Claude Desktop")}`);
|
|
294
|
+
console.log(
|
|
295
|
+
` ${pc.dim("Desktop uses a one-click extension or a connector, not a config file.")}`
|
|
296
|
+
);
|
|
297
|
+
console.log(` Set it up here: ${pc.cyan(`${APP_URL}/connect`)}`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const restartList = summary.map((s) => prettyClient(s.client)).join(" / ");
|
|
301
|
+
if (restartList) {
|
|
302
|
+
console.log(`\nRestart ${restartList} to load the MCP server.`);
|
|
303
|
+
}
|
|
304
|
+
console.log(`Try it: ask your agent to "use ping_humans to ask if pizza is cooler than tacos."`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function remove(client) {
|
|
308
|
+
// If --client given, remove only from that one. Else remove from every
|
|
309
|
+
// detected client (matches the setup command's auto-multi behavior).
|
|
310
|
+
let targets;
|
|
311
|
+
if (client) {
|
|
312
|
+
targets = [client];
|
|
313
|
+
} else {
|
|
314
|
+
targets = await detectClients();
|
|
315
|
+
if (targets.length === 0) {
|
|
316
|
+
console.log("Nothing to remove — no supported AI clients detected.");
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
for (const t of targets) {
|
|
321
|
+
await unpatchConfig(t);
|
|
322
|
+
console.log(`✓ Removed pinghumans from ${prettyClient(t)} config.`);
|
|
323
|
+
await unpatchRules(t);
|
|
324
|
+
track("remove", { client_label: t });
|
|
325
|
+
}
|
|
326
|
+
// Full removal (no --client) also signs this machine out.
|
|
327
|
+
if (!client) {
|
|
328
|
+
const { unlink } = await import("node:fs/promises");
|
|
329
|
+
await unlink(credsPath()).catch(() => {});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ─── Client detection ─────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
async function pathExists(p) {
|
|
336
|
+
try {
|
|
337
|
+
await stat(p);
|
|
338
|
+
return true;
|
|
339
|
+
} catch {
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function isOnPath(cmd) {
|
|
345
|
+
const which = platform() === "win32" ? "where" : "which";
|
|
346
|
+
try {
|
|
347
|
+
await execFileP(which, [cmd]);
|
|
348
|
+
return true;
|
|
349
|
+
} catch {
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Quick, low-fidelity check for which AI clients are installed locally.
|
|
356
|
+
* Returns a subset of SUPPORTED_CLIENTS in install-order preference.
|
|
357
|
+
*
|
|
358
|
+
* Detection rules (deliberately loose — better to surface a client we'll
|
|
359
|
+
* ask the user about than to skip a real install):
|
|
360
|
+
* - claude-code: `claude` binary on PATH
|
|
361
|
+
* - claude-desktop: the per-platform config directory exists (created on
|
|
362
|
+
* first launch; absent if the app was never opened)
|
|
363
|
+
* - cursor: ~/.cursor exists
|
|
364
|
+
*/
|
|
365
|
+
async function detectClients() {
|
|
366
|
+
const home = homedir();
|
|
367
|
+
const isMac = platform() === "darwin";
|
|
368
|
+
const isWin = platform() === "win32";
|
|
369
|
+
|
|
370
|
+
const [hasClaudeCode, hasClaudeDesktop, hasCursor] = await Promise.all([
|
|
371
|
+
isOnPath("claude"),
|
|
372
|
+
pathExists(
|
|
373
|
+
isMac
|
|
374
|
+
? join(home, "Library", "Application Support", "Claude")
|
|
375
|
+
: isWin
|
|
376
|
+
? join(process.env.APPDATA || join(home, "AppData", "Roaming"), "Claude")
|
|
377
|
+
: join(home, ".config", "Claude")
|
|
378
|
+
),
|
|
379
|
+
pathExists(join(home, ".cursor")),
|
|
380
|
+
]);
|
|
381
|
+
|
|
382
|
+
const out = [];
|
|
383
|
+
if (hasClaudeCode) out.push("claude-code");
|
|
384
|
+
if (hasClaudeDesktop) out.push("claude-desktop");
|
|
385
|
+
if (hasCursor) out.push("cursor");
|
|
386
|
+
return out;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ─── Device-code sign-in (RFC 8628) ──────────────────────────────────────
|
|
390
|
+
//
|
|
391
|
+
// The CLI mints a short code, the user approves at /oauth/device in any
|
|
392
|
+
// signed-in browser (works over SSH — no localhost listener), and we poll
|
|
393
|
+
// until the approval delivers a bearer. Flow and presentation mirror the
|
|
394
|
+
// Context7 CLI.
|
|
395
|
+
|
|
396
|
+
async function startDeviceAuthorization() {
|
|
397
|
+
const params = new URLSearchParams({ client_id: CLI_CLIENT_ID });
|
|
398
|
+
try {
|
|
399
|
+
// Shown on the approval page so the user can confirm which machine is
|
|
400
|
+
// asking (RFC 8628 §5.4 phishing resistance). Best-effort.
|
|
401
|
+
const h = hostname();
|
|
402
|
+
if (h) params.set("hostname", h);
|
|
403
|
+
} catch {}
|
|
404
|
+
const res = await fetch(`${APP_URL}/api/oauth/device/code`, {
|
|
405
|
+
method: "POST",
|
|
406
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
407
|
+
body: params.toString(),
|
|
408
|
+
});
|
|
409
|
+
if (!res.ok) {
|
|
410
|
+
const err = await res.json().catch(() => ({}));
|
|
411
|
+
throw new Error(err.error_description || err.error || "Failed to start sign-in.");
|
|
412
|
+
}
|
|
413
|
+
return res.json();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function pollDeviceToken(deviceCode) {
|
|
417
|
+
let res;
|
|
418
|
+
try {
|
|
419
|
+
res = await fetch(`${APP_URL}/api/oauth/device/token`, {
|
|
420
|
+
method: "POST",
|
|
421
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
422
|
+
body: new URLSearchParams({
|
|
423
|
+
grant_type: DEVICE_CODE_GRANT,
|
|
424
|
+
device_code: deviceCode,
|
|
425
|
+
client_id: CLI_CLIENT_ID,
|
|
426
|
+
}).toString(),
|
|
427
|
+
});
|
|
428
|
+
} catch {
|
|
429
|
+
return { status: "transient" }; // network blip — keep polling
|
|
430
|
+
}
|
|
431
|
+
if (res.ok) {
|
|
432
|
+
const tokens = await res.json();
|
|
433
|
+
return { status: "approved", token: tokens.access_token };
|
|
434
|
+
}
|
|
435
|
+
if (res.status >= 500) return { status: "transient" };
|
|
436
|
+
const err = await res.json().catch(() => ({}));
|
|
437
|
+
switch (err.error) {
|
|
438
|
+
case "authorization_pending":
|
|
439
|
+
return { status: "pending" };
|
|
440
|
+
case "slow_down":
|
|
441
|
+
return { status: "slow_down" };
|
|
442
|
+
case "access_denied":
|
|
443
|
+
return { status: "denied" };
|
|
444
|
+
case "expired_token":
|
|
445
|
+
return { status: "expired" };
|
|
446
|
+
default:
|
|
447
|
+
throw new Error(err.error_description || err.error || "Sign-in poll failed.");
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Prints a prompt and resolves on the next keypress. No-op when stdin isn't a TTY. */
|
|
452
|
+
function waitForEnter(prompt) {
|
|
453
|
+
if (!process.stdin.isTTY) return Promise.resolve();
|
|
454
|
+
return new Promise((resolve) => {
|
|
455
|
+
process.stdout.write(` ${prompt} `);
|
|
456
|
+
const onData = (chunk) => {
|
|
457
|
+
process.stdin.removeListener("data", onData);
|
|
458
|
+
process.stdin.setRawMode?.(false);
|
|
459
|
+
process.stdin.pause();
|
|
460
|
+
process.stdout.write("\n");
|
|
461
|
+
if (chunk[0] === 0x03) process.exit(130); // Ctrl-C
|
|
462
|
+
resolve();
|
|
463
|
+
};
|
|
464
|
+
process.stdin.setRawMode?.(true);
|
|
465
|
+
process.stdin.resume();
|
|
466
|
+
process.stdin.on("data", onData);
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async function fetchWhoami(token) {
|
|
471
|
+
const res = await fetch(`${APP_URL}/api/cli/whoami`, {
|
|
472
|
+
headers: { authorization: `Bearer ${token}` },
|
|
473
|
+
});
|
|
474
|
+
if (!res.ok) throw new Error("whoami failed");
|
|
475
|
+
return res.json();
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function performDeviceLogin() {
|
|
479
|
+
const [pc, { default: boxen }, { default: ora }] = await Promise.all([
|
|
480
|
+
import("picocolors").then((m) => m.default),
|
|
481
|
+
import("boxen"),
|
|
482
|
+
import("ora"),
|
|
483
|
+
]);
|
|
484
|
+
|
|
485
|
+
let authorization;
|
|
486
|
+
try {
|
|
487
|
+
authorization = await startDeviceAuthorization();
|
|
488
|
+
} catch (err) {
|
|
489
|
+
console.error(pc.red(`✗ ${err.message}`));
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const codeLine = `${pc.dim("Your one-time code:")}\n\n ${pc.green(pc.bold(authorization.user_code))}`;
|
|
494
|
+
const linkLine = `${pc.dim("Open this link to approve:")}\n${pc.cyan(authorization.verification_uri_complete)}\n\n${pc.dim("Or visit")} ${pc.cyan(authorization.verification_uri)} ${pc.dim("and enter the code above.")}`;
|
|
495
|
+
console.log(
|
|
496
|
+
boxen(`${codeLine}\n\n${linkLine}`, {
|
|
497
|
+
title: "Sign in to PingHumans",
|
|
498
|
+
titleAlignment: "left",
|
|
499
|
+
padding: 1,
|
|
500
|
+
margin: { top: 1, bottom: 1, left: 2, right: 2 },
|
|
501
|
+
borderStyle: "round",
|
|
502
|
+
borderColor: "gray",
|
|
503
|
+
})
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
await waitForEnter(pc.dim("Press Enter to open the browser, or Ctrl-C to quit..."));
|
|
507
|
+
try {
|
|
508
|
+
await open(authorization.verification_uri_complete);
|
|
509
|
+
} catch {
|
|
510
|
+
console.log(pc.dim(" Couldn't open a browser — visit the link above manually."));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const spinner = ora({ text: "Waiting for authorization...", indent: 2 }).start();
|
|
514
|
+
const deadline = Date.now() + authorization.expires_in * 1000;
|
|
515
|
+
let intervalMs = (authorization.interval ?? 5) * 1000;
|
|
516
|
+
|
|
517
|
+
while (Date.now() < deadline) {
|
|
518
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
519
|
+
let result;
|
|
520
|
+
try {
|
|
521
|
+
result = await pollDeviceToken(authorization.device_code);
|
|
522
|
+
} catch (err) {
|
|
523
|
+
spinner.fail(pc.red("Sign-in failed"));
|
|
524
|
+
console.error(pc.red(err.message));
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
if (result.status === "approved") {
|
|
528
|
+
let successText = "Login successful!";
|
|
529
|
+
try {
|
|
530
|
+
const who = await fetchWhoami(result.token);
|
|
531
|
+
const name = who.email || who.name;
|
|
532
|
+
if (name) successText = `Logged in as ${pc.bold(name)}`;
|
|
533
|
+
} catch {}
|
|
534
|
+
spinner.succeed(pc.green(successText));
|
|
535
|
+
return result.token;
|
|
536
|
+
}
|
|
537
|
+
if (result.status === "denied") {
|
|
538
|
+
spinner.fail(pc.red("Authorization denied."));
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
if (result.status === "expired") {
|
|
542
|
+
spinner.fail(pc.red("Code expired. Run setup again."));
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
// slow_down / transient: RFC 8628 §3.5 — back off 5s. pending: keep cadence.
|
|
546
|
+
if (result.status === "slow_down" || result.status === "transient") {
|
|
547
|
+
intervalMs += 5000;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
spinner.fail(pc.red("Code expired without approval."));
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// ─── Client config patching ───────────────────────────────────────────────
|
|
555
|
+
|
|
556
|
+
function configPath(client) {
|
|
557
|
+
const home = homedir();
|
|
558
|
+
const isMac = platform() === "darwin";
|
|
559
|
+
const isWin = platform() === "win32";
|
|
560
|
+
switch (client) {
|
|
561
|
+
case "claude-desktop": {
|
|
562
|
+
if (isMac) {
|
|
563
|
+
return join(
|
|
564
|
+
home,
|
|
565
|
+
"Library",
|
|
566
|
+
"Application Support",
|
|
567
|
+
"Claude",
|
|
568
|
+
"claude_desktop_config.json"
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
if (isWin) {
|
|
572
|
+
return join(
|
|
573
|
+
process.env.APPDATA || join(home, "AppData", "Roaming"),
|
|
574
|
+
"Claude",
|
|
575
|
+
"claude_desktop_config.json"
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
return join(home, ".config", "Claude", "claude_desktop_config.json");
|
|
579
|
+
}
|
|
580
|
+
case "cursor":
|
|
581
|
+
return join(home, ".cursor", "mcp.json");
|
|
582
|
+
case "claude-code":
|
|
583
|
+
return null; // managed via `claude mcp add`
|
|
584
|
+
default:
|
|
585
|
+
throw new Error(`Unknown client: ${client}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async function patchConfig(client, token) {
|
|
590
|
+
if (client === "claude-code") {
|
|
591
|
+
return patchClaudeCodeViaCli(token);
|
|
592
|
+
}
|
|
593
|
+
const path = configPath(client);
|
|
594
|
+
await mkdir(dirname(path), { recursive: true });
|
|
595
|
+
let config = {};
|
|
596
|
+
try {
|
|
597
|
+
const text = await readFile(path, "utf8");
|
|
598
|
+
config = JSON.parse(text);
|
|
599
|
+
} catch {
|
|
600
|
+
/* missing or empty */
|
|
601
|
+
}
|
|
602
|
+
if (!config.mcpServers || typeof config.mcpServers !== "object") {
|
|
603
|
+
config.mcpServers = {};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (client === "claude-desktop") {
|
|
607
|
+
// Claude Desktop doesn't natively support Streamable HTTP MCP servers
|
|
608
|
+
// (only stdio). Bridge via the community mcp-remote package, which spawns
|
|
609
|
+
// a stdio server that proxies to our hosted HTTP MCP.
|
|
610
|
+
config.mcpServers.pinghumans = {
|
|
611
|
+
command: "npx",
|
|
612
|
+
args: [
|
|
613
|
+
"-y",
|
|
614
|
+
"mcp-remote",
|
|
615
|
+
`${APP_URL}/api/mcp`,
|
|
616
|
+
"--header",
|
|
617
|
+
`Authorization: Bearer ${token}`,
|
|
618
|
+
],
|
|
619
|
+
};
|
|
620
|
+
} else {
|
|
621
|
+
// Cursor + others: native streamable-HTTP works.
|
|
622
|
+
config.mcpServers.pinghumans = {
|
|
623
|
+
url: `${APP_URL}/api/mcp`,
|
|
624
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
await writeFile(path, JSON.stringify(config, null, 2) + "\n");
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async function unpatchConfig(client) {
|
|
632
|
+
if (client === "claude-code") {
|
|
633
|
+
try {
|
|
634
|
+
await execFileP("claude", ["mcp", "remove", "pinghumans", "--scope", "user"]);
|
|
635
|
+
} catch (err) {
|
|
636
|
+
console.error(`Couldn't run \`claude mcp remove\`: ${err.message}`);
|
|
637
|
+
}
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
const path = configPath(client);
|
|
641
|
+
try {
|
|
642
|
+
const text = await readFile(path, "utf8");
|
|
643
|
+
const config = JSON.parse(text);
|
|
644
|
+
if (config?.mcpServers?.pinghumans) {
|
|
645
|
+
delete config.mcpServers.pinghumans;
|
|
646
|
+
await writeFile(path, JSON.stringify(config, null, 2) + "\n");
|
|
647
|
+
}
|
|
648
|
+
} catch {
|
|
649
|
+
/* nothing to remove */
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// ─── Agent rules ──────────────────────────────────────────────────────────
|
|
654
|
+
//
|
|
655
|
+
// In addition to MCP tool descriptions, we install a short prose "rule"
|
|
656
|
+
// telling the agent WHEN to reach for ping_humans. Tool descriptions only
|
|
657
|
+
// fire during tool selection; rules sit in the agent's persistent
|
|
658
|
+
// instructions and bias it toward the tool earlier in reasoning.
|
|
659
|
+
//
|
|
660
|
+
// Per-client landing spots:
|
|
661
|
+
// - Claude Code → ~/.claude/CLAUDE.md (the canonical user-memory file
|
|
662
|
+
// Claude Code auto-loads at session start). Marker tags so re-install
|
|
663
|
+
// and remove are clean even if the user hand-edits the file.
|
|
664
|
+
// - Cursor → ~/.cursor/rules/pinghumans.mdc (Cursor's auto-loaded
|
|
665
|
+
// rules dir, with `alwaysApply: true` frontmatter).
|
|
666
|
+
// - Claude Desktop → no equivalent; skipped.
|
|
667
|
+
//
|
|
668
|
+
// (RULES_MARKER_START / RULES_MARKER_END / RULES_BODY constants are
|
|
669
|
+
// declared near the top of the module — they're consumed indirectly via
|
|
670
|
+
// the entry-point try block, which runs before this section is even
|
|
671
|
+
// parsed in module-execution order.)
|
|
672
|
+
|
|
673
|
+
function escapeRegExp(s) {
|
|
674
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function stripMarkerBlock(text) {
|
|
678
|
+
const re = new RegExp(
|
|
679
|
+
`\\n*${escapeRegExp(RULES_MARKER_START)}[\\s\\S]*?${escapeRegExp(RULES_MARKER_END)}\\n*`,
|
|
680
|
+
"g"
|
|
681
|
+
);
|
|
682
|
+
return text.replace(re, "\n").trimStart();
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Where each client's rule + skill live. Claude Desktop has neither
|
|
686
|
+
// mechanism, so it gets MCP config only.
|
|
687
|
+
function rulePath(client) {
|
|
688
|
+
if (client === "claude-code")
|
|
689
|
+
return join(homedir(), ".claude", "rules", "pinghumans.md");
|
|
690
|
+
if (client === "cursor")
|
|
691
|
+
return join(homedir(), ".cursor", "rules", "pinghumans.mdc");
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function skillPath(client) {
|
|
696
|
+
if (client === "claude-code")
|
|
697
|
+
return join(homedir(), ".claude", "skills", "pinghumans-qa", "SKILL.md");
|
|
698
|
+
if (client === "cursor")
|
|
699
|
+
return join(homedir(), ".cursor", "skills", "pinghumans-qa", "SKILL.md");
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function ruleContent(client) {
|
|
704
|
+
// Cursor's rules dir requires frontmatter; Claude Code's takes plain md.
|
|
705
|
+
if (client === "cursor") return "---\nalwaysApply: true\n---\n\n" + RULE_BODY;
|
|
706
|
+
return RULE_BODY;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Installs/refreshes the rule + skill for a client. Returns
|
|
710
|
+
// [{label, path}, ...] for the setup summary. Also migrates pre-0.1.0
|
|
711
|
+
// installs by stripping our legacy managed block out of ~/.claude/CLAUDE.md
|
|
712
|
+
// — the agent guidance lives in its own files now, not the user's memory.
|
|
713
|
+
async function patchRules(client) {
|
|
714
|
+
const installed = [];
|
|
715
|
+
const rp = rulePath(client);
|
|
716
|
+
if (!rp) return installed;
|
|
717
|
+
|
|
718
|
+
if (client === "claude-code") await stripLegacyClaudeMdBlock();
|
|
719
|
+
|
|
720
|
+
await mkdir(dirname(rp), { recursive: true });
|
|
721
|
+
await writeFile(rp, ruleContent(client));
|
|
722
|
+
installed.push({ label: "Rule installed", path: rp });
|
|
723
|
+
|
|
724
|
+
const sp = skillPath(client);
|
|
725
|
+
await mkdir(dirname(sp), { recursive: true });
|
|
726
|
+
await writeFile(sp, SKILL_BODY);
|
|
727
|
+
installed.push({ label: "Skill installed", path: sp });
|
|
728
|
+
|
|
729
|
+
return installed;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
async function stripLegacyClaudeMdBlock() {
|
|
733
|
+
const path = join(homedir(), ".claude", "CLAUDE.md");
|
|
734
|
+
try {
|
|
735
|
+
const text = await readFile(path, "utf8");
|
|
736
|
+
if (!text.includes(RULES_MARKER_START)) return false;
|
|
737
|
+
const next = stripMarkerBlock(text);
|
|
738
|
+
await writeFile(path, next.trim().length === 0 ? "" : next);
|
|
739
|
+
return true;
|
|
740
|
+
} catch {
|
|
741
|
+
return false;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// ─── Stale-rules self-healing ───────────────────────────────────────────────
|
|
746
|
+
//
|
|
747
|
+
// The rules text evolves with the product, but installs only rewrote it on
|
|
748
|
+
// `setup`. Now ANY invocation of the CLI (npx fetches the latest package)
|
|
749
|
+
// silently refreshes a previously-installed-but-outdated block. Files we
|
|
750
|
+
// never touched are left alone — presence of our marker (Claude Code) or our
|
|
751
|
+
// dedicated file (Cursor) is the consent signal.
|
|
752
|
+
async function refreshStaleRules({ verbose = false } = {}) {
|
|
753
|
+
const updated = [];
|
|
754
|
+
|
|
755
|
+
for (const client of ["claude-code", "cursor"]) {
|
|
756
|
+
// Consent signal: our rule file exists (current installs) or our legacy
|
|
757
|
+
// managed block sits in ~/.claude/CLAUDE.md (pre-0.1.0 installs, which
|
|
758
|
+
// migrate to the rules-dir + skill layout on this refresh).
|
|
759
|
+
const rp = rulePath(client);
|
|
760
|
+
let installedHere = await pathExists(rp);
|
|
761
|
+
if (!installedHere && client === "claude-code") {
|
|
762
|
+
try {
|
|
763
|
+
const text = await readFile(join(homedir(), ".claude", "CLAUDE.md"), "utf8");
|
|
764
|
+
installedHere = text.includes(RULES_MARKER_START);
|
|
765
|
+
} catch {
|
|
766
|
+
/* no file → never installed */
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
if (!installedHere) continue;
|
|
770
|
+
|
|
771
|
+
const ruleCurrent =
|
|
772
|
+
(await readFile(rp, "utf8").catch(() => "")) === ruleContent(client);
|
|
773
|
+
const skillCurrent =
|
|
774
|
+
(await readFile(skillPath(client), "utf8").catch(() => "")) === SKILL_BODY;
|
|
775
|
+
if (!ruleCurrent || !skillCurrent) {
|
|
776
|
+
const files = await patchRules(client);
|
|
777
|
+
updated.push(...files.map((f) => f.path));
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
if (updated.length > 0) {
|
|
782
|
+
console.log(`↻ Refreshed PingHumans agent rules (v${VERSION}):`);
|
|
783
|
+
for (const p of updated) console.log(` ${p}`);
|
|
784
|
+
track("rules_refresh");
|
|
785
|
+
} else if (verbose) {
|
|
786
|
+
console.log(`✓ Agent rules already current (v${VERSION}).`);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Same always-run path also catches a silently-dead token before a
|
|
790
|
+
// downstream client (Claude Desktop) fails cryptically. Never throws.
|
|
791
|
+
try {
|
|
792
|
+
await validateStoredToken();
|
|
793
|
+
} catch {
|
|
794
|
+
/* validation is best-effort */
|
|
795
|
+
}
|
|
796
|
+
return updated.length;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
async function unpatchRules(client) {
|
|
800
|
+
// Legacy installs: clear our managed block out of ~/.claude/CLAUDE.md.
|
|
801
|
+
if (client === "claude-code") await stripLegacyClaudeMdBlock();
|
|
802
|
+
|
|
803
|
+
const { unlink, rm } = await import("node:fs/promises");
|
|
804
|
+
const rp = rulePath(client);
|
|
805
|
+
if (rp) await unlink(rp).catch(() => {});
|
|
806
|
+
const sp = skillPath(client);
|
|
807
|
+
if (sp) await rm(dirname(sp), { recursive: true, force: true }).catch(() => {});
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
async function patchClaudeCodeViaCli(token) {
|
|
811
|
+
// The official `claude mcp add` CLI is the recommended path.
|
|
812
|
+
// It writes to ~/.claude.json (or similar) for us.
|
|
813
|
+
try {
|
|
814
|
+
// Remove any existing config first, otherwise `mcp add` errors on dupes.
|
|
815
|
+
await execFileP("claude", [
|
|
816
|
+
"mcp",
|
|
817
|
+
"remove",
|
|
818
|
+
"pinghumans",
|
|
819
|
+
"--scope",
|
|
820
|
+
"user",
|
|
821
|
+
]).catch(() => {});
|
|
822
|
+
// Order matters: `--header` is variadic in `claude mcp add`, so it has to
|
|
823
|
+
// come AFTER the positional <name> and <url> args, otherwise commander
|
|
824
|
+
// eats them as additional headers and bails with "missing argument name".
|
|
825
|
+
await execFileP("claude", [
|
|
826
|
+
"mcp",
|
|
827
|
+
"add",
|
|
828
|
+
"--scope",
|
|
829
|
+
"user",
|
|
830
|
+
"--transport",
|
|
831
|
+
"http",
|
|
832
|
+
"pinghumans",
|
|
833
|
+
`${APP_URL}/api/mcp`,
|
|
834
|
+
"--header",
|
|
835
|
+
`Authorization: Bearer ${token}`,
|
|
836
|
+
]);
|
|
837
|
+
} catch (err) {
|
|
838
|
+
throw new Error(
|
|
839
|
+
`\`claude mcp add\` failed. Is the Claude Code CLI installed on your PATH? (https://docs.claude.com/en/docs/claude-code)\n ${err.message}`
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
845
|
+
|
|
846
|
+
function parseClient(args) {
|
|
847
|
+
for (let i = 0; i < args.length; i++) {
|
|
848
|
+
if (args[i] === "--client" && args[i + 1]) {
|
|
849
|
+
const c = String(args[i + 1]).toLowerCase();
|
|
850
|
+
if (SUPPORTED_CLIENTS.includes(c)) return c;
|
|
851
|
+
throw new Error(
|
|
852
|
+
`Unknown --client "${c}". Supported: ${SUPPORTED_CLIENTS.join(", ")}.`
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
if (args[i] === "--claude" || args[i] === "--claude-desktop")
|
|
856
|
+
return "claude-desktop";
|
|
857
|
+
if (args[i] === "--code" || args[i] === "--claude-code")
|
|
858
|
+
return "claude-code";
|
|
859
|
+
if (args[i] === "--cursor") return "cursor";
|
|
860
|
+
}
|
|
861
|
+
return null;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function prettyClient(client) {
|
|
865
|
+
return (
|
|
866
|
+
{
|
|
867
|
+
"claude-desktop": "Claude Desktop",
|
|
868
|
+
"claude-code": "Claude Code",
|
|
869
|
+
cursor: "Cursor",
|
|
870
|
+
}[client] || client
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function printHelp() {
|
|
875
|
+
console.log(`cpyany — install the PingHumans MCP server in your AI client.
|
|
876
|
+
|
|
877
|
+
Usage:
|
|
878
|
+
cpyany setup [--client claude-code|claude-desktop|cursor]
|
|
879
|
+
cpyany remove [--client claude-code|claude-desktop|cursor]
|
|
880
|
+
cpyany wait <ping_id> [--timeout <seconds>]
|
|
881
|
+
# block until a human test has results (exit 0 = news,
|
|
882
|
+
# 2 = timed out). Run it in the background after filing
|
|
883
|
+
# a test so your agent gets woken when results land.
|
|
884
|
+
cpyany whoami # show which PingHumans account this machine's token belongs to
|
|
885
|
+
cpyany rules # refresh the installed agent rules to this version
|
|
886
|
+
cpyany version
|
|
887
|
+
|
|
888
|
+
Without --client, setup auto-detects every supported AI client installed on
|
|
889
|
+
this machine (Claude Code, Claude Desktop, Cursor) and patches all of their
|
|
890
|
+
configs from a single OAuth flow. Use --client to restrict to one.
|
|
891
|
+
|
|
892
|
+
Setup opens your browser to ${APP_URL}/cli-auth, generates a fresh bearer
|
|
893
|
+
token after you sign in, and writes it into each client's MCP config.
|
|
894
|
+
|
|
895
|
+
Aliases: --claude (= --claude-desktop), --code (= --claude-code).
|
|
896
|
+
Legacy binary alias: pinghumans.
|
|
897
|
+
`);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// ─── `cpyany wait` — background-friendly result waiting ────────────────
|
|
901
|
+
//
|
|
902
|
+
// Turn-based coding agents can't poll on a timer. But harnesses with
|
|
903
|
+
// background shell tasks (Claude Code etc.) re-invoke the agent when a
|
|
904
|
+
// background command EXITS — so this command is the bridge to true async:
|
|
905
|
+
// launch it in the background right after filing a test; it polls the MCP
|
|
906
|
+
// endpoint client-side and exits the moment there's news.
|
|
907
|
+
//
|
|
908
|
+
// Exit codes: 0 = news (new result, or task complete/expired) — output has
|
|
909
|
+
// the full results text; 2 = timed out still pending; 1 = error.
|
|
910
|
+
|
|
911
|
+
// Hoisted as a function — the entry dispatch runs before bottom-of-module
|
|
912
|
+
// consts initialize (the 0.0.4 TDZ lesson, again).
|
|
913
|
+
function credsPath() {
|
|
914
|
+
return join(homedir(), ".config", "pinghumans", "credentials.json");
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
async function readCreds() {
|
|
918
|
+
try {
|
|
919
|
+
return JSON.parse(await readFile(credsPath(), "utf8"));
|
|
920
|
+
} catch {
|
|
921
|
+
return null;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
async function writeCreds(obj) {
|
|
926
|
+
try {
|
|
927
|
+
await mkdir(dirname(credsPath()), { recursive: true });
|
|
928
|
+
await writeFile(credsPath(), JSON.stringify(obj, null, 2), { mode: 0o600 });
|
|
929
|
+
} catch {
|
|
930
|
+
/* best-effort — `wait` falls back to client configs */
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
async function saveLocalToken(token) {
|
|
935
|
+
// Fresh token from setup → reset any throttle/nudge state.
|
|
936
|
+
await writeCreds({ token });
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// Passive token-health check. A dead token (revoked, or the account was
|
|
940
|
+
// deleted — its mcp_tokens cascade-removed) otherwise sits silently in
|
|
941
|
+
// every client config until something like Claude Desktop fails with a
|
|
942
|
+
// cryptic OAuth race. Hooked into the always-run self-heal so any
|
|
943
|
+
// `npx cpyany` invocation catches it. Strict rules: only nudge on an
|
|
944
|
+
// EXPLICIT auth failure (401/403 or {success:false}); stay silent on
|
|
945
|
+
// offline/timeout/5xx (no false alarms); throttle to once/day; stderr only.
|
|
946
|
+
async function validateStoredToken() {
|
|
947
|
+
const token = await resolveLocalToken();
|
|
948
|
+
if (!token) return; // nothing stored → nothing to validate
|
|
949
|
+
|
|
950
|
+
const creds = await readCreds();
|
|
951
|
+
if (creds && typeof creds.checkedAt === "number" &&
|
|
952
|
+
Date.now() - creds.checkedAt < NUDGE_INTERVAL_MS) {
|
|
953
|
+
return; // checked recently — don't re-probe or re-nudge
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
let dead = false;
|
|
957
|
+
try {
|
|
958
|
+
const ctrl = new AbortController();
|
|
959
|
+
const timer = setTimeout(() => ctrl.abort(), 3000);
|
|
960
|
+
const res = await fetch(`${APP_URL}/api/cli/whoami`, {
|
|
961
|
+
headers: { authorization: `Bearer ${token}` },
|
|
962
|
+
signal: ctrl.signal,
|
|
963
|
+
}).finally(() => clearTimeout(timer));
|
|
964
|
+
if (res.status === 401 || res.status === 403) {
|
|
965
|
+
dead = true;
|
|
966
|
+
} else if (res.ok) {
|
|
967
|
+
const j = await res.json().catch(() => ({}));
|
|
968
|
+
if (j && j.success === false) dead = true;
|
|
969
|
+
}
|
|
970
|
+
// 5xx / other → inconclusive; fall through without recording or nudging.
|
|
971
|
+
if (res.status >= 500) return;
|
|
972
|
+
} catch {
|
|
973
|
+
return; // offline / aborted → never false-alarm, retry next run
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// Record the (reachable) check so we throttle — only when creds.json
|
|
977
|
+
// already exists (every install since 0.0.10); ancient config-only
|
|
978
|
+
// installs just pay the cheap check each run.
|
|
979
|
+
if (creds) await writeCreds({ ...creds, checkedAt: Date.now() });
|
|
980
|
+
|
|
981
|
+
if (dead) {
|
|
982
|
+
const pc = await import("picocolors").then((m) => m.default).catch(() => null);
|
|
983
|
+
const warn = (s) => (pc ? pc.yellow(s) : s);
|
|
984
|
+
console.error(
|
|
985
|
+
"\n" +
|
|
986
|
+
warn("⚠ Your PingHumans token is no longer valid") +
|
|
987
|
+
" (revoked, or the account was deleted).\n" +
|
|
988
|
+
" Re-link this machine: " +
|
|
989
|
+
(pc ? pc.cyan("npx cpyany setup") : "npx cpyany setup") +
|
|
990
|
+
"\n"
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
async function resolveLocalToken() {
|
|
996
|
+
// 1) Our own stash (written by setup since 0.0.10).
|
|
997
|
+
try {
|
|
998
|
+
const creds = JSON.parse(await readFile(credsPath(), "utf8"));
|
|
999
|
+
if (creds.token) return creds.token;
|
|
1000
|
+
} catch {}
|
|
1001
|
+
// 2) Older installs: fish the bearer out of a client config we wrote.
|
|
1002
|
+
const candidates = [
|
|
1003
|
+
join(homedir(), ".claude.json"),
|
|
1004
|
+
configPath("claude-desktop"),
|
|
1005
|
+
configPath("cursor"),
|
|
1006
|
+
];
|
|
1007
|
+
for (const p of candidates) {
|
|
1008
|
+
try {
|
|
1009
|
+
const cfg = JSON.parse(await readFile(p, "utf8"));
|
|
1010
|
+
const entry = cfg?.mcpServers?.pinghumans;
|
|
1011
|
+
const header = entry?.headers?.Authorization ?? entry?.headers?.authorization;
|
|
1012
|
+
const m = /Bearer\s+(\S+)/.exec(header ?? "");
|
|
1013
|
+
if (m) return m[1];
|
|
1014
|
+
} catch {}
|
|
1015
|
+
}
|
|
1016
|
+
return null;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
async function waitForResultsCli(rest) {
|
|
1020
|
+
const pingId = rest.find((a) => !a.startsWith("--"));
|
|
1021
|
+
if (!pingId || !/^[0-9a-f-]{36}$/i.test(pingId)) {
|
|
1022
|
+
throw new Error("usage: cpyany wait <ping_id> [--timeout <seconds>]");
|
|
1023
|
+
}
|
|
1024
|
+
const tIdx = rest.indexOf("--timeout");
|
|
1025
|
+
const timeoutSec = tIdx >= 0 ? Math.max(30, parseInt(rest[tIdx + 1], 10) || 1800) : 1800;
|
|
1026
|
+
|
|
1027
|
+
const token = await resolveLocalToken();
|
|
1028
|
+
if (!token) {
|
|
1029
|
+
throw new Error(
|
|
1030
|
+
"No PingHumans token found. Run `npx cpyany setup` first."
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const deadline = Date.now() + timeoutSec * 1000;
|
|
1035
|
+
let baseline = null;
|
|
1036
|
+
for (;;) {
|
|
1037
|
+
const res = await fetch(`${APP_URL}/api/mcp`, {
|
|
1038
|
+
method: "POST",
|
|
1039
|
+
headers: {
|
|
1040
|
+
"content-type": "application/json",
|
|
1041
|
+
accept: "application/json, text/event-stream",
|
|
1042
|
+
authorization: `Bearer ${token}`,
|
|
1043
|
+
},
|
|
1044
|
+
body: JSON.stringify({
|
|
1045
|
+
jsonrpc: "2.0",
|
|
1046
|
+
id: 1,
|
|
1047
|
+
method: "tools/call",
|
|
1048
|
+
params: { name: "get_test_results", arguments: { ping_id: pingId } },
|
|
1049
|
+
}),
|
|
1050
|
+
});
|
|
1051
|
+
const raw = await res.text();
|
|
1052
|
+
const m = raw.match(/data: (.*)/);
|
|
1053
|
+
const payload = JSON.parse(m ? m[1] : raw);
|
|
1054
|
+
if (payload.error) throw new Error(payload.error.message ?? "MCP error");
|
|
1055
|
+
const result = payload.result;
|
|
1056
|
+
const sc = result.structuredContent ?? {};
|
|
1057
|
+
const status = sc.status ?? "pending";
|
|
1058
|
+
const received = sc.n_received ?? 0;
|
|
1059
|
+
|
|
1060
|
+
if (baseline === null) baseline = received;
|
|
1061
|
+
|
|
1062
|
+
if (status === "not_found") {
|
|
1063
|
+
throw new Error(
|
|
1064
|
+
"Ping not found for this account. Results are asker-scoped — `wait` only works on tests filed with the same PingHumans account."
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
1067
|
+
if (status !== "pending" || received > baseline) {
|
|
1068
|
+
// News — print the agent-readable result and exit 0 so a background
|
|
1069
|
+
// harness wakes the agent up.
|
|
1070
|
+
console.log(result.content?.[0]?.text ?? JSON.stringify(sc, null, 2));
|
|
1071
|
+
process.exit(0);
|
|
1072
|
+
}
|
|
1073
|
+
if (Date.now() >= deadline) {
|
|
1074
|
+
console.log(
|
|
1075
|
+
`Timed out after ${timeoutSec}s — still pending (${received}/${sc.n_target ?? "?"} results). ` +
|
|
1076
|
+
`Run \`cpyany wait ${pingId}\` again or check later with get_test_results.`
|
|
1077
|
+
);
|
|
1078
|
+
process.exit(2);
|
|
1079
|
+
}
|
|
1080
|
+
process.stderr.write(
|
|
1081
|
+
`… pending ${received}/${sc.n_target ?? "?"} (${Math.round((deadline - Date.now()) / 1000)}s left)\n`
|
|
1082
|
+
);
|
|
1083
|
+
await new Promise((r) => setTimeout(r, 20000));
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// ─── `cpyany whoami` ───────────────────────────────────────────────────
|
|
1088
|
+
//
|
|
1089
|
+
// Which account does this machine's token belong to? Results are
|
|
1090
|
+
// asker-scoped server-side, so "Ping not found" almost always means
|
|
1091
|
+
// wrong-account token — this is the 10-second way to check.
|
|
1092
|
+
|
|
1093
|
+
async function whoamiCli() {
|
|
1094
|
+
const pc = await import("picocolors").then((m) => m.default);
|
|
1095
|
+
const token = await resolveLocalToken();
|
|
1096
|
+
if (!token) {
|
|
1097
|
+
console.log(pc.yellow("Not logged in."));
|
|
1098
|
+
console.log(pc.dim("Run `npx cpyany setup` to authenticate."));
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
try {
|
|
1102
|
+
const who = await fetchWhoami(token);
|
|
1103
|
+
console.log(pc.green("Logged in"));
|
|
1104
|
+
if (who.name) console.log(`${pc.dim("Name:".padEnd(13))}${who.name}`);
|
|
1105
|
+
if (who.email) console.log(`${pc.dim("Email:".padEnd(13))}${who.email}`);
|
|
1106
|
+
} catch {
|
|
1107
|
+
console.log(pc.yellow("Token present but not accepted by the server."));
|
|
1108
|
+
console.log(pc.dim("Run `npx cpyany setup` to re-authenticate."));
|
|
1109
|
+
}
|
|
1110
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cpyany",
|
|
3
|
+
"version": "0.2.5",
|
|
4
|
+
"description": "Install the PingHumans MCP server in Claude Desktop, Claude Code, or Cursor with one command.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.mjs",
|
|
7
|
+
"bin": {
|
|
8
|
+
"cpyany": "index.mjs",
|
|
9
|
+
"copyanything": "index.mjs",
|
|
10
|
+
"pinghumans": "index.mjs"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"index.mjs",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test:setup": "node index.mjs setup --client claude-code"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@inquirer/prompts": "^8.5.2",
|
|
24
|
+
"boxen": "^8.0.1",
|
|
25
|
+
"open": "^10.1.2",
|
|
26
|
+
"ora": "^9.4.0",
|
|
27
|
+
"picocolors": "^1.1.1"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://pinghumans.com",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/alex-durango/pinghumans.git",
|
|
33
|
+
"directory": "cli"
|
|
34
|
+
},
|
|
35
|
+
"license": "UNLICENSED",
|
|
36
|
+
"private": false,
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"keywords": [
|
|
41
|
+
"mcp",
|
|
42
|
+
"model-context-protocol",
|
|
43
|
+
"claude",
|
|
44
|
+
"cursor",
|
|
45
|
+
"ai",
|
|
46
|
+
"humans",
|
|
47
|
+
"cpyany",
|
|
48
|
+
"copyanything",
|
|
49
|
+
"pinghumans"
|
|
50
|
+
]
|
|
51
|
+
}
|