@sprid/cli 0.1.7 → 0.1.9

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/CHANGELOG.md CHANGED
@@ -3,6 +3,29 @@
3
3
  sprid follows semver: a breaking change to a command's arguments, its output
4
4
  shape or its exit codes is a major release.
5
5
 
6
+ ## 0.1.9 - 2026-09-25
7
+
8
+ - `sprid connect github --repo owner/repo` connects GitHub repository traffic
9
+ (views, clones, referrers) to an app, so Sprid keeps the history past
10
+ GitHub's 14 days. Repeat `--repo` or separate with commas.
11
+ - Telemetry now includes an anonymous daily install ping: a random id made on
12
+ this machine, the surface the skills came through, versions, OS, CI and
13
+ whether a login exists. No token, account or workspace. The notice is shown
14
+ again before the first ping; `sprid telemetry off` and `DO_NOT_TRACK=1` stop it.
15
+
16
+ - `sprid doctor --plugin <skills-root>` also checks the installed Sprid skills
17
+ against the latest pushed release and prints the update command for the host
18
+ that installed them (Claude Code, Codex or a skills folder). With
19
+ `sprid update --auto on`, `--apply-updates` runs that update too.
20
+ - `sprid doctor --json` reports `autoDecided`: whether anyone has answered
21
+ `--auto on|off` for this installation, so an agent asks once.
22
+
23
+ ## 0.1.8 - 2026-09-25
24
+
25
+ - `sprid screenshots` draws a device that looks like one: a metal band with side
26
+ buttons, a thin bezel, and the camera each platform has. `frameImages` in the
27
+ config swaps in device artwork you hold a licence for (`@sprid/shots` 0.1.2).
28
+
6
29
  ## 0.1.7 - 2026-09-24
7
30
 
8
31
  - `sprid connect meta-ads --account <slug>` connects the Meta ad account Sprid
package/README.md CHANGED
@@ -227,6 +227,14 @@ and a failure is ignored. Turn it off with `sprid telemetry off` (saved in
227
227
  `~/.sprid/telemetry.json`), or for one shell with `DO_NOT_TRACK=1` or
228
228
  `SPRID_TELEMETRY=0`; `help`, `version`, `completion` and `mcp` never send.
229
229
 
230
+ Install ping: once a day per surface, signed in or not, one anonymous request to
231
+ `POST /api/installs/ping` carrying a random id created in `~/.sprid/telemetry.json`,
232
+ the surface (`cli`, or `claude`, `codex` or `skills` when `sprid doctor --plugin`
233
+ reports how the skills were installed), the CLI and skills versions, the OS and
234
+ Node versions, whether `CI` is set and whether a login exists. No token, account
235
+ or workspace. It is how an install that never signs in is counted. Same notice,
236
+ same switches.
237
+
230
238
  Credentials stay out of normal output. Build tools can print their own logs; do
231
239
  not include secrets in custom build commands.
232
240
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprid/cli",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "The Sprid command line: connect your app, review results, prepare posts and store screenshots, and release mobile apps.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
@@ -38,7 +38,7 @@
38
38
  "node": ">=18"
39
39
  },
40
40
  "dependencies": {
41
- "@sprid/shots": "0.1.1",
41
+ "@sprid/shots": "0.1.2",
42
42
  "@sprid/release": "0.1.2"
43
43
  },
44
44
  "publishConfig": {
package/src/args.mjs CHANGED
@@ -9,6 +9,7 @@ export const VALUE_FLAGS = new Set([
9
9
  'id', 'request-id',
10
10
  "api",
11
11
  "auto",
12
+ "plugin",
12
13
  "file",
13
14
  "task",
14
15
  "payload",
@@ -60,6 +60,9 @@ export const SENSOR_SERVICES = [
60
60
  "lemonsqueezy",
61
61
  "paddle",
62
62
  "cloudflare",
63
+ // Repository traffic: views, clones, referrers. GitHub keeps 14 days; Sprid
64
+ // copies it daily so the history survives.
65
+ "github",
63
66
  ];
64
67
  /** Model / generation keys held per WORKSPACE (BYOK), not per App Profile. */
65
68
  export const PROVIDERS = ["anthropic", "openai", "fal", "google", "replicate"];
@@ -79,6 +82,7 @@ export const SENSOR_KEYS = {
79
82
  lemonsqueezy: ["lemonSqueezyApiKey"],
80
83
  paddle: ["paddleApiKey"],
81
84
  cloudflare: ["cloudflareApiToken"],
85
+ github: ["githubToken"],
82
86
  };
83
87
 
84
88
  const SENSOR_LABEL = {
@@ -95,6 +99,7 @@ const SENSOR_LABEL = {
95
99
  lemonsqueezy: "Lemon Squeezy",
96
100
  paddle: "Paddle",
97
101
  cloudflare: "Cloudflare",
102
+ github: "GitHub",
98
103
  };
99
104
 
100
105
  const PENDING_POLL_MS = 3000;
@@ -423,6 +428,17 @@ export function sensorPatch(service, flags, cwd, pasted) {
423
428
  }
424
429
  case "cloudflare":
425
430
  return { cloudflareZoneId: need("zone"), secrets: { cloudflareApiToken: secret("token") } };
431
+ case "github": {
432
+ // `--repo a/b,c/d` (or the flag repeated). The list REPLACES the one on
433
+ // the profile, so name every repo. A pasted github.com URL is accepted;
434
+ // the server normalises and validates each entry again.
435
+ const raw = [flags.repo].flat().filter((v) => v && v !== true).flatMap((v) => String(v).split(","));
436
+ const repos = raw.map((r) => r.trim()).filter(Boolean);
437
+ if (!repos.length) throw new UsageError("sprid connect github needs --repo owner/repo (comma-separate several)");
438
+ const bad = repos.find((r) => !/^(?:(?:https?:\/\/)?(?:www\.)?github\.com\/)?[A-Za-z0-9-]+\/[A-Za-z0-9._-]+(?:\.git)?\/?$/.test(r));
439
+ if (bad) throw new UsageError(`--repo takes owner/repo, not "${bad}".`);
440
+ return { githubRepos: repos, secrets: { githubToken: secret("key") } };
441
+ }
426
442
  default:
427
443
  throw new UsageError(`Unknown key type "${service}"`);
428
444
  }
@@ -1,11 +1,12 @@
1
- import { checkVersion, detectInstallation, autoEnabled, setAuto, performUpdate, installationCommand } from '../updates.mjs';
1
+ import { checkVersion, detectInstallation, autoEnabled, autoDecided, setAuto, performUpdate, installationCommand } from '../updates.mjs';
2
2
  import { UsageError } from '../args.mjs';
3
+ import { checkPlugin, updatePlugin } from '../plugin-updates.mjs';
3
4
 
4
5
  function validate(ctx, allowed) {
5
6
  if (ctx.positionals.length) throw new UsageError('Unexpected argument. Use sprid help for update syntax.');
6
7
  for (const [key, value] of Object.entries(ctx.flags)) {
7
8
  if (!allowed.includes(key)) throw new UsageError(`Unknown option --${key}. No update attempted.`);
8
- if (key !== 'auto' && value !== true) throw new UsageError(`--${key} takes no value. No update attempted.`);
9
+ if (key !== 'auto' && key !== 'plugin' && value !== true) throw new UsageError(`--${key} takes no value. No update attempted.`);
9
10
  }
10
11
  }
11
12
 
@@ -15,7 +16,7 @@ async function report(ctx, force = false) {
15
16
  checkVersion({ ...options, current: ctx.version, offline: Boolean(ctx.flags.offline), force }),
16
17
  detectInstallation(options),
17
18
  ]);
18
- return { ...version, installation, auto: autoEnabled(installation, ctx.env),
19
+ return { ...version, installation, auto: autoEnabled(installation, ctx.env), autoDecided: autoDecided(installation, ctx.env),
19
20
  updateCommand: installation.kind === 'unsupported' ? null : `sprid update${version.requiresApproval ? ' --yes' : ''}`,
20
21
  install: installationCommand(installation, version.recommended) };
21
22
  }
@@ -27,39 +28,57 @@ function output(ctx, result) {
27
28
  else if (result.updateAvailable) ctx.warn(`${result.requiresApproval ? 'Potentially breaking update. ' : ''}${result.updateCommand || result.installation.reason}`);
28
29
  else if (result.installation.kind === 'unsupported') ctx.print(result.installation.reason);
29
30
  if (result.skipped) ctx.print(`Update skipped: ${result.skipped}.`);
31
+ const plugin = result.plugin;
32
+ if (plugin?.updated) ctx.print(`Skills updated. ${plugin.reload}`);
33
+ else if (plugin?.updateAvailable) ctx.warn(`Newer Sprid skills: ${plugin.latest}${plugin.current ? ` (running ${plugin.current})` : ''}. ${plugin.updateCommand ?? plugin.reason}`);
34
+ else if (plugin) ctx.print(plugin.check === 'verified' ? `Skills ${plugin.current} are current.` : 'Skills version check unavailable.');
30
35
  }
31
36
  export async function doctor(ctx) {
32
- validate(ctx, ['json', 'offline', 'apply-updates']);
37
+ validate(ctx, ['json', 'offline', 'apply-updates', 'plugin']);
38
+ if (ctx.flags.plugin === true) throw new UsageError('--plugin needs the skills root, two directories above a SKILL.md.');
33
39
  const result = await report(ctx);
40
+ let plugin;
41
+ if (ctx.flags.plugin) {
42
+ const options = { env: ctx.env, ...ctx.updateOptions };
43
+ plugin = await checkPlugin({ root: ctx.flags.plugin, offline: Boolean(ctx.flags.offline), ...options });
44
+ // One opt-in keeps all of Sprid current: the CLI and the skills that drive it.
45
+ if (ctx.flags['apply-updates'] && result.auto && plugin.check === 'verified' && plugin.updateAvailable && plugin.updateCommand) {
46
+ plugin = { ...plugin, ...await updatePlugin(ctx.flags.plugin, options) };
47
+ }
48
+ }
49
+ // The install ping names the host the skills came through (see telemetry.mjs).
50
+ if (plugin) ctx.pluginInfo = { host: plugin.host, current: plugin.current };
51
+ // Every path below reports through say(), so the skills result rides along.
52
+ const say = plugin ? (c, r) => output(c, { ...r, plugin }) : output;
34
53
  // This flag marks an explicit between-jobs boundary. Ordinary commands never install.
35
54
  if (ctx.flags['apply-updates'] && result.auto && result.check === 'verified' && result.compatible && result.installation.kind !== 'unsupported') {
36
- const { 'apply-updates': _, ...flags } = ctx.flags;
37
- return update({ ...ctx, flags: { ...flags, 'auto-run': true } });
55
+ const { 'apply-updates': _, plugin: _plugin, ...flags } = ctx.flags;
56
+ return update({ ...ctx, flags: { ...flags, 'auto-run': true } }, say);
38
57
  }
39
- output(ctx, result);
58
+ say(ctx, result);
40
59
  return 0;
41
60
  }
42
- export async function update(ctx) {
61
+ export async function update(ctx, say = output) {
43
62
  validate(ctx, ['json', 'offline', 'auto', 'auto-run', 'check', 'yes']);
44
63
  if (ctx.flags.auto !== undefined) {
45
64
  if (!['on', 'off'].includes(ctx.flags.auto)) throw new UsageError('Use sprid update --auto on|off.');
46
65
  if (ctx.flags['auto-run'] || ctx.flags.check) throw new UsageError('--auto cannot be combined with --auto-run or --check.');
47
66
  const installation = await detectInstallation({ env: ctx.env, ...ctx.updateOptions });
48
67
  setAuto(installation, ctx.flags.auto === 'on', ctx.env);
49
- output(ctx, { configured: ctx.flags.auto === 'on', installation });
68
+ say(ctx, { configured: ctx.flags.auto === 'on', installation });
50
69
  return 0;
51
70
  }
52
71
  const result = await report(ctx, !ctx.flags.check);
53
- if (ctx.flags.check) { output(ctx, result); return 0; }
72
+ if (ctx.flags.check) { say(ctx, result); return 0; }
54
73
  if (ctx.flags['auto-run'] && (!result.auto || !result.compatible || result.check !== 'verified' || result.installation.kind === 'unsupported')) {
55
- output(ctx, { ...result, skipped: !result.auto ? 'automatic updates are off' : 'no verified compatible update' }); return 0;
74
+ say(ctx, { ...result, skipped: !result.auto ? 'automatic updates are off' : 'no verified compatible update' }); return 0;
56
75
  }
57
76
  if (result.check !== 'verified') throw new Error('Cannot verify the recommended version. No update attempted; retry when the registry is available.');
58
- if (!result.updateAvailable) { output(ctx, result); return 0; }
77
+ if (!result.updateAvailable) { say(ctx, result); return 0; }
59
78
  if (result.installation.kind === 'unsupported') throw new UsageError(result.installation.reason);
60
79
  if (result.requiresApproval && !ctx.flags.yes) throw new UsageError('Potentially breaking update. Review the release, then run sprid update --yes to approve it.');
61
80
  const updated = await performUpdate(result.installation, result.recommended, { env: ctx.env, ...ctx.updateOptions });
62
- output(ctx, { ...result, ...updated });
81
+ say(ctx, { ...result, ...updated });
63
82
  return 0;
64
83
  }
65
84
 
@@ -36,9 +36,9 @@ export const COMMAND_GROUPS = [
36
36
  ['post', 'sprid post handoff <postId>', 'Print the prompt to paste into an agent: what the post currently is, which verbs to call, and an empty line for the ask. Same string the editor\'s "Hand off to agent" button copies.'],
37
37
  ] },
38
38
  { title: 'Installation and updates', entries: [
39
- ['doctor', 'sprid doctor [--json] [--offline] [--apply-updates]', 'Check the running CLI against the npm latest release. --apply-updates is a between-jobs boundary and installs compatible updates only after explicit opt-in.'],
39
+ ['doctor', 'sprid doctor [--json] [--offline] [--apply-updates] [--plugin <skills-root>]', 'Check the running CLI against the npm latest release, and with --plugin the installed Sprid skills against the latest pushed. --apply-updates is a between-jobs boundary and installs compatible updates, CLI and skills, only after explicit opt-in (sprid update --auto on).'],
40
40
  ['update', 'sprid update [--check] [--yes]', 'Update the running installation and verify its new version. --check only reports; potentially breaking releases require --yes. Project installs use their own package manager and lockfile.'],
41
- ['telemetry', 'sprid telemetry [status|on|off]', 'Show or change usage telemetry. When on, each command sends its name, exit code, duration and versions to your Sprid account, never arguments or file contents. DO_NOT_TRACK=1 or SPRID_TELEMETRY=0 turns it off for one shell.'],
41
+ ['telemetry', 'sprid telemetry [status|on|off]', 'Show or change usage telemetry. When on, the CLI sends an anonymous install id with versions once a day, and when signed in each command sends its name, exit code, duration and versions to your Sprid account, never arguments or file contents. DO_NOT_TRACK=1 or SPRID_TELEMETRY=0 turns it off for one shell.'],
42
42
  ['update', 'sprid update --auto on|off', 'Opt this installation into or out of compatible updates at job boundaries. Default off. Agents must have user approval before enabling.'],
43
43
  ] },
44
44
  { title: 'Store assets and releases', entries: [
@@ -73,6 +73,7 @@ export const COMMAND_GROUPS = [
73
73
  ['connect', 'sprid connect google-ads --account <slug> [--ad-account <customer id>]', 'Connect the Google Ads account that promotes YouTube videos. When the Google login reaches several accounts, Sprid lists their ids; run it again with --ad-account.'],
74
74
  ['connect', 'sprid connect tiktok-ads --account <slug> [--ad-account <advertiser id>]', 'Connect the TikTok ad account that promotes TikTok posts as Spark Ads. When the login reaches several advertisers, Sprid lists their ids; run it again with --ad-account.'],
75
75
  ['connect', 'sprid connect tiktok-comments --account <slug>', 'Let the Inbox read and answer comments on your TikTok posts. Sign in with the TikTok account that publishes them; this is separate from TikTok publishing and from TikTok Ads.'],
76
+ ['connect', 'sprid connect github --app <slug> --key-from-clipboard --repo <owner/repo>[,<owner/repo>]', 'Read a repository’s views, clones, referrers and stars. GitHub keeps 14 days of traffic, so Sprid copies it daily and the history starts the day you connect. --repo replaces the list, so name every repo.'],
76
77
  ['connect', 'sprid connect ga4|plausible|umami --app <slug> --key <file> … [--use]', 'PostHog, Google Analytics, Plausible and Umami all count the same website visits, so Sprid reads one of them and never adds them together. --use makes this one the source the traffic card reports from.'],
77
78
  ['pinterest', 'sprid pinterest boards --account <slug> [--channel <id>] [--bookmark <token>]', 'List boards and sections for the exact connected Pinterest channel; a returned bookmark reads the next page.'],
78
79
  ['pinterest', 'sprid pinterest create --account <slug> --name <name> [--description <text>] [--privacy public|secret]', 'Create a board in the exact connected Pinterest account. Older connections must reconnect once to grant board creation permission.'],
@@ -155,7 +156,7 @@ export const CLI_NOTES = [
155
156
  'Use -w/--workspace <slug|id> for one command, or sprid use to remember your choice.',
156
157
  'sprid login stores its token in ~/.sprid/credentials.json. SPRID_PAT overrides it.',
157
158
  'SPRID_URL overrides the API address. SPRID_APP_URL overrides the web app address.',
158
- 'Usage telemetry (command, exit code, duration, versions) goes to your Sprid account when signed in. sprid telemetry off, DO_NOT_TRACK=1 or SPRID_TELEMETRY=0 stops it.',
159
+ 'Usage telemetry: an anonymous daily install id with versions, and when signed in each command, exit code and duration to your Sprid account. sprid telemetry off, DO_NOT_TRACK=1 or SPRID_TELEMETRY=0 stops it.',
159
160
  ];
160
161
 
161
162
  export const COMMAND_EXAMPLES = {
@@ -21,7 +21,8 @@ export const GUIDE_GROUPS = [
21
21
  "polar",
22
22
  "lemonsqueezy",
23
23
  "paddle",
24
- "cloudflare"
24
+ "cloudflare",
25
+ "github"
25
26
  ]
26
27
  },
27
28
  {
@@ -924,6 +925,53 @@ export const GUIDES = [
924
925
  "markdown": "# Cloudflare (optional)\n\n**What Sprid does with this:** Read traffic reports for your website.\n\n## You need\n\nAccess to your domain’s Cloudflare account and permission to create a token. Enable **Web Analytics** for visitor reports; basic request counts include bots.\n\n## Click path (dash.cloudflare.com)\n\n1. Open [Cloudflare](https://dash.cloudflare.com) → profile icon → **My Profile → API Tokens**.\n2. **Create Token → Custom token → Get started**, named `Sprid`.\n3. **Permissions**:\n - **Zone → Analytics → Read**\n - **Account → Account Analytics → Read**\n4. **Zone Resources → Include → Specific zone**: your domain.\n5. **Account Resources → Include**: your account.\n6. Leave **Client IP Address Filtering** and **TTL** empty. **Continue to summary → Create Token**.\n7. Copy the token and leave it on your clipboard. It is shown once.\n\nFor a connection that survives you leaving the team, create an account-owned token instead under **Manage Account → Account API Tokens**, with the same permissions.\n\n## Zone id\n\nYour domain → **Overview** → **API** card: copy **Zone ID** and **Account ID**. The Zone ID goes in the command; ask your agent to save the Account ID in your Sprid app profile.\n\n## Then run\n\n```\nsprid connect cloudflare --key-from-clipboard --zone 0123456789abcdef0123456789abcdef\n```\n\nUse your own Zone ID. Keep the token out of chat.\n\nSprid reads the token from your clipboard, saves it and clears the clipboard, so it never shows on screen or lands in a file. Working with an agent? Tell it the token is copied and it runs this for you. Typing it yourself, copy the token last: paste the command into your terminal first, then copy the token, then press Enter. If the clipboard still holds the command, Sprid refuses it; copy the token and run it again. Without clipboard access (a remote shell), save the token to a file and pass `--token <file>` instead.\n\n## How to check it worked\n\nRun `sprid status`, then ask your agent: “Check that Sprid can read recent Cloudflare traffic for this domain.” The check names the domain and says whether visitor reports or only request counts are available.\n\n## If it fails\n\n- **Access denied:** **API Tokens → ⋯ → Edit**. Check both Read permissions and the selected domain and account.\n- **Domain not found:** use the Zone ID, not the domain name.\n- **Visitor reports are empty:** enable **Analytics & Logs → Web Analytics** and check the tracking snippet is on your site.\n\n## Investigate with this connection\n\nReads `rum` and `http`. RUM is pinned to the saved account and hostname; beacon traffic is sampled and not verified human.\n\n```sh\nsprid marketing-review capabilities --app <slug> --source cloudflare --json\n```\n\nSee [connected queries](https://sprid.studio/docs/queries).\n\n## Sources\n\n- [Create an API token](https://developers.cloudflare.com/fundamentals/api/get-started/create-token/)\n- [GraphQL Analytics API token permissions](https://developers.cloudflare.com/analytics/graphql-api/getting-started/authentication/api-token-auth/)\n- [Find zone and account ids](https://developers.cloudflare.com/fundamentals/account/find-account-and-zone-ids/)\n- [Verify a token](https://developers.cloudflare.com/api/resources/user/subresources/tokens/methods/verify/)\n",
925
926
  "revision": "c34f4dd6420981c8"
926
927
  },
928
+ {
929
+ "id": "github",
930
+ "title": "GitHub",
931
+ "summary": "See how many people view and clone your repository each day, where they came from, which pages they read, and how stars, forks and watchers move.",
932
+ "command": "sprid connect github --key-from-clipboard --repo your-org/your-repo",
933
+ "url": "https://sprid.studio/docs/connect/github",
934
+ "sections": [
935
+ {
936
+ "id": "you-need",
937
+ "title": "You need",
938
+ "kind": "requirements",
939
+ "markdown": "A **token that can read traffic** on the repository, and **push access** to it yourself. GitHub shows traffic only to people who can write to a repo, whatever the token says.\n\nChecked against GitHub's permissions reference on 2026-09-25: all four traffic endpoints need the fine-grained permission **Repository permissions → Administration: Read-only**. A classic token needs the `repo` scope."
940
+ },
941
+ {
942
+ "id": "click-path",
943
+ "title": "Click path",
944
+ "kind": "steps",
945
+ "markdown": "1. Open [GitHub → Settings → Developer settings → Personal access tokens → Fine-grained tokens](https://github.com/settings/personal-access-tokens) and choose **Generate new token**.\n2. Name it `sprid`. Pick an expiry date; Sprid tells you when the token stops working.\n3. **Resource owner:** the account or organization that owns the repo.\n4. **Repository access → Only select repositories**, and pick the repos you want measured.\n5. **Permissions → Repository permissions → Administration → Read-only.** Metadata switches itself to Read-only; leave everything else off.\n6. **Generate token**, copy it and leave it on your clipboard. GitHub shows it once.\n\nIn an organization, an owner may have to approve the token before it works. Until they do, the read fails with the permission message below."
946
+ },
947
+ {
948
+ "id": "then-run",
949
+ "title": "Then run",
950
+ "kind": "command",
951
+ "markdown": "```\nsprid connect github --key-from-clipboard --repo your-org/your-repo\n```\n\nSeveral repos: `--repo your-org/app,your-org/site`. The list replaces the one already saved, so name every repo you want kept. A pasted `github.com` URL works too.\n\nSprid reads the key from your clipboard, saves it and clears the clipboard, so it never shows on screen or lands in a file. Working with an agent? Tell it the token is copied and it runs this for you. Typing it yourself, copy the token last: paste the command into your terminal first, then copy the token, then press Enter. If the clipboard still holds the command, Sprid refuses it; copy the token and run it again. Without clipboard access (a remote shell), save the token to a file and pass `--key <file>` instead."
952
+ },
953
+ {
954
+ "id": "how-to-check-it-worked",
955
+ "title": "How to check it worked",
956
+ "kind": "verify",
957
+ "markdown": "Run `sprid status`. The first read lands within the hour; then ask your agent: “Show this app’s GitHub views and clones for the last 7 days through Sprid.” A saved token confirms setup; only the read confirms access."
958
+ },
959
+ {
960
+ "id": "if-it-fails",
961
+ "title": "If it fails",
962
+ "kind": "troubleshooting",
963
+ "markdown": "- **Rejected the token:** it expired, was revoked or was mistyped. Fine-grained tokens expire on the date you set. Create a new one and run the command again.\n- **Cannot read traffic:** the token is missing **Administration: Read-only**, or you have read access to the repo but not push access. In an organization, check whether the token is waiting for an owner's approval.\n- **Cannot see that repository:** the name is wrong, or the repo is private and not among the token's selected repositories. GitHub answers both the same way.\n- **Rate limited:** another tool is using the same token heavily. Nothing is lost: GitHub still holds 14 days, and tomorrow's read catches up."
964
+ },
965
+ {
966
+ "id": "what-sprid-can-and-cannot-read-here",
967
+ "title": "What Sprid can and cannot read here",
968
+ "kind": "detail",
969
+ "markdown": "Views and clones per day, with GitHub's own daily unique counts; the top 10 referring sites and the top 10 pages over GitHub's 14-day window; stars, forks and watchers.\n\nViews and clones add up across days and repos. Unique visitors do not: someone who cloned on three days is three daily uniques but one unique over the window. So Sprid reports period totals as counts, and shows unique people only as GitHub's own 14-day figure.\n\nA clone is not an install. CI runs, mirrors and bots clone too, and one install can clone more than once. Treat clones as an upper bound that moves with real interest."
970
+ }
971
+ ],
972
+ "markdown": "# GitHub\n\n**What Sprid does with this:** See how many people view and clone your repository each day, where they came from, which pages they read, and how stars, forks and watchers move.\n\nGitHub only keeps traffic for 14 days, then deletes it. Sprid reads the window every day and keeps it, so your history starts the day you connect and never runs out.\n\n## You need\n\nA **token that can read traffic** on the repository, and **push access** to it yourself. GitHub shows traffic only to people who can write to a repo, whatever the token says.\n\nChecked against GitHub's permissions reference on 2026-09-25: all four traffic endpoints need the fine-grained permission **Repository permissions → Administration: Read-only**. A classic token needs the `repo` scope.\n\n## Click path\n\n1. Open [GitHub → Settings → Developer settings → Personal access tokens → Fine-grained tokens](https://github.com/settings/personal-access-tokens) and choose **Generate new token**.\n2. Name it `sprid`. Pick an expiry date; Sprid tells you when the token stops working.\n3. **Resource owner:** the account or organization that owns the repo.\n4. **Repository access → Only select repositories**, and pick the repos you want measured.\n5. **Permissions → Repository permissions → Administration → Read-only.** Metadata switches itself to Read-only; leave everything else off.\n6. **Generate token**, copy it and leave it on your clipboard. GitHub shows it once.\n\nIn an organization, an owner may have to approve the token before it works. Until they do, the read fails with the permission message below.\n\n## Then run\n\n```\nsprid connect github --key-from-clipboard --repo your-org/your-repo\n```\n\nSeveral repos: `--repo your-org/app,your-org/site`. The list replaces the one already saved, so name every repo you want kept. A pasted `github.com` URL works too.\n\nSprid reads the key from your clipboard, saves it and clears the clipboard, so it never shows on screen or lands in a file. Working with an agent? Tell it the token is copied and it runs this for you. Typing it yourself, copy the token last: paste the command into your terminal first, then copy the token, then press Enter. If the clipboard still holds the command, Sprid refuses it; copy the token and run it again. Without clipboard access (a remote shell), save the token to a file and pass `--key <file>` instead.\n\n## How to check it worked\n\nRun `sprid status`. The first read lands within the hour; then ask your agent: “Show this app’s GitHub views and clones for the last 7 days through Sprid.” A saved token confirms setup; only the read confirms access.\n\n## If it fails\n\n- **Rejected the token:** it expired, was revoked or was mistyped. Fine-grained tokens expire on the date you set. Create a new one and run the command again.\n- **Cannot read traffic:** the token is missing **Administration: Read-only**, or you have read access to the repo but not push access. In an organization, check whether the token is waiting for an owner's approval.\n- **Cannot see that repository:** the name is wrong, or the repo is private and not among the token's selected repositories. GitHub answers both the same way.\n- **Rate limited:** another tool is using the same token heavily. Nothing is lost: GitHub still holds 14 days, and tomorrow's read catches up.\n\n## What Sprid can and cannot read here\n\nViews and clones per day, with GitHub's own daily unique counts; the top 10 referring sites and the top 10 pages over GitHub's 14-day window; stars, forks and watchers.\n\nViews and clones add up across days and repos. Unique visitors do not: someone who cloned on three days is three daily uniques but one unique over the window. So Sprid reports period totals as counts, and shows unique people only as GitHub's own 14-day figure.\n\nA clone is not an install. CI runs, mirrors and bots clone too, and one install can clone more than once. Treat clones as an upper bound that moves with real interest.\n",
973
+ "revision": "f6e2f390c58af602"
974
+ },
927
975
  {
928
976
  "id": "instagram",
929
977
  "title": "Instagram",
@@ -1568,8 +1616,8 @@ export const CONTENT_GUIDES = [
1568
1616
  "title": "Check whether a traffic spike is real",
1569
1617
  "summary": "Tells you how to judge a sudden jump in website visitors, and how to remove a scraper from your numbers without removing customers. Read it before you act on a spike or explain one.",
1570
1618
  "url": "https://sprid.studio/docs/traffic",
1571
- "markdown": "# Check whether a traffic spike is real\n\n**What this guide does:** Tells you how to judge a sudden jump in website\nvisitors, and how to remove a scraper from your numbers without removing\ncustomers. Read it before you act on a spike or explain one.\n\nA spike is not proof of a bot, and a quiet baseline is not proof of people.\nGet it wrong one way and you chase a channel that never existed; the other way\nand you drop real readers from your own numbers. Use the four reads below, in\norder, and keep any rule as narrow as your evidence.\n\nSprid flags candidates. When one day carries at least 35% of a 30-day period\nand runs at ten times the median day, `next` shows **Review that day** with the\ndate, the multiple and that day’s pageviews per visitor against the rest of the\nmonth. It is a prompt, not a verdict: a daily series can’t tell a crawl from a\ngood day.\n\n## Four reads, in order\n\n**1. Find the hours, not the day.** Split the suspect day by hour. Marketing\narrives across a day and decays over several. A scrape is a block: a flat rate\nfor a few hours, then nothing. If the day’s total sits in four consecutive\nhours with ordinary hours either side, it is one client, not an audience.\n\n**2. Divide pageviews by visitors, but don’t trust it alone.** A browser with no\ncookie is a new visitor on every request, so a crawl that reads each page once\nshows a huge visitor count at 1.0 pageviews each. A crawler that hits each URL\nseveral times doesn’t: the September 2026 scrape ran at **1.60 pageviews a\nvisitor against a baseline of 1.44**. A flat 1.0 is suspicious; a healthy ratio\nproves nothing.\n\n**3. Ask for whole groups, not separate dimensions.** `review_traffic` returns\ncompound groups (browser, version, OS, screen, timezone, referrer, country\ntogether), because adding up separate dimension counts double-counts everyone.\nOne group carrying most of a day is the finding. Twelve groups sharing it evenly\nis not, unless they agree on something (below).\n\n**4. Look at which pages were hit.** A crawl walks your sitemap: every locale\nand item, a few hits each. Real discovery is lopsided: a few pages that rank or\nwere linked take most of the traffic.\n\n## What the fingerprint looks like\n\nRead the fields that are hardest to fake:\n\n- **Timezone against geography** is the strongest tell. A browser on\n `Asia/Shanghai` while its IPs sit in the US, Singapore and Germany is one\n operator on rented addresses. The addresses rotate; the machine’s clock\n doesn’t.\n- **An odd viewport** (`800x600`, `1512x982`, `1280x720`) beats the user agent,\n which is the field they bother to spoof. Expect a plausible current Chrome on\n macOS with a screen no customer has.\n- **A blend is a signature too.** One wave rotated Chrome 118-120, Edge 119-120\n and Firefox 120-121 so no browser stood out, all on one viewport, timezone and\n referrer. What they varied is noise; what they forgot to vary is the rule.\n- **Referrer `$direct`** on everything, with no search or social.\n- **Country is not a tell.** The same wave comes from a dozen countries. A\n country rule removes customers and keeps the scraper.\n\n## Check the comparison period too\n\nA percentage change has two periods, and nobody looks at the older one. On one\napp in September 2026 the same thirty days read **+252.5%** before review,\n**-62.6%** with only the obvious spike excluded, and **-17.6%** once two earlier\nwaves in the comparison period were excluded too. The middle answer was wrong\nand the most convincing, because it already looked corrected.\n\nSo when you find one wave, look for others before saving anything. Query the\nwhole history for its marker (the timezone, the viewport) by day. Waves have\nedges: 5 to 30 visitors a day in the background and several hundred on wave\ndays tells you where the rule starts and that the rest is ordinary.\n\n## Two more traps\n\n**Raw provider data is not what Sprid counted.** Web visitors already exclude\nevents with no browser, crawler and headless user agents, reported bots, and\nanything marked internal or test. A wave with a null browser never reached your\ntotal, so a raw query including it won’t match the card. Compare like with like\nor you exclude the same traffic twice.\n\n**Bound a rule by how ordinary its conditions are.** `1920x1080` is a real\nscreen many customers use, so a rule on it gets exactly the wave’s dates. A\nviewport nobody has can carry a longer window. The date range is the safety\nmargin for a condition that isn’t distinctive on its own.\n\n## Save the exclusion\n\nPreview, then save. `review_traffic` shows visitor counts before and after for\nthe period **and the one before it**, which is the number you want to see move.\n\n```json\n{\n \"id\": \"scrape-2026-09-18\",\n \"reason\": \"Full-site crawl 04:00-08:00 UTC: Asia/Shanghai timezone, 800x600 viewport, direct referrer, IPs across US/SG/DE. 39,958 of the day's 40,476 visitors, every locale and item page hit at most 5 times with no cookie reuse.\",\n \"start\": \"2026-09-18\",\n \"end\": \"2026-09-19\",\n \"enabled\": true,\n \"conditions\": [\n { \"field\": \"timezone\", \"value\": \"Asia/Shanghai\" },\n { \"field\": \"screenWidth\", \"value\": \"800\" },\n { \"field\": \"screenHeight\", \"value\": \"600\" }\n ]\n}\n```\n\n- End dates are exclusive and UTC.\n- Conditions in a rule are AND; separate rules are OR.\n- Fields: `browser`, `browserVersion`, `os`, `osVersion`, `screenWidth`,\n `screenHeight`, `timezone`, `referrer`, `country`. Values are exact strings;\n a missing property never matches.\n- Write `reason` for someone reading it in a year: the window, the evidence and\n how much it removes.\n\nSave through `upsert_app_profile` or `PATCH /api/app-profiles/:id` under\n`posthogConfig.trafficExclusions`, keeping the rest of the configuration. Then\n**log the change as an event** with the areas it affects and why, or next\nmonth’s drop in your graph becomes a new mystery.\n\n## What an exclusion doesn’t touch\n\nYour provider’s data is never changed or deleted; Sprid filters when it reads.\nApp activity, registrations and revenue are unaffected, since a scraper doesn’t\nsign up. Raw connected queries and the marketing-review event inventory stay\nunfiltered on purpose, as the evidence to check the rule against. **Restore this\ntraffic** disables a rule and the original numbers come straight back.\n\n## When not to exclude\n\nA spike with a real referrer and lopsided pages is a link that worked: find it\nbefore you filter it. A shared office machine, an uptime probe and a preview\nrenderer all repeat a fingerprint, and none is worth a rule. A group you can’t\nexplain stays in: an unexplained visitor counted is a smaller error than a\ncustomer removed.\n",
1572
- "revision": "2ee50917e61c94f4"
1619
+ "markdown": "# Check whether a traffic spike is real\n\n**What this guide does:** Tells you how to judge a sudden jump in website\nvisitors, and how to remove a scraper from your numbers without removing\ncustomers. Read it before you act on a spike or explain one.\n\nA spike is not proof of a bot, and a quiet baseline is not proof of people.\nGet it wrong one way and you chase a channel that never existed; the other way\nand you drop real readers from your own numbers. Use the four reads below, in\norder, and keep any rule as narrow as your evidence.\n\nSprid flags candidates. When one day carries at least 35% of a 30-day period\nand runs at ten times the median day, `next` shows **Review that day** with the\ndate, the multiple and that day’s pageviews per visitor against the rest of the\nmonth. Treat it as a reason to look closer: a daily series can’t tell a crawl from a\ngood day.\n\n## Four reads, in order\n\n**1. Find the hours, not the day.** Split the suspect day by hour. Marketing\narrives across a day and decays over several. A scrape is a block: a flat rate\nfor a few hours, then nothing. If the day’s total sits in four consecutive\nhours with ordinary hours either side, it is one client, not an audience.\n\n**2. Divide pageviews by visitors, but don’t trust it alone.** A browser with no\ncookie is a new visitor on every request, so a crawl that reads each page once\nshows a huge visitor count at 1.0 pageviews each. A crawler that hits each URL\nseveral times doesn’t: the September 2026 scrape ran at **1.60 pageviews a\nvisitor against a baseline of 1.44**. A flat 1.0 is suspicious; a healthy ratio\nproves nothing.\n\n**3. Ask for whole groups, not separate dimensions.** `review_traffic` returns\ncompound groups (browser, version, OS, screen, timezone, referrer, country\ntogether), because adding up separate dimension counts double-counts everyone.\nOne group carrying most of a day is the finding. Twelve groups sharing it evenly\nis not, unless they agree on something (below).\n\n**4. Look at which pages were hit.** A crawl walks your sitemap: every locale\nand item, a few hits each. Real discovery is lopsided: a few pages that rank or\nwere linked take most of the traffic.\n\n## What the fingerprint looks like\n\nRead the fields that are hardest to fake:\n\n- **Timezone against geography** is the strongest tell. A browser on\n `Asia/Shanghai` while its IPs sit in the US, Singapore and Germany is one\n operator on rented addresses. The addresses rotate; the machine’s clock\n doesn’t.\n- **An odd viewport** (`800x600`, `1512x982`, `1280x720`) beats the user agent,\n which is the field they bother to spoof. Expect a plausible current Chrome on\n macOS with a screen no customer has.\n- **A blend is a signature too.** One wave rotated Chrome 118-120, Edge 119-120\n and Firefox 120-121 so no browser stood out, all on one viewport, timezone and\n referrer. What they varied is noise; what they forgot to vary is the rule.\n- **Referrer `$direct`** on everything, with no search or social.\n- **Country is not a tell.** The same wave comes from a dozen countries. A\n country rule removes customers and keeps the scraper.\n\n## Check the comparison period too\n\nA percentage change has two periods, and nobody looks at the older one. On one\napp in September 2026 the same thirty days read **+252.5%** before review,\n**-62.6%** with only the obvious spike excluded, and **-17.6%** once two earlier\nwaves in the comparison period were excluded too. The middle answer was wrong\nand the most convincing, because it already looked corrected.\n\nSo when you find one wave, look for others before saving anything. Query the\nwhole history for its marker (the timezone, the viewport) by day. Waves have\nedges: 5 to 30 visitors a day in the background and several hundred on wave\ndays tells you where the rule starts and that the rest is ordinary.\n\n## Two more traps\n\n**Raw provider data is not what Sprid counted.** Web visitors already exclude\nevents with no browser, crawler and headless user agents, reported bots, and\nanything marked internal or test. A wave with a null browser never reached your\ntotal, so a raw query including it won’t match the card. Compare like with like\nor you exclude the same traffic twice.\n\n**Bound a rule by how ordinary its conditions are.** `1920x1080` is a real\nscreen many customers use, so a rule on it gets exactly the wave’s dates. A\nviewport nobody has can carry a longer window. The date range is the safety\nmargin for a condition that isn’t distinctive on its own.\n\n## Save the exclusion\n\nPreview, then save. `review_traffic` shows visitor counts before and after for\nthe period **and the one before it**, which is the number you want to see move.\n\n```json\n{\n \"id\": \"scrape-2026-09-18\",\n \"reason\": \"Full-site crawl 04:00-08:00 UTC: Asia/Shanghai timezone, 800x600 viewport, direct referrer, IPs across US/SG/DE. 39,958 of the day's 40,476 visitors, every locale and item page hit at most 5 times with no cookie reuse.\",\n \"start\": \"2026-09-18\",\n \"end\": \"2026-09-19\",\n \"enabled\": true,\n \"conditions\": [\n { \"field\": \"timezone\", \"value\": \"Asia/Shanghai\" },\n { \"field\": \"screenWidth\", \"value\": \"800\" },\n { \"field\": \"screenHeight\", \"value\": \"600\" }\n ]\n}\n```\n\n- End dates are exclusive and UTC.\n- Conditions in a rule are AND; separate rules are OR.\n- Fields: `browser`, `browserVersion`, `os`, `osVersion`, `screenWidth`,\n `screenHeight`, `timezone`, `referrer`, `country`. Values are exact strings;\n a missing property never matches.\n- Write `reason` for someone reading it in a year: the window, the evidence and\n how much it removes.\n\nSave through `upsert_app_profile` or `PATCH /api/app-profiles/:id` under\n`posthogConfig.trafficExclusions`, keeping the rest of the configuration. Then\n**log the change as an event** with the areas it affects and why, or next\nmonth’s drop in your graph becomes a new mystery.\n\n## What an exclusion doesn’t touch\n\nYour provider’s data is never changed or deleted; Sprid filters when it reads.\nApp activity, registrations and revenue are unaffected, since a scraper doesn’t\nsign up. Raw connected queries and the marketing-review event inventory stay\nunfiltered on purpose, as the evidence to check the rule against. **Restore this\ntraffic** disables a rule and the original numbers come straight back.\n\n## When not to exclude\n\nA spike with a real referrer and lopsided pages is a link that worked: find it\nbefore you filter it. A shared office machine, an uptime probe and a preview\nrenderer all repeat a fingerprint, and none is worth a rule. A group you can’t\nexplain stays in: an unexplained visitor counted is a smaller error than a\ncustomer removed.\n",
1620
+ "revision": "865977c916e965ab"
1573
1621
  },
1574
1622
  {
1575
1623
  "id": "pinterest-content",
@@ -1584,8 +1632,8 @@ export const CONTENT_GUIDES = [
1584
1632
  "title": "Why a post travels",
1585
1633
  "summary": "Explains what Instagram and TikTok actually reward, and turns each mechanic into a rule you can build a post against. Read it before deciding a format, a slide count or a caption length, and when a post that looked good got no reach.",
1586
1634
  "url": "https://sprid.studio/docs/distribution",
1587
- "markdown": "# Why a post travels\n\n**What this guide does:** Explains what Instagram and TikTok actually reward,\nand turns each mechanic into a rule you can build a post against. Read it before\ndeciding a format, a slide count or a caption length, and when a post that\nlooked good got no reach.\n\nWhether a post is *good* and whether it *travels* are two different questions.\nThis one is about the second. Every rule here exists because of a specific\nmechanic, and if the mechanic changes the rule goes with it - so each is written\nwith its reason attached rather than as a commandment.\n\n**Verified against published sources 2026-08-04.** Platform mechanics decay.\nTreat anything here as a claim about a moving system, re-check it quarterly, and\nprefer your own numbers the moment you have them.\n\n## Instagram\n\n**The three named ranking signals are watch time, sends per reach, and likes per\nreach.** Sends are the heaviest, reported at roughly three to five times the\nweight of a like. Sends per reach is specifically the signal that reaches people\nwho do not follow you, which is the only reach a new account can grow on.\n\n**Feed, Reels, Stories and Explore rank separately** and weight those signals\ndifferently. Feed leans on how close you already are to the viewer; Explore on\nengagement velocity and interest match. A post that does well with your existing\nfollowers is not automatically a post that travels.\n\n**Why carousels, specifically:**\n\n- Every swipe is engagement, and dwell time accumulates across the slides.\n Carousels are reported at two to three times the reach of a single image for\n the same content.\n- **The platform re-serves a carousel to people who did not swipe, starting from\n the second slide.** This is the most actionable mechanic available: slide two\n gets an independent second chance to be someone's first impression.\n- Carousels out-save single images by a wide margin, and saves compound, because\n saved posts get resurfaced.\n- Completion - how many people reach the last slide - is what pushes a post out\n of your followers and into Explore.\n\n**Hashtags do not drive distribution.** The platform's own position is that they\ncategorise rather than distribute. Discovery comes from the words in your\ncaption: captions, alt text, bios and on-screen text are indexed and served into\nin-app search. Keyword-rich captions have been measured at around 30% more reach\nthan hashtag-heavy ones. Keep three to five hashtags as labels and spend the\neffort on the prose.\n\n## TikTok, photo mode\n\n- Ranking is swipe-through rate, dwell time and reverse swipes, with completion\n rate as the primary signal.\n- Distribution starts with a **small test batch of roughly 200-500 viewers**,\n mostly followers and people who engage with adjacent content. What that batch\n does decides everything afterwards. This is why the first hour matters, and\n why a weak second slide is fatal rather than merely costly.\n- Saves are weighted and photo posts save well. A carousel with fewer views but\n a high save-and-comment share is doing better than a higher-view post that\n bounces on slide one.\n- Interest-based distribution means an outlier is possible from your first post.\n TikTok is spikier; Instagram grinds.\n\n## What follows for how you build a post\n\n| Rule | Because |\n|---|---|\n| **Slide 2 is a second hook.** It delivers on slide 1 *and* opens a new thread, and it has to work cold | the platform re-serves from slide 2; TikTok's test batch dies there |\n| **Seven slides for a narrative format** (hook, five body, closer) | seven to ten is the reported dwell-time sweet spot; under five reads as a short post, over ten causes mid-carousel fatigue |\n| **No slide may be skippable.** If a body slide can be removed without breaking the post, you wrote a list, not an experience | completion is the ranking signal, and a list lets people stop anywhere |\n| **The peak lands in the last third** | a middle peak makes the tail a letdown, and the tail is where completion is won |\n| **Design for the send, not the like.** At least one slide should make someone think of a specific person | sends per reach is the non-follower signal, worth several likes |\n| **The send prompt lives in the caption**, naming one kind of person, never \"share if you relate\" | it belongs where sends are earned, and it keeps the last slide screenshottable |\n| **At least two slides hand over something usable** | recognition earns likes; recognition plus something doable earns saves, and saves compound |\n| **Captions carry the audience's own search phrases, in prose** | captions are indexed; hashtags are not distribution |\n| **Caption length 400-600 characters on Instagram, 150-300 on TikTok**, first line an independent hook under 125 characters | that is where the \"more\" cut falls; past roughly 300 characters TikTok needs a tap and reach drops |\n| **Three to five hashtags, never the generic feed tag** | labels, not reach |\n| **One canvas at 4:5, content clear of the top and bottom edges** | survives the 3:4 grid crop, letterboxes acceptably on TikTok |\n\n## Cadence, and the first hour\n\n**Three to five posts a week, sustained.** Three a week for twelve weeks beats\nseven a week for three. The failure mode is not low quality, it is stopping - and\nthe documented version of it is 34 drafts and 3 published posts.\n\n**The first hour is the test batch.** Being there to answer early comments is the\ncheapest intervention available on either platform; a comment answered in the\nfirst hour is worth more than the same reply a day later.\n\n**Post at a fixed time** your audience is awake for, set once on the account's\nposting schedule rather than decided per post.\n\n**Expect silence for two months.** A new account with no face takes three to six\nmonths to reach a thousand followers on Instagram. Distribution is a power law:\na few posts carry most of the reach. Judging before 90 days is judging noise.\n\n## When something works, fan it out\n\nA post that breaks out is the beginning of the work, not the end.\n\n1. Make five to ten variants of the winner with the structure fixed and **exactly\n one variable changed** - the hook phrasing, the register, the opening image.\n2. One variable per variant, or the result teaches nothing.\n3. Log which variant won *and* which source line its hook came from. That tells\n you which well to keep digging.\n4. A losing variant is data. Kill it rather than nursing it.\n\n## What is worth not believing\n\nEverything above is published guidance and platform statements, not our\nmeasurements. Before you treat any of it as settled for **your** audience, these\nare the clean one-variable tests: caption length judged on saves and sends per\nreach; a quiet text card against a photo behind text; a native 9:16 crop against\na letterboxed 4:5; whether a screenshot of your app costs reach or buys installs.\n\nLog the real numbers per post at seven days - views, completion, saves, sends,\ncomments, profile taps. The moment you have your own numbers they outrank every\nsource below.\n\n## Sources\n\n- [Instagram algorithm ranking signals (Buffer)](https://buffer.com/resources/instagram-algorithms/) - watch time, sends per reach, likes per reach, per-surface ranking\n- [The ranking signals that matter (Clixie)](https://www.clixie.ai/blog/instagram-algorithm) - sends weighted three to five times a like\n- [How carousels beat Reels for engagement (Storrito)](https://storrito.com/resources/how-instagram-carousels-beat-reels-for-engagement-in-2026-and-when-to-use-each/) - re-serving from slide 2, reach multiple\n- [Carousel best practices (Adpicto)](https://www.adpicto.com/en/blog/instagram-carousel-best-practices-2026) - slide count, dwell time, saves\n- [Carousel algorithm (TryMyPost)](https://www.trymypost.com/blog/instagram-carousel-algorithm-strategy-2026) - dwell time and completion\n- [Do hashtags still work (Kontentino)](https://www.kontentino.com/q-and-a/instagram-hashtags-reach/) - hashtags do not drive reach\n- [Keywords versus hashtags (Dive Media)](https://www.divemedia.com.au/marketing-tips-and-insights/social-seo-keywords-vs-hashtags) - caption indexing and the reach lift\n- [TikTok photo mode algorithm (ReelBase)](https://reelbase.io/blog/tiktok-photo-mode-algorithm-explained) - photo mode reach against video\n- [TikTok carousel algorithm (PostWaffle)](https://www.postwaffle.com/blog/tiktok-carousel-algorithm) - swipe-through, reverse swipes, test batch size, saves\n",
1588
- "revision": "6799e74c42287dc5"
1635
+ "markdown": "# Why a post travels\n\n**What this guide does:** Explains what Instagram and TikTok actually reward,\nand turns each mechanic into a rule you can build a post against. Read it before\ndeciding a format, a slide count or a caption length, and when a post that\nlooked good got no reach.\n\nWhether a post is *good* and whether it *travels* are two different questions.\nThis one is about the second. Every rule here exists because of a specific\nmechanic, and if the mechanic changes the rule goes with it, so each one carries\nits reason.\n\n**Verified against published sources 2026-08-04.** Platform mechanics decay.\nTreat anything here as a claim about a moving system, re-check it quarterly, and\nprefer your own numbers the moment you have them.\n\n## Instagram\n\n**The three named ranking signals are watch time, sends per reach, and likes per\nreach.** Sends are the heaviest, reported at roughly three to five times the\nweight of a like. Sends per reach is specifically the signal that reaches people\nwho do not follow you, which is the only reach a new account can grow on.\n\n**Feed, Reels, Stories and Explore rank separately** and weight those signals\ndifferently. Feed leans on how close you already are to the viewer; Explore on\nengagement velocity and interest match. A post that does well with your existing\nfollowers is not automatically a post that travels.\n\n**Why carousels, specifically:**\n\n- Every swipe is engagement, and dwell time accumulates across the slides.\n Carousels are reported at two to three times the reach of a single image for\n the same content.\n- **The platform re-serves a carousel to people who did not swipe, starting from\n the second slide.** This is the most actionable mechanic available: slide two\n gets an independent second chance to be someone's first impression.\n- Carousels out-save single images by a wide margin, and saves compound, because\n saved posts get resurfaced.\n- Completion - how many people reach the last slide - is what pushes a post out\n of your followers and into Explore.\n\n**Hashtags do not drive distribution.** The platform's own position is that they\ncategorise rather than distribute. Discovery comes from the words in your\ncaption: captions, alt text, bios and on-screen text are indexed and served into\nin-app search. Keyword-rich captions have been measured at around 30% more reach\nthan hashtag-heavy ones. Keep three to five hashtags as labels and spend the\neffort on the prose.\n\n## TikTok, photo mode\n\n- Ranking is swipe-through rate, dwell time and reverse swipes, with completion\n rate as the primary signal.\n- Distribution starts with a **small test batch of roughly 200-500 viewers**,\n mostly followers and people who engage with adjacent content. What that batch\n does decides everything afterwards. This is why the first hour matters, and\n why a weak second slide can end the post’s reach.\n- Saves are weighted and photo posts save well. A carousel with fewer views but\n a high save-and-comment share is doing better than a higher-view post that\n bounces on slide one.\n- Interest-based distribution means an outlier is possible from your first post.\n TikTok is spikier; Instagram grinds.\n\n## What follows for how you build a post\n\n| Rule | Because |\n|---|---|\n| **Slide 2 is a second hook.** It delivers on slide 1 *and* opens a new thread, and it has to work cold | the platform re-serves from slide 2; TikTok's test batch dies there |\n| **Seven slides for a narrative format** (hook, five body, closer) | seven to ten is the reported dwell-time sweet spot; under five reads as a short post, over ten causes mid-carousel fatigue |\n| **No slide may be skippable.** If a body slide can be removed without breaking the post, you wrote a list, and people can stop anywhere in a list | completion is the ranking signal, and a list lets people stop anywhere |\n| **The peak lands in the last third** | a middle peak makes the tail a letdown, and the tail is where completion is won |\n| **Design for sends.** At least one slide should make someone think of a specific person | sends per reach is the non-follower signal, worth several likes |\n| **The send prompt lives in the caption**, naming one kind of person, never \"share if you relate\" | it belongs where sends are earned, and it keeps the last slide screenshottable |\n| **At least two slides hand over something usable** | recognition earns likes; recognition plus something doable earns saves, and saves compound |\n| **Captions carry the audience's own search phrases, in prose** | captions are indexed; hashtags are not distribution |\n| **Caption length 400-600 characters on Instagram, 150-300 on TikTok**, first line an independent hook under 125 characters | that is where the \"more\" cut falls; past roughly 300 characters TikTok needs a tap and reach drops |\n| **Three to five hashtags, never the generic feed tag** | labels, not reach |\n| **One canvas at 4:5, content clear of the top and bottom edges** | survives the 3:4 grid crop, letterboxes acceptably on TikTok |\n\n## Cadence, and the first hour\n\n**Three to five posts a week, sustained.** Three a week for twelve weeks beats\nseven a week for three. The failure mode is not low quality, it is stopping - and\nthe documented version of it is 34 drafts and 3 published posts.\n\n**The first hour is the test batch.** Being there to answer early comments is the\ncheapest intervention available on either platform; a comment answered in the\nfirst hour is worth more than the same reply a day later.\n\n**Post at a fixed time** your audience is awake for, set once on the account's\nposting schedule rather than decided per post.\n\n**Expect silence for two months.** A new account with no face takes three to six\nmonths to reach a thousand followers on Instagram. Distribution is a power law:\na few posts carry most of the reach. Judging before 90 days is judging noise.\n\n## When something works, fan it out\n\nWhen a post breaks out, the work starts: more like it, and the best one promoted.\n\n1. Make five to ten variants of the winner with the structure fixed and **exactly\n one variable changed** - the hook phrasing, the register, the opening image.\n2. One variable per variant, or the result teaches nothing.\n3. Log which variant won *and* which source line its hook came from. That tells\n you which well to keep digging.\n4. A losing variant tells you something. Stop it and write down what.\n\n## What is worth not believing\n\nEverything above is published guidance and platform statements, not our\nmeasurements. Before you treat any of it as settled for **your** audience, these\nare the clean one-variable tests: caption length judged on saves and sends per\nreach; a quiet text card against a photo behind text; a native 9:16 crop against\na letterboxed 4:5; whether a screenshot of your app costs reach or buys installs.\n\nLog the real numbers per post at seven days - views, completion, saves, sends,\ncomments, profile taps. The moment you have your own numbers they outrank every\nsource below.\n\n## Sources\n\n- [Instagram algorithm ranking signals (Buffer)](https://buffer.com/resources/instagram-algorithms/) - watch time, sends per reach, likes per reach, per-surface ranking\n- [The ranking signals that matter (Clixie)](https://www.clixie.ai/blog/instagram-algorithm) - sends weighted three to five times a like\n- [How carousels beat Reels for engagement (Storrito)](https://storrito.com/resources/how-instagram-carousels-beat-reels-for-engagement-in-2026-and-when-to-use-each/) - re-serving from slide 2, reach multiple\n- [Carousel best practices (Adpicto)](https://www.adpicto.com/en/blog/instagram-carousel-best-practices-2026) - slide count, dwell time, saves\n- [Carousel algorithm (TryMyPost)](https://www.trymypost.com/blog/instagram-carousel-algorithm-strategy-2026) - dwell time and completion\n- [Do hashtags still work (Kontentino)](https://www.kontentino.com/q-and-a/instagram-hashtags-reach/) - hashtags do not drive reach\n- [Keywords versus hashtags (Dive Media)](https://www.divemedia.com.au/marketing-tips-and-insights/social-seo-keywords-vs-hashtags) - caption indexing and the reach lift\n- [TikTok photo mode algorithm (ReelBase)](https://reelbase.io/blog/tiktok-photo-mode-algorithm-explained) - photo mode reach against video\n- [TikTok carousel algorithm (PostWaffle)](https://www.postwaffle.com/blog/tiktok-carousel-algorithm) - swipe-through, reverse swipes, test batch size, saves\n",
1636
+ "revision": "a00738c55be34d96"
1589
1637
  },
1590
1638
  {
1591
1639
  "id": "ads",
@@ -63,7 +63,7 @@ op('lemonsqueezy', 'subscription-invoices', 'Read initial, renewal and update in
63
63
  op('paddle', 'transactions', 'Read billed transactions by time, status, customer or subscription.', { ...dates, status: array(choice('Status.', ['draft', 'ready', 'billed', 'paid', 'completed', 'canceled', 'past_due']), 7), customer_id: id('Paddle customer ID.'), subscription_id: id('Paddle subscription ID.'), limit, cursor }, [], { start: '2026-08-01', end: '2026-09-01', status: ['completed'], limit: 100 });
64
64
  op('paddle', 'subscriptions', 'Read subscription lifecycles by status or price; optional creation dates filter each returned page locally.', { ...dates, status: array(choice('Status.', ['active', 'canceled', 'past_due', 'paused', 'trialing']), 5), customer_id: id('Paddle customer ID.'), price_id: id('Paddle price ID.'), limit, cursor }, [], { status: ['trialing'], limit: 100 });
65
65
  for (const source of Object.keys(QUERY_SOURCES).filter(s => QUERY_SOURCES[s].stored)) {
66
- op(source, 'posts', 'Filter stored publishing outcomes with the latest metric snapshot before the exclusive end.', { ...dates, status: choice('Publishing status.', ['published', 'failed', 'missed', 'scheduled', 'pending', 'rendering', 'publishing', 'awaiting_runner']), contentType: choice('Post type.', ['carousel', 'reel']), limit, offset: integer('Zero-based offset.', 100000, 0) }, ['start', 'end'], { start: '2026-08-01', end: '2026-09-01', status: 'published', limit: 100 });
66
+ op(source, 'posts', 'Filter stored publishing outcomes with the latest metric snapshot before the exclusive end.', { ...dates, status: choice('Publishing status.', ['published', 'failed', 'missed', 'scheduled', 'pending', 'rendering', 'publishing']), contentType: choice('Post type.', ['carousel', 'reel']), limit, offset: integer('Zero-based offset.', 100000, 0) }, ['start', 'end'], { start: '2026-08-01', end: '2026-09-01', status: 'published', limit: 100 });
67
67
  op(source, 'comments', 'Read feedback already collected into Sprid’s Inbox, with optional post and reply filters.', { ...dates, postId: integer('Sprid post ID.', 1000000000), replied: { type: 'boolean', description: 'Filter comments by reply state.' }, limit, offset: integer('Zero-based offset.', 100000, 0) }, ['start', 'end'], { start: '2026-08-01', end: '2026-09-01', replied: false, limit: 100 });
68
68
  }
69
69
 
@@ -0,0 +1,94 @@
1
+ // Is the agent running the latest Sprid skills?
2
+ //
3
+ // The skills ship as a plugin (Claude Code, Codex) or as copied skill folders
4
+ // (`npx skills add`), never through npm, so the CLI's own registry check says
5
+ // nothing about them. A host caches an installed plugin and updates it only when
6
+ // asked, which is how an agent ran month-old skills while three releases sat in
7
+ // the repository. The skills pass their own root with `sprid doctor --plugin`.
8
+ import { readFileSync, realpathSync, existsSync } from 'node:fs';
9
+ import { join, sep } from 'node:path';
10
+ import { runProcess, stableVersion, compareVersions, stateDirectory } from './updates.mjs';
11
+ import { writeFileSync, mkdirSync, renameSync, rmSync } from 'node:fs';
12
+ import { dirname } from 'node:path';
13
+ import { randomUUID } from 'node:crypto';
14
+
15
+ export const PLUGIN_SOURCE = 'https://raw.githubusercontent.com/sprid-studio/plugin/main/package.json';
16
+ const PLUGIN_PACKAGE = 'sprid-plugin';
17
+ const DAY = 86_400_000;
18
+ const read = path => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
19
+ const real = path => { try { return realpathSync(path); } catch { return null; } };
20
+ const safeName = value => /^[A-Za-z0-9._-]{1,100}$/.test(value);
21
+
22
+ function saveJson(path, data) {
23
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
24
+ const tmp = `${path}.${randomUUID()}.tmp`;
25
+ try { writeFileSync(tmp, JSON.stringify(data) + '\n', { mode: 0o600, flag: 'wx' }); renameSync(tmp, path); }
26
+ finally { rmSync(tmp, { force: true }); }
27
+ }
28
+
29
+ /** Which host installed the skills, read off the directory the host put them in. */
30
+ export function detectPluginHost(root) {
31
+ const parts = root.split(sep);
32
+ const at = (a, b) => parts.findIndex((part, i) => part === a && parts[i + 1] === 'plugins' && parts[i + 2] === 'cache');
33
+ const claude = at('.claude'), codex = at('.codex');
34
+ if (claude >= 0 && safeName(parts[claude + 3] ?? '') && safeName(parts[claude + 4] ?? '')) {
35
+ const marketplace = parts[claude + 3], plugin = parts[claude + 4];
36
+ return { kind: 'claude', marketplace, reload: 'Run /reload-plugins, or start a new session, to load the new skills.',
37
+ commands: [['claude', ['plugin', 'marketplace', 'update', marketplace]], ['claude', ['plugin', 'update', `${plugin}@${marketplace}`]]] };
38
+ }
39
+ if (codex >= 0 && safeName(parts[codex + 3] ?? '') && safeName(parts[codex + 4] ?? '')) {
40
+ const marketplace = parts[codex + 3], plugin = parts[codex + 4];
41
+ return { kind: 'codex', marketplace, reload: 'Start a new Codex thread to load the new skills.',
42
+ commands: [['codex', ['plugin', 'marketplace', 'upgrade', marketplace]], ['codex', ['plugin', 'add', `${plugin}@${marketplace}`]]] };
43
+ }
44
+ if (existsSync(join(root, '.git'))) return { kind: 'unsupported', reason: 'Source checkout. Update it with git.' };
45
+ return { kind: 'skills', reload: 'Start a new agent session to load the new skills.', commands: [['npx', ['-y', 'skills', 'update', '-y']]] };
46
+ }
47
+
48
+ /** Registry failure is data, never a reason to block work. Cached for a day, like the CLI check. */
49
+ export async function checkPlugin({ root, env = process.env, fetchImpl = globalThis.fetch, now = Date.now(), offline = false } = {}) {
50
+ const resolved = real(root);
51
+ if (!resolved) return { check: 'unavailable', reason: 'The plugin root does not exist.' };
52
+ const manifest = read(join(resolved, 'package.json'));
53
+ const current = manifest?.name === PLUGIN_PACKAGE && stableVersion(manifest.version) ? manifest.version : null;
54
+ const host = detectPluginHost(resolved);
55
+
56
+ const path = join(stateDirectory(env), 'plugin-latest.json');
57
+ const cached = read(path);
58
+ let record = cached;
59
+ if (!offline && !(cached && Number.isFinite(cached.checkedAt) && now >= cached.checkedAt && now - cached.checkedAt < DAY)) {
60
+ try {
61
+ const res = await fetchImpl(PLUGIN_SOURCE, { redirect: 'error', signal: AbortSignal.timeout(2000), headers: { Accept: 'application/json' } });
62
+ if (!res.ok) throw new Error('unavailable');
63
+ const text = await res.text();
64
+ if (text.length > 65536) throw new Error('too large');
65
+ const data = JSON.parse(text);
66
+ if (data.name !== PLUGIN_PACKAGE || !stableVersion(data.version)) throw new Error('invalid');
67
+ record = { checkedAt: now, version: data.version, available: true };
68
+ } catch { record = { checkedAt: now, version: stableVersion(cached?.version) ? cached.version : null, available: false }; }
69
+ try { saveJson(path, record); } catch { /* A read-only home must not prevent checking. */ }
70
+ }
71
+ const latest = stableVersion(record?.version) ? record.version : null;
72
+ const verified = !offline && record?.available === true;
73
+ // Skill folders copied by `npx skills add` carry no package.json, so their
74
+ // version is unknown; the update command is still the right advice.
75
+ const updateAvailable = Boolean(latest && (current ? compareVersions(latest, current) > 0 : host.kind === 'skills'));
76
+ return {
77
+ current, latest, check: verified ? 'verified' : 'unavailable', updateAvailable,
78
+ host: host.kind, ...(host.marketplace ? { marketplace: host.marketplace } : {}),
79
+ updateCommand: host.commands ? host.commands.map(([cmd, args]) => [cmd, ...args].join(' ')).join(' && ') : null,
80
+ reload: host.reload ?? null, ...(host.reason ? { reason: host.reason } : {}),
81
+ source: PLUGIN_SOURCE,
82
+ };
83
+ }
84
+
85
+ /** Run the host's own update commands. The host, not Sprid, writes its plugin cache. */
86
+ export async function updatePlugin(root, { env = process.env, runner = runProcess } = {}) {
87
+ const host = detectPluginHost(real(root) ?? root);
88
+ if (!host.commands) throw new Error(host.reason || 'Cannot identify how these skills were installed.');
89
+ for (const [command, args] of host.commands) {
90
+ try { await runner(command, args, { cwd: env.HOME, env, timeout: 120000 }); }
91
+ catch { return { updated: false, failed: [command, ...args].join(' ') }; }
92
+ }
93
+ return { updated: true, reload: host.reload };
94
+ }
package/src/telemetry.mjs CHANGED
@@ -1,28 +1,41 @@
1
- // Usage telemetry: after a command finishes, one small POST to the Sprid API
1
+ // Usage telemetry, two kinds, one switch.
2
+ //
3
+ // Command events: after a command finishes, one small POST to the Sprid API
2
4
  // saying which command ran, how it ended and how long it took. What is sent
3
5
  // is exactly `commandEvent`'s fields: the command, a subcommand only when it
4
6
  // is one this CLI documents, the exit code, an error KIND (an HTTP status or
5
7
  // usage/network/other, never the message), the duration, the CLI and Node
6
8
  // versions, the OS and whether it ran in CI. Never arguments, file paths,
7
- // slugs, output or anything read from disk.
9
+ // slugs, output or anything read from disk. It goes to the API the CLI is
10
+ // already signed in to, under the same login; signed out sends none.
11
+ //
12
+ // Install ping: at most once a day per surface, signed in or not, an
13
+ // anonymous POST with exactly `installEvent`'s fields - a random id made on
14
+ // this machine, the surface (cli, or the host the skills were installed
15
+ // through when `doctor --plugin` ran), the CLI and skills versions, OS, Node,
16
+ // CI and whether a login exists. No token, no account, no workspace. It is
17
+ // how installs that never sign in get counted at all.
8
18
  //
9
- // It goes to the API the CLI is already signed in to, under the same login,
10
- // and nowhere else. Signed out means nothing is sent. Off with
11
- // `sprid telemetry off`, DO_NOT_TRACK=1 or SPRID_TELEMETRY=0. The first run
12
- // that could send only prints the notice; nothing leaves before the person
13
- // has been told.
19
+ // Off with `sprid telemetry off`, DO_NOT_TRACK=1 or SPRID_TELEMETRY=0. The
20
+ // first run that could send only prints the notice; nothing leaves before
21
+ // the person has been told. The notice is versioned: someone told only about
22
+ // command events is told again before the first install ping.
14
23
 
24
+ import { randomUUID } from 'node:crypto';
15
25
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
16
26
  import { homedir } from 'node:os';
17
27
  import { dirname, join } from 'node:path';
18
28
  import { COMMAND_GROUPS } from './docs/commands.mjs';
19
29
  import { UsageError } from './args.mjs';
30
+ import { DEFAULT_API_URL } from './creds.mjs';
20
31
 
21
32
  const SEND_TIMEOUT_MS = 1000;
22
33
  /** Commands that report nothing: they are about the CLI itself, or run for hours. */
23
34
  const SILENT = new Set(['help', 'version', 'completion', 'mcp', 'telemetry']);
24
35
 
25
- export const NOTICE = 'Sprid CLI sends usage telemetry to your Sprid account: the command name, exit code and duration, never arguments or file contents. Turn it off with `sprid telemetry off` or DO_NOT_TRACK=1.';
36
+ export const NOTICE = 'Sprid CLI sends usage telemetry: once a day an anonymous install id with the CLI and skills versions, and when you are signed in each command\'s name, exit code and duration. Never arguments or file contents. Turn it off with `sprid telemetry off` or DO_NOT_TRACK=1.';
37
+ /** Bumped when the notice starts covering something new. 1 covered command events only. */
38
+ export const NOTICE_VERSION = 2;
26
39
 
27
40
  export function settingsPath(env = process.env) {
28
41
  return join(env.HOME || homedir(), '.sprid', 'telemetry.json');
@@ -47,7 +60,9 @@ export function telemetryState(env = process.env) {
47
60
  if (falsy(env.SPRID_TELEMETRY)) return { enabled: false, reason: 'SPRID_TELEMETRY is off' };
48
61
  const settings = readSettings(env);
49
62
  if (settings.enabled === false) return { enabled: false, reason: 'turned off with sprid telemetry off' };
50
- return { enabled: true, reason: settings.enabled === true ? 'turned on with sprid telemetry on' : 'on by default', noticed: Boolean(settings.noticedAt) };
63
+ // A notice seen before versioning (noticedAt, no version) covered command events only.
64
+ const noticeVersion = settings.noticedAt ? (Number(settings.noticeVersion) || 1) : 0;
65
+ return { enabled: true, reason: settings.enabled === true ? 'turned on with sprid telemetry on' : 'on by default', noticed: noticeVersion >= 1, noticeVersion };
51
66
  }
52
67
 
53
68
  let documented;
@@ -92,34 +107,89 @@ export function commandEvent({ command, words, exitCode, durationMs, error, vers
92
107
  };
93
108
  }
94
109
 
110
+ /** Prints the notice once per notice version and records it. True when it printed, so the run sends nothing. */
111
+ function noticeFirst(ctx, state) {
112
+ if (state.noticeVersion >= NOTICE_VERSION) return false;
113
+ ctx.warn(` ${NOTICE}`);
114
+ try { writeSettings({ ...readSettings(ctx.env), noticedAt: new Date().toISOString(), noticeVersion: NOTICE_VERSION }, ctx.env); } catch { /* A read-only home only means the notice repeats. */ }
115
+ return true;
116
+ }
117
+
118
+ async function post(ctx, url, body, headers = {}) {
119
+ const res = await ctx.fetch(url, {
120
+ method: 'POST',
121
+ redirect: 'error',
122
+ signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
123
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': `sprid/${ctx.version}`, ...headers },
124
+ body: JSON.stringify(body),
125
+ });
126
+ await res.body?.cancel?.().catch?.(() => {});
127
+ return res.ok;
128
+ }
129
+
95
130
  /**
96
- * Report one finished command. Never throws and never waits longer than
97
- * SEND_TIMEOUT_MS: a slow network costs the person at most a second, and a
98
- * failure costs them nothing.
131
+ * Report one finished command: the install ping when today's is due, and the
132
+ * command event when signed in. Never throws and never waits longer than
133
+ * SEND_TIMEOUT_MS: the two requests run side by side, so a slow network costs
134
+ * the person at most a second, and a failure costs them nothing.
99
135
  */
100
136
  export async function reportCommand(ctx, { command, words, exitCode, durationMs, error }) {
101
137
  try {
102
138
  if (SILENT.has(command)) return false;
103
139
  const state = telemetryState(ctx.env);
104
140
  if (!state.enabled) return false;
105
- let auth;
106
- try { auth = ctx.auth(); } catch { return false; }
107
- if (!state.noticed) {
108
- ctx.warn(` ${NOTICE}`);
109
- try { writeSettings({ ...readSettings(ctx.env), noticedAt: new Date().toISOString() }, ctx.env); } catch { /* A read-only home only means the notice repeats. */ }
110
- return false;
141
+ let auth = null;
142
+ try { auth = ctx.auth(); } catch { /* Signed out: the install ping still counts this machine. */ }
143
+ if (noticeFirst(ctx, state)) return false;
144
+ const sends = [reportInstall(ctx, { signedIn: Boolean(auth) })];
145
+ if (auth) {
146
+ const workspaceId = ctx._workspace?.id ?? auth.workspace?.id ?? null;
147
+ const event = commandEvent({ command, words, exitCode, durationMs, error, version: ctx.version, env: ctx.env, workspaceId });
148
+ sends.push(post(ctx, `${auth.apiUrl}/api/cli/events`, { events: [event] }, { Authorization: `Bearer ${auth.token}` }).catch(() => false));
111
149
  }
112
- const workspaceId = ctx._workspace?.id ?? auth.workspace?.id ?? null;
113
- const event = commandEvent({ command, words, exitCode, durationMs, error, version: ctx.version, env: ctx.env, workspaceId });
114
- const res = await ctx.fetch(`${auth.apiUrl}/api/cli/events`, {
115
- method: 'POST',
116
- redirect: 'error',
117
- signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
118
- headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${auth.token}`, 'User-Agent': `sprid/${ctx.version}` },
119
- body: JSON.stringify({ events: [event] }),
120
- });
121
- await res.body?.cancel?.().catch?.(() => {});
122
- return res.ok;
150
+ const results = await Promise.all(sends);
151
+ return results.some(Boolean);
152
+ } catch {
153
+ return false;
154
+ }
155
+ }
156
+
157
+ const today = (now = new Date()) => now.toISOString().slice(0, 10);
158
+
159
+ /**
160
+ * The anonymous install ping. `surface` is `cli`, or the host the skills came
161
+ * through (`claude`, `codex`, `skills`) when `sprid doctor --plugin` reported
162
+ * one this run, which every Sprid skill does once per session.
163
+ */
164
+ export function installEvent({ installId, version, plugin = null, signedIn, env = process.env, now = new Date() }) {
165
+ return {
166
+ installId,
167
+ // `unsupported` is a git checkout of the skills: ours, almost always, and kept apart.
168
+ surface: !plugin?.host ? 'cli' : plugin.host === 'unsupported' ? 'source' : plugin.host,
169
+ version,
170
+ pluginVersion: plugin?.current ?? null,
171
+ platform: process.platform,
172
+ node: process.version,
173
+ ci: Boolean(env.CI && !falsy(env.CI)),
174
+ signedIn: Boolean(signedIn),
175
+ at: now.toISOString(),
176
+ };
177
+ }
178
+
179
+ /** Sends today's ping for this surface if it has not gone yet. Caller has checked consent. */
180
+ export async function reportInstall(ctx, { signedIn, now = new Date() }) {
181
+ try {
182
+ const settings = readSettings(ctx.env);
183
+ const installId = /^[0-9a-f-]{36}$/.test(settings.installId ?? '') ? settings.installId : randomUUID();
184
+ const event = installEvent({ installId, version: ctx.version, plugin: ctx.pluginInfo ?? null, signedIn, env: ctx.env, now });
185
+ const pinged = settings.pinged && typeof settings.pinged === 'object' ? settings.pinged : {};
186
+ if (pinged[event.surface] === today(now)) return false;
187
+ // Recorded before sending: a failed ping is not retried the same day,
188
+ // which keeps a machine offline all day from paying a timeout per command.
189
+ try { writeSettings({ ...settings, installId, pinged: { ...pinged, [event.surface]: today(now) } }, ctx.env); } catch { return false; }
190
+ let apiUrl = DEFAULT_API_URL;
191
+ try { apiUrl = ctx.auth().apiUrl; } catch { if (ctx.env.SPRID_URL) apiUrl = ctx.env.SPRID_URL; }
192
+ return await post(ctx, `${apiUrl}/api/installs/ping`, event);
123
193
  } catch {
124
194
  return false;
125
195
  }
@@ -129,12 +199,13 @@ export async function reportCommand(ctx, { command, words, exitCode, durationMs,
129
199
  export async function telemetry(ctx) {
130
200
  const action = ctx.positionals[0] ?? 'status';
131
201
  if (action === 'on' || action === 'off') {
132
- writeSettings({ ...readSettings(ctx.env), enabled: action === 'on', noticedAt: readSettings(ctx.env).noticedAt ?? new Date().toISOString() }, ctx.env);
202
+ // Choosing on or off is having read what it does, so it counts as the current notice.
203
+ writeSettings({ ...readSettings(ctx.env), enabled: action === 'on', noticedAt: readSettings(ctx.env).noticedAt ?? new Date().toISOString(), noticeVersion: NOTICE_VERSION }, ctx.env);
133
204
  } else if (action !== 'status') {
134
205
  throw new UsageError(`Unknown telemetry action "${action}". Use status, on or off.`);
135
206
  }
136
207
  const state = telemetryState(ctx.env);
137
208
  if (ctx.json) ctx.out({ enabled: state.enabled, reason: state.reason, settings: settingsPath(ctx.env) });
138
- else ctx.print(` Telemetry is ${state.enabled ? 'on' : 'off'} (${state.reason}).${state.enabled ? ' Sent: command, exit code, duration, versions, OS. Never arguments or file contents.' : ''}`);
209
+ else ctx.print(` Telemetry is ${state.enabled ? 'on' : 'off'} (${state.reason}).${state.enabled ? ' Sent: once a day an anonymous install id with versions and OS; signed in, each command, exit code and duration. Never arguments or file contents.' : ''}`);
139
210
  return 0;
140
211
  }
package/src/updates.mjs CHANGED
@@ -110,6 +110,10 @@ function keyFor(installation) { return createHash('sha256').update(installation.
110
110
  export function autoEnabled(installation, env = process.env) {
111
111
  return read(join(stateDirectory(env), `${keyFor(installation)}.json`))?.auto === true;
112
112
  }
113
+ /** Whether anyone has answered on/off for this installation, so an agent asks once. */
114
+ export function autoDecided(installation, env = process.env) {
115
+ return typeof read(join(stateDirectory(env), `${keyFor(installation)}.json`))?.auto === 'boolean';
116
+ }
113
117
  export function setAuto(installation, enabled, env = process.env) {
114
118
  if (installation.kind === 'unsupported') throw new Error(installation.reason);
115
119
  atomicJson(join(stateDirectory(env), `${keyFor(installation)}.json`), { auto: enabled });