cpyany 0.2.7 → 0.2.8

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 (3) hide show
  1. package/README.md +7 -4
  2. package/index.mjs +97 -14
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -8,8 +8,10 @@ npx cpyany setup
8
8
  ```
9
9
 
10
10
  Auto-detects every supported AI client installed on your machine (Claude Code,
11
- Claude Desktop, Cursor), opens a browser to authorize, and writes the resulting
12
- bearer token into each client's MCP config from a single OAuth flow.
11
+ Claude Desktop, Cursor, Codex), opens a browser to authorize, and writes the
12
+ resulting bearer token into each client's MCP config from a single OAuth flow.
13
+ Re-running setup reuses the machine's existing login, so adding a client later
14
+ is just `npx cpyany setup --client <name>`.
13
15
 
14
16
  After setup, ask your coding agent to copy from a reference:
15
17
 
@@ -24,6 +26,7 @@ Use cpyany to copy the hero section from https://example.com into this app.
24
26
  | Claude Code | runs `claude mcp add --scope user --transport http …` (writes `~/.claude.json`) | `~/.claude/rules/cpyany.md` + `~/.claude/skills/cpyany/SKILL.md` | `claude` on `PATH` |
25
27
  | 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 |
26
28
  | Cursor | `~/.cursor/mcp.json` | `~/.cursor/rules/cpyany.mdc` (`alwaysApply: true`) + `~/.cursor/skills/cpyany/SKILL.md` | `~/.cursor` exists |
29
+ | Codex | `[mcp_servers.cpyany]` table in `~/.codex/config.toml` (streamable HTTP, bearer via `http_headers`) | _(none yet — MCP config only)_ | `~/.codex` exists or `codex` on `PATH` |
27
30
 
28
31
  Guidance is two-tier: a short always-loaded rule telling the agent when to use
29
32
  cpyany, and a deeper `cpyany` skill with the copy-from-reference workflow.
@@ -35,8 +38,8 @@ cpyany, and a deeper `cpyany` skill with the copy-from-reference workflow.
35
38
  npx cpyany setup --client claude-code
36
39
  ```
37
40
 
38
- `--client` accepts `claude-code`, `claude-desktop`, or `cursor`. Aliases:
39
- `--claude` (= `--claude-desktop`), `--code` (= `--claude-code`).
41
+ `--client` accepts `claude-code`, `claude-desktop`, `cursor`, or `codex`.
42
+ Aliases: `--claude` (= `--claude-desktop`), `--code` (= `--claude-code`).
40
43
 
41
44
  ## Skip the browser
42
45
 
package/index.mjs CHANGED
@@ -11,7 +11,7 @@ import { execFile } from "node:child_process";
11
11
  import { promisify } from "node:util";
12
12
  import open from "open";
13
13
 
14
- const VERSION = "0.2.6";
14
+ const VERSION = "0.2.8";
15
15
  const execFileP = promisify(execFile);
16
16
  const APP_URL = process.env.PINGHUMANS_APP_URL ?? "https://pinghumans.com";
17
17
  // Hoisted with the other top-of-module consts — the entry try-block runs
@@ -20,7 +20,7 @@ const APP_URL = process.env.PINGHUMANS_APP_URL ?? "https://pinghumans.com";
20
20
  // reaches NUDGE_INTERVAL_MS transitively from that path, so it lives here.
21
21
  const CLI_CLIENT_ID = "pinghumans-cli";
22
22
  const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
23
- const SUPPORTED_CLIENTS = ["claude-desktop", "claude-code", "cursor"];
23
+ const SUPPORTED_CLIENTS = ["claude-desktop", "claude-code", "cursor", "codex"];
24
24
  // Throttle the passive token-health check to once/day so it neither spams
25
25
  // the nudge nor adds a whoami round-trip to every incidental invocation.
26
26
  const NUDGE_INTERVAL_MS = 24 * 60 * 60 * 1000;
@@ -223,7 +223,7 @@ async function setup(client) {
223
223
  const detected = await detectClients();
224
224
  if (detected.length === 0) {
225
225
  throw new Error(
226
- "Couldn't find Claude Code, Claude Desktop, or Cursor on this machine. " +
226
+ "Couldn't find Claude Code, Claude Desktop, Cursor, or Codex on this machine. " +
227
227
  "Install one of them first, or pass --client to specify manually."
228
228
  );
229
229
  }
@@ -244,13 +244,30 @@ async function setup(client) {
244
244
  }
245
245
  }
246
246
 
247
- // One device-code sign-in regardless of how many configs we'll patch
248
- // (RFC 8628 works over SSH too, no localhost listener needed).
249
- const token = await performDeviceLogin();
250
- if (!token) process.exit(1);
251
- _bearerForTelemetry = token;
247
+ // One sign-in regardless of how many configs we'll patch. A stored token
248
+ // that still authenticates is reused re-running setup to add a second
249
+ // client must not force a fresh sign-in. Anything else falls through to
250
+ // the device flow (RFC 8628 — works over SSH too, no localhost listener).
252
251
  const pc0 = await import("picocolors").then((m) => m.default);
253
- console.log(`${pc0.green("✔")} Authenticated`);
252
+ let token = await resolveLocalToken();
253
+ if (token) {
254
+ try {
255
+ const who = await fetchWhoami(token);
256
+ if (who?.success === false) throw new Error("token revoked");
257
+ const name = who?.email || who?.name;
258
+ console.log(
259
+ `${pc0.green("✔")} Already signed in${name ? ` as ${pc0.bold(name)}` : ""} — reusing this machine's login (\`cpyany remove\` signs out)`
260
+ );
261
+ } catch {
262
+ token = null; // dead or unverifiable → fresh sign-in
263
+ }
264
+ }
265
+ if (!token) {
266
+ token = await performDeviceLogin();
267
+ if (!token) process.exit(1);
268
+ console.log(`${pc0.green("✔")} Authenticated`);
269
+ }
270
+ _bearerForTelemetry = token;
254
271
  await saveLocalToken(token); // lets `cpyany wait`/`whoami` authenticate later
255
272
 
256
273
  // Install everything, then print a Context7-style per-client summary.
@@ -362,13 +379,14 @@ async function isOnPath(cmd) {
362
379
  * - claude-desktop: the per-platform config directory exists (created on
363
380
  * first launch; absent if the app was never opened)
364
381
  * - cursor: ~/.cursor exists
382
+ * - codex: ~/.codex exists, or the `codex` binary is on PATH
365
383
  */
366
384
  async function detectClients() {
367
385
  const home = homedir();
368
386
  const isMac = platform() === "darwin";
369
387
  const isWin = platform() === "win32";
370
388
 
371
- const [hasClaudeCode, hasClaudeDesktop, hasCursor] = await Promise.all([
389
+ const [hasClaudeCode, hasClaudeDesktop, hasCursor, hasCodexDir, hasCodexBin] = await Promise.all([
372
390
  isOnPath("claude"),
373
391
  pathExists(
374
392
  isMac
@@ -378,12 +396,15 @@ async function detectClients() {
378
396
  : join(home, ".config", "Claude")
379
397
  ),
380
398
  pathExists(join(home, ".cursor")),
399
+ pathExists(join(home, ".codex")),
400
+ isOnPath("codex"),
381
401
  ]);
382
402
 
383
403
  const out = [];
384
404
  if (hasClaudeCode) out.push("claude-code");
385
405
  if (hasClaudeDesktop) out.push("claude-desktop");
386
406
  if (hasCursor) out.push("cursor");
407
+ if (hasCodexDir || hasCodexBin) out.push("codex");
387
408
  return out;
388
409
  }
389
410
 
@@ -580,6 +601,8 @@ function configPath(client) {
580
601
  }
581
602
  case "cursor":
582
603
  return join(home, ".cursor", "mcp.json");
604
+ case "codex":
605
+ return join(home, ".codex", "config.toml");
583
606
  case "claude-code":
584
607
  return null; // managed via `claude mcp add`
585
608
  default:
@@ -591,6 +614,9 @@ async function patchConfig(client, token) {
591
614
  if (client === "claude-code") {
592
615
  return patchClaudeCodeViaCli(token);
593
616
  }
617
+ if (client === "codex") {
618
+ return patchCodexConfig(token);
619
+ }
594
620
  const path = configPath(client);
595
621
  await mkdir(dirname(path), { recursive: true });
596
622
  let config = {};
@@ -637,6 +663,17 @@ async function unpatchConfig(client) {
637
663
  await execFileP("claude", ["mcp", "remove", "pinghumans", "--scope", "user"]).catch(() => {});
638
664
  return;
639
665
  }
666
+ if (client === "codex") {
667
+ try {
668
+ const path = configPath("codex");
669
+ const text = await readFile(path, "utf8");
670
+ const next = stripTomlTable(text, "mcp_servers.cpyany");
671
+ if (next !== text) await writeFile(path, next);
672
+ } catch {
673
+ /* nothing to remove */
674
+ }
675
+ return;
676
+ }
640
677
  const path = configPath(client);
641
678
  try {
642
679
  const text = await readFile(path, "utf8");
@@ -651,6 +688,48 @@ async function unpatchConfig(client) {
651
688
  }
652
689
  }
653
690
 
691
+ // Codex keeps MCP servers in ~/.codex/config.toml ([mcp_servers.<name>]
692
+ // tables). Streamable HTTP works natively via `url`; the bearer rides in
693
+ // `http_headers` — codex's config validation rejects an inline
694
+ // `bearer_token` key, and the only other option (bearer_token_env_var)
695
+ // needs an env var setup can't persist into the user's shell. Only our
696
+ // own table is rewritten; the rest of the file — users hand-edit
697
+ // config.toml — is left byte-for-byte alone.
698
+ async function patchCodexConfig(token) {
699
+ const path = configPath("codex");
700
+ await mkdir(dirname(path), { recursive: true });
701
+ let text = "";
702
+ try {
703
+ text = await readFile(path, "utf8");
704
+ } catch {
705
+ /* missing — start fresh */
706
+ }
707
+ // Collapse the strip's leftover trailing blank lines so re-runs are
708
+ // byte-stable instead of growing one blank line per invocation.
709
+ text = stripTomlTable(text, "mcp_servers.cpyany").replace(/\n{2,}$/, "\n");
710
+ const block =
711
+ `[mcp_servers.cpyany]\n` +
712
+ `url = "${APP_URL}/api/mcp"\n` +
713
+ `http_headers = { "Authorization" = "Bearer ${token}" }\n`;
714
+ const sep = text.trim().length === 0 ? "" : text.endsWith("\n") ? "\n" : "\n\n";
715
+ await writeFile(path, (text.trim().length === 0 ? "" : text) + sep + block);
716
+ }
717
+
718
+ // Drop one [name] table — its header line plus every line up to the next
719
+ // table header (or EOF). Line-based surgery, not a TOML parse: it only
720
+ // ever deletes between OUR header and the next header, so hand-authored
721
+ // tables around it survive untouched.
722
+ function stripTomlTable(text, tableName) {
723
+ const out = [];
724
+ let inTable = false;
725
+ for (const line of text.split("\n")) {
726
+ const header = /^\s*\[([^\]]+)\]/.exec(line);
727
+ if (header) inTable = header[1].trim() === tableName;
728
+ if (!inTable) out.push(line);
729
+ }
730
+ return out.join("\n");
731
+ }
732
+
654
733
  // ─── Agent rules ──────────────────────────────────────────────────────────
655
734
  //
656
735
  // In addition to MCP tool descriptions, we install a short prose "rule"
@@ -893,6 +972,7 @@ function parseClient(args) {
893
972
  if (args[i] === "--code" || args[i] === "--claude-code")
894
973
  return "claude-code";
895
974
  if (args[i] === "--cursor") return "cursor";
975
+ if (args[i] === "--codex") return "codex";
896
976
  }
897
977
  return null;
898
978
  }
@@ -903,6 +983,7 @@ function prettyClient(client) {
903
983
  "claude-desktop": "Claude Desktop",
904
984
  "claude-code": "Claude Code",
905
985
  cursor: "Cursor",
986
+ codex: "Codex",
906
987
  }[client] || client
907
988
  );
908
989
  }
@@ -911,8 +992,8 @@ function printHelp() {
911
992
  console.log(`cpyany — install the cpyany MCP server in your AI client.
912
993
 
913
994
  Usage:
914
- cpyany setup [--client claude-code|claude-desktop|cursor]
915
- cpyany remove [--client claude-code|claude-desktop|cursor]
995
+ cpyany setup [--client claude-code|claude-desktop|cursor|codex]
996
+ cpyany remove [--client claude-code|claude-desktop|cursor|codex]
916
997
  cpyany wait <ping_id> [--timeout <seconds>]
917
998
  # block until the cpyany test has results (exit 0 = news,
918
999
  # 2 = timed out). Run it in the background after filing
@@ -922,8 +1003,10 @@ Usage:
922
1003
  cpyany version
923
1004
 
924
1005
  Without --client, setup auto-detects every supported AI client installed on
925
- this machine (Claude Code, Claude Desktop, Cursor) and patches all of their
926
- configs from a single OAuth flow. Use --client to restrict to one.
1006
+ this machine (Claude Code, Claude Desktop, Cursor, Codex) and patches all of
1007
+ their configs from a single OAuth flow. Use --client to restrict to one.
1008
+ Re-running setup reuses this machine's login, so adding a client later is
1009
+ just \`cpyany setup --client <name>\`.
927
1010
 
928
1011
  Setup opens your browser to ${APP_URL}/cli-auth, generates a fresh bearer
929
1012
  token after you sign in, and writes it into each client's MCP config.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cpyany",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "Install cpyany so coding agents can copy websites, pages, and elements from references.",
5
5
  "type": "module",
6
6
  "main": "index.mjs",